ordainer 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -1
- package/README.md +18 -16
- package/index.d.ts +2 -2
- package/index.js +16 -10
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
Entries before 1.1.0 are written with the current names. Those versions used the product's former name for the
|
|
4
4
|
package, class, env vars, outage error code, User-Agent and key prefix: the full list is in 1.1.0 "Renamed".
|
|
5
5
|
|
|
6
|
+
## 1.2.6 (2026-11-09)
|
|
7
|
+
|
|
8
|
+
README-only: install with `npm install ordainer`. The code is the same as 1.2.5, byte for byte; only the install
|
|
9
|
+
instructions in this README changed.
|
|
10
|
+
|
|
11
|
+
## 1.2.5 (2026-11-09)
|
|
12
|
+
|
|
13
|
+
Changed: **the retry pause is jittered** (P89-L1), like the Ruby SDK: 50 ms ± 50% (25–75 ms) before the first retry,
|
|
14
|
+
doubling for each further one. Retry count, `retryBudgetMs` and which answers are retried are unchanged. When a server
|
|
15
|
+
sheds load (`503 decide_timeout`, "at capacity"), callers no longer all retry in one wave 50 ms later.
|
|
16
|
+
|
|
17
|
+
Changed: **Error messages reworded; codes, statuses and fields unchanged. Match on error.code, not the message.** `429 plan_limit_reached`: "This call wasn't run because the usage limit was reached (0 of 5 left). It resets at {resets_at}. Retrying before then won't work." The same sentence is used when the key's label is present: the message does not name the key. `error.label` is unchanged. The "It resets at" sentence is still omitted when `resets_at` is missing. A sticky deny reuses this sentence. `403 plan_denied` with `tool_not_in_plan`: "This call wasn't run because "<tool>" isn't included in your current access. If you need it, contact the provider of this service. (tool_not_in_plan)", with the same quoting rules as 1.2.3 (otherwise "this action"). Any other plan deny is unchanged. Status, `error.code`, `reason`, `remaining`, `limit`, `resets_at`, `request_id`, `label`, headers (`Retry-After`, `X-Request-Id`) and whether the call runs are unchanged.
|
|
18
|
+
|
|
19
|
+
This is the first Node SDK build that passes the deny `explanation` through to callers, so it should be published only after the matching API change (#207) is live.
|
|
20
|
+
|
|
6
21
|
## 1.2.4 (2026-10-22)
|
|
7
22
|
|
|
8
23
|
The first release on npm: `npm install ordainer`. The code is the same as 1.2.3, byte for byte; only the install
|
|
@@ -14,7 +29,7 @@ Added (T18-P39): **the 429 `plan_limit_reached` names the agent** when the API's
|
|
|
14
29
|
agent (agent-a) has reached its plan limit (0 of 3 left). …" and `error.label`. Also on `result.label` (denies only; null
|
|
15
30
|
on allows), on a sticky deny, and JSON-escaped in the shadow "would deny" log line. The label is cleaned (control,
|
|
16
31
|
invisible and bidi characters removed, whitespace collapsed) and cut to 64 characters. It is off by default on both
|
|
17
|
-
sides: the API sends `label` only for
|
|
32
|
+
sides: the API sends `label` only once it's turned on for your account (email support), and `exposeKeyLabel: true` opts the SDK in; without
|
|
18
33
|
either, the body and sentence are exactly as before. Without a label (an older server) nothing changes. Status, `error.code`, `Retry-After` and
|
|
19
34
|
whether the call runs are unchanged.
|
|
20
35
|
|
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@ A small wrapper that asks Ordainer (`POST /v1/decide`) before your tool handler
|
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
|
+
<!-- Checked by test/sdk-pack.test.js. LAUNCH SWAP applied (Nov 9 2026): install is `npm install ordainer`.
|
|
9
|
+
Live docs already use that command. This README does not follow sites/docs SDK_INSTALL while SDK_ON_NPM is still false on main. -->
|
|
8
10
|
```bash
|
|
9
11
|
npm install ordainer
|
|
10
12
|
```
|
|
@@ -51,14 +53,14 @@ if (d.allowed) { /* run it */ }
|
|
|
51
53
|
| Situation | Default result |
|
|
52
54
|
|---|---|
|
|
53
55
|
| Ordainer says allow | Handler runs. `req.ordainer` holds the decision (`remaining`, `limit`, `requestId`, …). `remaining` and `limit` are `number \| null`: `null` on a plan with no cap (and when Ordainer didn't answer), so check before doing arithmetic. |
|
|
54
|
-
| Ordainer says deny, **enforce** | Handler does not run. `429 plan_limit_reached` with `Retry-After` and a message you can show the agent as-is. `tool_not_in_plan`
|
|
56
|
+
| Ordainer says deny, **enforce** | Handler does not run. The status follows the API `reason`. `quota_exceeded` (the plan allowance) is `429 plan_limit_reached` with `Retry-After` and a message you can show the agent as-is. `402` is only that quota deny, and only when `denyStatus` is set. `tool_not_in_plan`, `credits_exhausted` and `key_credits_exhausted` are `403 plan_denied`, not `402`. `test_limit_reached` is its own row below. `result.decision` is `'deny'`, `result.allowed` is `false` and `result.wouldDeny` is `false` (1.2.2: it's a real deny, not a would-deny). |
|
|
55
57
|
| Ordainer says deny, **shadow** | Handler runs. A `[ordainer] shadow: would deny …` warning is logged, `onDecision` gets `wouldDeny: true`, and the console shows "would deny (call ran)". |
|
|
56
|
-
| Timeout (`timeoutMs`), network error, 5xx, or a 200 that isn't a decision | **Fail open**: the handler runs and a warning is logged. With `failMode: 'closed'` you get `503 ordainer_unavailable` instead. These are the **only** cases `failMode` covers (`403 subscription_inactive` follows `billingLapse`, below). |
|
|
58
|
+
| Timeout (`timeoutMs`), network error, 5xx, or a 200 that isn't a decision | **Fail open**: the handler runs and a warning is logged. This includes `503 decide_timeout` when the Ordainer server is at capacity (it refuses the call at once, before deciding, nothing counted; the message says "at capacity"): retried once (`retries`), then `failMode`. With `failMode: 'closed'` you get `503 ordainer_unavailable` instead. These are the **only** cases `failMode` covers (`403 subscription_inactive` follows `billingLapse`, below). |
|
|
57
59
|
| Any other 4xx (`400`, `401 unknown_key`/`revoked_key`, `403 subscription_required`, `409 idempotency_conflict`, `429` other than `safety_limit_reached`, …) or no agent key configured | **Blocked in any `failMode`** (QA S2): Ordainer answered, and the answer isn't "allow", so the call is never let through unchecked. `plan_check_rejected` with **no `Retry-After`** (a retry won't fix it): `500` for a key, config, `400` or `409` problem, `403` for `subscription_required` (Q2; never `503`, which means "outage, retry"); or `429 rate_limited` with `Retry-After` for a rate limit; `result.rejected: true` (`failClosed` stays `false`), `result.error` has the code and status. A "rejected (call blocked in any failMode): <code>: <message> X-Request-Id=req_… tool=…" warning is logged. The warning is logged at most once a minute per failure mode and error code (`warningIntervalMs`), with "(N more since the last warning)", so an outage doesn't flood your logs; `onDecision` still sees every result. |
|
|
58
60
|
| `403 subscription_inactive`: your own Ordainer subscription is inactive | A **billing state, not an outage**. Follows the **billing-lapse setting `billingLapse`**, never `failMode` (Terms 10.3(b)): `'open'` (default) runs the handler, unchecked and uncounted (`result.allowed: true`, `result.failOpen: false`); `'closed'` (opt-in) blocks with `403 plan_check_rejected`, **no `Retry-After`** and a neutral message (`result.rejected: true`). Either way: its own warning at most once a minute (not the generic one), `result.billingInactive: true`, `result.billingLapse`, `result.reason: 'subscription_inactive'` and `onBillingInactive(result)` on every call. See below. |
|
|
59
61
|
| `403 subscription_required`: your Ordainer account has never had a paid subscription (live key) | **Blocked in any `failMode`**, like other 4xx (`result.rejected: true`). It gets its own billing warning (`[ordainer] subscription required: … no paid subscription yet … Subscribe …`, never "subscription inactive"), `result.billingInactive: true`, `result.reason: 'subscription_required'` and `onBillingInactive(result)`. |
|
|
60
|
-
| `429 rate_limited`: this agent key sent more than the per-key limit
|
|
61
|
-
| `429 concurrency_limited`: this agent key already has too many plan checks in flight at once on one Ordainer server (4 in flight at once per key on our hosted service; up to 20 more wait
|
|
62
|
+
| `429 rate_limited`: this agent key sent more than the per-key limit. About 100 requests a second, each server counts separately. | A **rate limit, not an outage**. Blocked in any `failMode` with `429 rate_limited` and the server's `Retry-After`. Never silent: its own warning at most once a minute, `result.rateLimited: true`, `result.error.limitScope: 'per_key'`, `result.retryAfterSec`, and `onRateLimited(result)` on every such call. |
|
|
63
|
+
| `429 concurrency_limited`: this agent key already has too many plan checks in flight at once on one Ordainer server (4 in flight at once per key on our hosted service; up to 20 more wait in line within the server's 250 ms deadline, so small bursts just serialize). Also a call that waited in that line and then found the server at capacity (the message says so): it is still this key's burst, so still a 429 | Handled **exactly like `429 rate_limited`**: blocked in any `failMode` (never a fail-open; it is answered inside the timeout), `429 rate_limited` with `Retry-After` to the end user, `result.rateLimited: true`, `result.reason: 'concurrency_limited'`, `result.error.limitScope: 'per_key_concurrency'`, `onRateLimited(result)`, and its own "concurrency limited" warning at most once a minute. Send fewer calls in parallel per agent key. For abuse-prone tools consider `failMode: 'closed'`. |
|
|
62
64
|
| `429 too_many_failed_auth`: too many requests with a missing or unrecognized agent key came from your server's address (Ordainer's per-address limit; never sent for a valid key) | A **bad key, not a rate problem**: rejected like other 4xx (`result.rejected: true`, `result.rateLimited: false`, `result.error.limitScope: 'per_address'`; `onRateLimited` is not called). Its own warning: "too many requests with a missing or unrecognized agent key from this server's address (Ordainer's per-address limit …) Check the agent key …". Fix the key; retrying won't help. |
|
|
63
65
|
| Deny `test_limit_reached`: your account used its free test-key decisions for the month (test keys only) | **Enforce: blocks** regardless of `failMode`: `429 test_limit_reached` with `Retry-After` until the 1st (UTC). **Shadow (1.2.0): runs.** Shadow never blocks: the server counts the call and records a would-deny `test_limit_reached` (answer `mode: 'shadow'`), and the handler runs (`result.allowed: true`, `result.wouldDeny: true`). Not an outage and not your customer's plan. Both: `result.testLimitReached: true`, its own warning at most once a minute, and `onTestLimit(result)` on every such call. Live keys are never affected. |
|
|
64
66
|
| `429 safety_limit_reached` (1.2.0): shadow calls passed the safety ceiling, 10 times your account's monthly test limit | A **safety limit, not a deny**. The handler **runs in every `failMode`** (like `billingLapse: 'open'`), unchecked and not recorded, until your test usage resets at the start of next month (UTC). `result.allowed: true`, `result.safetyLimited: true`, `result.failOpen: false`, `result.rejected: false`, `result.error.limitScope: 'org_safety'`, `result.resetsAt`, `result.retryAfterSec`; its own warning at most once per `warningIntervalMs`, and `onSafetyLimit(result)` on every such call. |
|
|
@@ -102,14 +104,14 @@ it the SDK blocks and warns:
|
|
|
102
104
|
|
|
103
105
|
Mock the SDK in CI test suites instead of calling Ordainer on every run; they share the account's 1,000.
|
|
104
106
|
|
|
105
|
-
**Shadow at the test limit (1.2.0).** In shadow mode the test limit never blocks: past it, each shadow call is
|
|
106
|
-
|
|
107
|
+
**Shadow at the test limit (1.2.0).** In shadow mode the test limit never blocks: past it, each shadow call is
|
|
108
|
+
recorded as a would-deny `test_limit_reached` and counts only toward the safety ceiling, and the SDK runs it (a "test limit reached (shadow)"
|
|
107
109
|
warning, at most once per `warningIntervalMs`). Past a safety ceiling of 10 times the limit the server answers
|
|
108
110
|
`429 safety_limit_reached` until the start of next month (UTC); the SDK runs the call in every `failMode`
|
|
109
111
|
(`result.safetyLimited`, `onSafetyLimit`). If you call our API directly, we recommend letting the tool run and logging
|
|
110
112
|
the error. If you reach it, email support@ordainer.ai.
|
|
111
113
|
|
|
112
|
-
**Rate limit.** The server allows each agent key `DECIDE_RATE_LIMIT_PER_SEC` requests a second
|
|
114
|
+
**Rate limit.** The server allows each agent key `DECIDE_RATE_LIMIT_PER_SEC` requests a second. About 100 requests a second, each server counts separately. Over
|
|
113
115
|
it, the SDK logs (at most once a minute, with a suppressed count):
|
|
114
116
|
|
|
115
117
|
```
|
|
@@ -124,7 +126,7 @@ operation**. The server replays a key for 24 hours; after that it's a new, count
|
|
|
124
126
|
|
|
125
127
|
`retries: n` (default **1**, max 5, `0` = off; on the client, `decide()`, `middleware()` or `wrap()`) retries a timeout,
|
|
126
128
|
a network error or a **503** (the server's fast `decide_timeout` / `db_timeout` / `db_unavailable` answers, or an outage) with the **same**
|
|
127
|
-
key, after a
|
|
129
|
+
key, after a short jittered pause (25–75 ms, doubling for each further retry, as in the Ruby SDK), so a lost answer is replayed by the server, not counted twice. 4xx answers and other 5xx
|
|
128
130
|
(500, 502, …) are never retried. All attempts of one call share `retryBudgetMs` (default 650: a 300 ms attempt, the
|
|
129
131
|
pause, and one more 300 ms attempt): a retry starts only with at least 50 ms left and its timeout is cut to the time
|
|
130
132
|
left, so a call never takes much longer than the budget. Only after the last attempt does `failMode` apply (default
|
|
@@ -165,15 +167,15 @@ HTTP/1.1 429 Too Many Requests
|
|
|
165
167
|
Retry-After: 3600
|
|
166
168
|
X-Request-Id: req_KdimJCxq8zbjfL4g
|
|
167
169
|
|
|
168
|
-
{"error":{"code":"plan_limit_reached","message":"This
|
|
170
|
+
{"error":{"code":"plan_limit_reached","message":"This call wasn't run because the usage limit was reached (0 of 5 left). It resets at 2026-11-01T00:00:00.000Z. Retrying before then won't work.","request_id":"req_KdimJCxq8zbjfL4g","reason":"quota_exceeded","remaining":0,"limit":5,"resets_at":"2026-11-01T00:00:00.000Z"}}
|
|
169
171
|
```
|
|
170
172
|
|
|
171
|
-
**Naming the agent** (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
**Naming the agent** (T18-P39): when the API sends the agent key's label on the deny and you pass
|
|
174
|
+
`exposeKeyLabel: true`, the body gets a `label` field. The message does not name the key. It is the sentence above,
|
|
175
|
+
with or without a label. The label is the name you gave the key, cleaned and cut to 64 characters; nothing else about
|
|
176
|
+
the key is sent. This is off by default in two places: the API only sends `label` when your account has it turned on
|
|
177
|
+
(ask us; a console setting is coming), and the SDK only puts it on the body when you pass `exposeKeyLabel: true`.
|
|
178
|
+
With either one off, there is no `label` field and the message stays exactly as above. Labels are yours to choose,
|
|
177
179
|
so only turn it on if your key names are fine for your callers to see.
|
|
178
180
|
|
|
179
181
|
`Retry-After` is always the real number of seconds from the moment of the call until `resets_at`, rounded up (at
|
|
@@ -193,7 +195,7 @@ decision id exists). Earlier builds used `Ordainer-Request-Id` and `error.type`;
|
|
|
193
195
|
|---|---|---|---|
|
|
194
196
|
| Quota (`plan_limit_reached`) | 429 (or `denyStatus`) | seconds until the reset | says when it resets |
|
|
195
197
|
| `test_limit_reached` | 429 | seconds until the reset (none if `resets_at` is unknown) | "This call wasn't run. This service's test calls for the month are used up. They reset on November 1 (UTC). Nothing is wrong on your side, and trying again won't help until they reset. If you need it sooner, contact the service's team. (test_limit_reached)" (the day is `resets_at` in UTC, as "<Month D>"; no "They reset on …" sentence unless `resets_at` is a valid ISO-8601 date-time) |
|
|
196
|
-
| Any other deny (today `tool_not_in_plan`) | 403 `plan_denied` | **none** | `tool_not_in_plan`: "This call wasn't run because
|
|
198
|
+
| Any other deny (today `tool_not_in_plan`) | 403 `plan_denied` | **none** | `tool_not_in_plan`: "This call wasn't run because "research.run" isn't included in your current access. If you need it, contact the provider of this service. (tool_not_in_plan)" (the tool you passed to `decide()`, in quotes only when it matches `^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$` and has no `//` and no leading `scheme:` (`^[A-Za-z][A-Za-z0-9+.-]*:`, so nothing URL-like is echoed); otherwise "this action"). Any other reason: "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team. (reason)" (the reason in brackets only when it looks like an API code: lowercase letters and `_`, up to 40 characters) |
|
|
197
199
|
| `429 rate_limited` | 429 `rate_limited` | the server's value | "The service is getting too many requests on this key right now, so this call was held back. Please wait a moment and try again. (rate_limited)" (the seconds are in `Retry-After` and `retry_after`) |
|
|
198
200
|
| `429 concurrency_limited` | 429 `rate_limited` | the server's value | "The service is handling too many calls on this key at once, so this call was held back. Please wait a moment and try again. (concurrency_limited)" (the seconds are in `Retry-After` and `retry_after`) |
|
|
199
201
|
| Outage under `failMode: 'closed'` (timeout, network error, 5xx, malformed answer) | **503 `ordainer_unavailable`** (only here) | `5` | "This call wasn't run. The service couldn't finish a safety check it does before each call, so it stopped instead of going ahead without it. Nothing is wrong on your side. Please try again in a minute. (ordainer_unavailable)" |
|
package/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface DecideResult {
|
|
|
32
32
|
replayed: boolean;
|
|
33
33
|
/** Sticky deny: Ordainer couldn't answer (would fail open), but this (agent key, tool) got quota_exceeded within stickyDenyMs, so the call is denied (allowed false, reason 'quota_exceeded', 429 plan_limit_reached), or in shadow allowed and reported (wouldDeny). `error` says why there was no answer. */
|
|
34
34
|
stickyDeny: boolean;
|
|
35
|
-
/** The agent key's label (its name), sent by the API on denies only, cleaned and at most 64 characters. null on allows, errors, older servers, or unless exposeKeyLabel: true. Shown
|
|
35
|
+
/** The agent key's label (its name), sent by the API on denies only, cleaned and at most 64 characters. null on allows, errors, older servers, or unless exposeKeyLabel: true. Shown on the 429 plan_limit_reached body as error.label. The message does not include it. */
|
|
36
36
|
label: string | null;
|
|
37
37
|
/** HTTP attempts made (retries reuse the same idempotency key). 0 when no request was sent. */
|
|
38
38
|
attempts: number;
|
|
@@ -79,7 +79,7 @@ export interface OrdainerOptions {
|
|
|
79
79
|
/** Sticky deny window (ms) after a quota_exceeded deny for an (agent key, tool): a would-be fail-open for that pair is denied (shadow: reported). Default 60000, 0 = off, max 300000. Ends early at the plan's reset or on an allow. */
|
|
80
80
|
stickyDenyMs?: number;
|
|
81
81
|
denyStatus?: DenyStatus;
|
|
82
|
-
/**
|
|
82
|
+
/** Put the agent key's label on the 429 plan_limit_reached body (error.label) when the API sends it. The message does not include it. Default false (labels stay out of caller responses and result.label is null); set true to opt in. */
|
|
83
83
|
exposeKeyLabel?: boolean;
|
|
84
84
|
onDecision?: (result: DecideResult) => void | Promise<void>;
|
|
85
85
|
/** Called on every call answered 403 subscription_inactive (subscription inactive, under either billingLapse setting) OR 403 subscription_required (live key, no paid subscription yet); check result.error.code. The log warning is rate-limited; this hook is not. */
|
package/index.js
CHANGED
|
@@ -82,7 +82,7 @@ const { version: SDK_VERSION } = require('./package.json'); // npm pack always s
|
|
|
82
82
|
// Brand strings in one place ("Ordainer" is a working name; the rename sweep edits this block).
|
|
83
83
|
const BRAND = Object.freeze({
|
|
84
84
|
name: 'Ordainer',
|
|
85
|
-
baseUrl: 'https://api.ordainer.dev',
|
|
85
|
+
baseUrl: 'https://api.ordainer.dev',
|
|
86
86
|
envKey: 'ORDAINER_KEY',
|
|
87
87
|
envBaseUrl: 'ORDAINER_BASE_URL',
|
|
88
88
|
logPrefix: '[ordainer]',
|
|
@@ -112,7 +112,7 @@ const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN']);
|
|
|
112
112
|
// The server rejects idempotency keys longer than this (400 invalid_idempotency_key).
|
|
113
113
|
const MAX_IDEMPOTENCY_LEN = 255;
|
|
114
114
|
const MAX_RETRIES = 5;
|
|
115
|
-
const RETRY_DELAY_MS = 50;
|
|
115
|
+
const RETRY_DELAY_MS = 50; // first pause; doubles each retry, with +/-50% jitter (25-75 ms first), like the Ruby SDK
|
|
116
116
|
// Architecture review #1: one retry by default, only for a timeout, a network error or a 503 (the server's
|
|
117
117
|
// fast decide_timeout / db_timeout / db_unavailable / outage answers), and only inside a small total time budget for the
|
|
118
118
|
// whole decide() (all attempts and the pauses between them). 4xx and other 5xx are never retried.
|
|
@@ -283,7 +283,7 @@ function showableTool(tool) {
|
|
|
283
283
|
function planDeniedMessage(reason, tool) {
|
|
284
284
|
if (reason === 'tool_not_in_plan') {
|
|
285
285
|
const what = showableTool(tool) ? `"${tool}"` : 'this action';
|
|
286
|
-
return `This call wasn't run because
|
|
286
|
+
return `This call wasn't run because ${what} isn't included in your current access. If you need it, contact the provider of this service. (tool_not_in_plan)`;
|
|
287
287
|
}
|
|
288
288
|
const msg = "This call wasn't run because it isn't allowed on your account right now. If you need it, contact the service's team.";
|
|
289
289
|
return typeof reason === 'string' && /^[a-z][a-z_]{0,39}$/.test(reason) ? `${msg} (${reason})` : msg;
|
|
@@ -391,18 +391,19 @@ function denyResponse(result, now = Date.now()) {
|
|
|
391
391
|
body: {
|
|
392
392
|
error: {
|
|
393
393
|
code: 'plan_limit_reached',
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
394
|
+
// Approved Nov 3 2026 (decisions.md:365 and :368). The message does not name the key.
|
|
395
|
+
// error.label is still set when the API sent one and exposeKeyLabel is on.
|
|
396
|
+
// The reset sentence is omitted when resets_at is missing, same as before.
|
|
397
|
+
message: `This call wasn't run because the usage limit was reached (${result.remaining ?? 0} of ${result.limit ?? '?'} left).`
|
|
398
398
|
+ (result.resetsAt ? ` It resets at ${result.resetsAt}.` : '')
|
|
399
|
-
+
|
|
399
|
+
+ " Retrying before then won't work.",
|
|
400
400
|
...(label ? { label } : {}),
|
|
401
401
|
request_id: result.requestId,
|
|
402
402
|
reason: result.reason,
|
|
403
403
|
remaining: result.remaining,
|
|
404
404
|
limit: result.limit,
|
|
405
405
|
resets_at: result.resetsAt,
|
|
406
|
+
...(result.explanation ? { explanation: result.explanation } : {}),
|
|
406
407
|
},
|
|
407
408
|
},
|
|
408
409
|
};
|
|
@@ -509,6 +510,7 @@ class Ordainer {
|
|
|
509
510
|
this.stickyDenyMs = opts.stickyDenyMs ?? DEFAULT_STICKY_DENY_MS;
|
|
510
511
|
this._sticky = new Map(); // sha256(key)\0tool -> { until, mode, limit, resetsAt }
|
|
511
512
|
this._sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); // tests
|
|
513
|
+
this._random = opts.random || Math.random; // tests (the retry jitter)
|
|
512
514
|
this.apiKey = opts.apiKey ?? process.env[BRAND.envKey];
|
|
513
515
|
this.baseUrl = String(opts.baseUrl || process.env[BRAND.envBaseUrl] || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
514
516
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -516,7 +518,8 @@ class Ordainer {
|
|
|
516
518
|
this.failMode = opts.failMode || 'open';
|
|
517
519
|
this.billingLapse = opts.billingLapse || 'open';
|
|
518
520
|
this.denyStatus = opts.denyStatus || 429;
|
|
519
|
-
// T18-P39: put the agent key's label (from a deny answer)
|
|
521
|
+
// T18-P39: put the agent key's label (from a deny answer) on the 429 plan_limit_reached body
|
|
522
|
+
// as error.label. The message does not include it (decisions.md:368).
|
|
520
523
|
// Labels are yours to choose; set false if they are internal names your callers shouldn't see.
|
|
521
524
|
if (opts.exposeKeyLabel !== undefined && typeof opts.exposeKeyLabel !== 'boolean') {
|
|
522
525
|
throw new TypeError(`${BRAND.name}: exposeKeyLabel must be true or false`);
|
|
@@ -578,7 +581,9 @@ class Ordainer {
|
|
|
578
581
|
result = await this._call(base, { key, tool, idem, requestedMode, effectiveFailMode, effectiveBillingLapse, timeoutMs });
|
|
579
582
|
result.attempts = attempt;
|
|
580
583
|
if (!result.retryable || attempt >= maxAttempts) break;
|
|
581
|
-
|
|
584
|
+
// P89-L1: jittered, so callers shed at the same moment (503 at capacity) don't all retry in one wave.
|
|
585
|
+
// Whole ms: the next attempt's timeout is cut to the time left, and AbortSignal.timeout() takes integers only.
|
|
586
|
+
const pause = Math.round(RETRY_DELAY_MS * 2 ** (attempt - 1) * (0.5 + this._random()));
|
|
582
587
|
const left = budgetEnd - Date.now() - pause;
|
|
583
588
|
if (left < MIN_ATTEMPT_MS) break; // out of budget: failMode applies now
|
|
584
589
|
timeoutMs = Math.min(this.timeoutMs, left);
|
|
@@ -709,6 +714,7 @@ class Ordainer {
|
|
|
709
714
|
limit: json.limit,
|
|
710
715
|
resetsAt: json.resets_at,
|
|
711
716
|
requestId: json.request_id,
|
|
717
|
+
...(typeof json.explanation === 'string' ? { explanation: json.explanation } : {}),
|
|
712
718
|
mode: effectiveMode,
|
|
713
719
|
wouldDeny,
|
|
714
720
|
// T18-P39: the server sends the key's label on denies only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ordainer",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"description": "Ask Ordainer before your tool runs: one decide() call checks the caller's plan and counts the use.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"middleware",
|
|
30
30
|
"express"
|
|
31
31
|
],
|
|
32
|
-
"homepage": "https://ordainer.dev",
|
|
32
|
+
"homepage": "https://docs.ordainer.dev",
|
|
33
33
|
"bugs": {
|
|
34
34
|
"email": "support@ordainer.ai"
|
|
35
35
|
},
|