ordainer 1.2.7 → 1.2.8

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 CHANGED
@@ -3,6 +3,12 @@
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.8 (2026-11-11)
7
+
8
+ Behaviour change. 1.2.7 and earlier don't have it (1.2.7 was README-only, the same code as 1.2.6).
9
+
10
+ Changed: **`Retry-After` on a quota or test-limit block is capped at 3600 seconds (one hour).** The header is the seconds from the call until `resets_at`, rounded up (at least 1), and never more than 3600. A monthly reset no longer produces a ~27-day `Retry-After`. Use `resets_at` in the JSON body for the real reset time; that field is unchanged. Unknown or non-finite `resets_at` still sends no header. The server's own `Retry-After` on `429 rate_limited` and `Retry-After: 5` on `503 ordainer_unavailable` are not this cap. The API's decide `Retry-After` is unchanged.
11
+
6
12
  ## 1.2.7 (2026-11-11)
7
13
 
8
14
  README-only: queue-wait wording matches the live docs (#254). A call that waited in the key's line and ran out of time is `503 decide_timeout` (the SDK retries once, then `failMode`). `429 concurrency_limited` only when the line of 20 waiting is full. The code is the same as 1.2.6, byte for byte; only the README (and this changelog) changed.
package/README.md CHANGED
@@ -53,7 +53,7 @@ if (d.allowed) { /* run it */ }
53
53
  | Situation | Default result |
54
54
  |---|---|
55
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. |
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). |
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` (capped at 3600 seconds; `resets_at` is the real time) 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). |
57
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)". |
58
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") and when a call waited in this key's line of 20 and ran out of time (nothing counted): 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). |
59
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. |
@@ -62,7 +62,7 @@ if (d.allowed) { /* run it */ }
62
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
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 about 200 ms for a slot). `429 concurrency_limited` only when that line of 20 waiting is full (`limit_scope: "per_key_concurrency"`). A call that waited in that line and ran out of time gets `503 decide_timeout` (retried once, then `failMode`), not 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'`. |
64
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. |
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. |
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), capped at 3600 seconds (`resets_at` is the real time). **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. |
66
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. |
67
67
  | The base URL's host doesn't resolve (`ENOTFOUND` / `EAI_AGAIN`, e.g. a typo in `ORDAINER_BASE_URL`) (1.2.0) | Handled like any network error (`failMode` applies; default: the handler runs). The **first** time per host, one loud error-level line (`logger.error`, else `logger.warn`; never throttled) names the host and says to set `ORDAINER_BASE_URL`; `result.error.dnsCode` says which. |
68
68
 
@@ -178,9 +178,10 @@ the key is sent. This is off by default in two places: the API only sends `label
178
178
  With either one off, there is no `label` field and the message stays exactly as above. Labels are yours to choose,
179
179
  so only turn it on if your key names are fine for your callers to see.
180
180
 
181
- `Retry-After` is always the real number of seconds from the moment of the call until `resets_at`, rounded up (at
182
- least 1). This example was produced at 2026-10-31T23:00:00Z, one hour before the reset, so it's `3600`; on Oct 1 at
183
- 00:00 UTC it would be `2678400` (31 days).
181
+ `Retry-After` on a quota or test-limit block is the number of seconds from the moment of the call until `resets_at`,
182
+ rounded up (at least 1), and capped at 3600 (one hour). Use `resets_at` in the JSON body for the real reset time.
183
+ This example was produced at 2026-10-31T23:00:00Z, one hour before the reset, so it's `3600`. On Oct 1 at
184
+ 00:00 UTC the real wait is 31 days (`2678400` seconds) and the header is still `3600`.
184
185
 
185
186
  Blocked responses follow the API's own conventions: the `X-Request-Id` header (the decision's `request_id`, the same
186
187
  id as the audit row) and `error.code` + `error.message` + `error.request_id`. The codes are `plan_limit_reached`
@@ -193,8 +194,8 @@ decision id exists). Earlier builds used `Ordainer-Request-Id` and `error.type`;
193
194
 
194
195
  | Block | Status and code | `Retry-After` | Message |
195
196
  |---|---|---|---|
196
- | Quota (`plan_limit_reached`) | 429 (or `denyStatus`) | seconds until the reset | says when it resets |
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) |
197
+ | Quota (`plan_limit_reached`) | 429 (or `denyStatus`) | seconds until the reset, capped at 3600; `resets_at` is the real time | says when it resets |
198
+ | `test_limit_reached` | 429 | seconds until the reset, capped at 3600 (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) |
198
199
  | 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) |
199
200
  | `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`) |
200
201
  | `429 concurrency_limited` (waiting line of 20 is full) | 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`) |
@@ -241,7 +242,7 @@ Now `503` means only a real outage.
241
242
  | `rateLimited`, `retryAfterSec` | `429 rate_limited` (per key) or `429 concurrency_limited` (the key's line of 20 waiting is full; `reason` says which) and its `Retry-After` in seconds. |
242
243
  | `safetyLimited` | 1.2.0: `429 safety_limit_reached` (shadow calls past the safety ceiling, 10 times the account's monthly test limit). The call ran (`allowed: true`, `failOpen: false`); `retryAfterSec` and `resetsAt` say when it ends. |
243
244
  | `error` | `{ code, message, status?, limitScope? }` when there was no decision; `limitScope` is `'per_key'`, `'per_key_concurrency'` or `'per_address'` on a 429 (the server's `error.limit_scope`; not `limit`, which is the plan's cap). |
244
- | `label` | The agent key's label (its name) when the API sent it on a deny, cleaned and at most 64 characters; `null` on allows, errors, older servers, or unless `exposeKeyLabel: true`. Named in the 429 `plan_limit_reached` (unreleased, DRAFT T18-P39). |
245
+ | `label` | The agent key's label (its name) when the API sent it on a deny, cleaned and at most 64 characters; `null` on allows, errors, older servers, unless your account has the key label turned on, or unless `exposeKeyLabel: true`. Also on the 429 `plan_limit_reached` body as `error.label`; the message does not name the key. |
245
246
  | `stickyDeny` | A call that would have failed open was handled as a quota deny because this (key, tool) got `quota_exceeded` within `stickyDenyMs` (see "Sticky deny"); blocked in enforce, still run (and `failOpen`) in shadow. `error` says what failed. |
246
247
  | `denyStatus` | Status a blocked quota deny answers with (429 or 402). |
247
248
  | `tool`, `latencyMs` | The tool checked and how long the check took. |
package/index.d.ts CHANGED
@@ -44,13 +44,13 @@ export interface DecideResult {
44
44
  testLimitReached: boolean;
45
45
  /** 1.2.0: Ordainer answered 429 safety_limit_reached (shadow calls past the safety ceiling, 10x the account's monthly test limit). A safety limit, not a deny: the call RUNS in every failMode (allowed true, failOpen false, rejected false), unchecked and not recorded. error.limitScope 'org_safety'; resetsAt and retryAfterSec say when it ends (the start of next month, UTC). */
46
46
  safetyLimited: boolean;
47
- /** Ordainer answered 429 rate_limited (this agent key is over the per-key abuse limit; error.limitScope 'per_key') or 429 concurrency_limited (too many decides in flight at once for this key; error.limitScope 'per_key_concurrency', reason 'concurrency_limited'): a rate limit, not an outage. Blocked in any failMode; always warned. False for the per-address 429 too_many_failed_auth (a bad key: rejected, error.limitScope 'per_address'). */
47
+ /** Ordainer answered 429 rate_limited (this agent key is over the per-key abuse limit; error.limitScope 'per_key') or 429 concurrency_limited (too many decides in flight at once for this key and its line of 20 waiting is full; a wait that runs out is 503 decide_timeout instead; error.limitScope 'per_key_concurrency', reason 'concurrency_limited'): a rate limit, not an outage. Blocked in any failMode; always warned. False for the per-address 429 too_many_failed_auth (a bad key: rejected, error.limitScope 'per_address'). */
48
48
  rateLimited: boolean;
49
49
  /** Seconds from the 429's Retry-After header when rateLimited or safetyLimited, else null. */
50
50
  retryAfterSec: number | null;
51
51
  /**
52
52
  * Why there was no decision. `limitScope` only on a 429 (not `limit`: result.limit is the plan's cap): 'per_key' (rate_limited: this key is over its request rate),
53
- * 'per_key_concurrency' (concurrency_limited: too many decides in flight at once for this key) or
53
+ * 'per_key_concurrency' (concurrency_limited: too many decides in flight at once for this key and its line of 20 waiting is full) or
54
54
  * 'per_address' (too_many_failed_auth: too many missing/unknown keys from your server's address; never sent for a valid key) or
55
55
  * 'org_safety' (safety_limit_reached: shadow calls past the safety ceiling; the call ran).
56
56
  * `dnsCode` (1.2.0): 'ENOTFOUND' or 'EAI_AGAIN' on a network_error where the base URL's host didn't resolve.
package/index.js CHANGED
@@ -46,8 +46,10 @@
46
46
  // rate limit, not an outage: blocked with 429 rate_limited + Retry-After in
47
47
  // any failMode, and never silent: its own rate-limited warning,
48
48
  // result.rateLimited, result.retryAfterSec and onRateLimited on every such call.
49
- // - 429 concurrency_limited (this agent key has too many decides in flight at once,
50
- // Ordainer's per-key concurrency limit) is handled exactly like 429 rate_limited
49
+ // - 429 concurrency_limited (this agent key has too many decides in flight at once and
50
+ // its line of 20 waiting is full, Ordainer's per-key concurrency limit; a call that
51
+ // waited in that line and ran out of time is 503 decide_timeout instead, retried once,
52
+ // then failMode) is handled exactly like 429 rate_limited
51
53
  // (result.rateLimited, reason 'concurrency_limited', error.limitScope
52
54
  // 'per_key_concurrency', the end user gets 429 rate_limited + Retry-After). Never a fail-open.
53
55
  // - 429 too_many_failed_auth (Ordainer's per-address limit on missing/unknown keys;
@@ -60,9 +62,9 @@
60
62
  // data. Use one key per real operation. retries (default 1) retries a timeout,
61
63
  // network error or 503 with the SAME key, so a retry is replayed, not counted.
62
64
  // - Blocked calls get 429 for quota_exceeded (with Retry-After until the plan
63
- // resets), 403 for other denies, 503 ordainer_unavailable only for a real
65
+ // resets, capped at 3600 seconds; the body resets_at is the real time), 403 for other denies, 503 ordainer_unavailable only for a real
64
66
  // outage under failMode 'closed' (with Retry-After: 5). Retry-After only where
65
- // a retry can work: quota/test limit (until the reset), 429 rate_limited, and
67
+ // a retry can work: quota/test limit (until the reset, header capped at one hour), 429 rate_limited, and
66
68
  // that outage. A block a retry can't fix (Q2) is plan_check_rejected with no
67
69
  // Retry-After and no "try again": 403 for billing (subscription_required, or
68
70
  // the subscription_inactive lapse under billingLapse 'closed'; neutral message), 500 for everything else
@@ -306,6 +308,16 @@ function safeKeyLabel(v) {
306
308
  // What a blocked call should answer. Safe to return verbatim to the agent.
307
309
  // Same conventions as the Ordainer API: an X-Request-Id header and
308
310
  // {"error": {"code", "message", "request_id", ...}}.
311
+
312
+ // Caller-facing Retry-After derived from resets_at, in whole seconds.
313
+ // Capped at one hour so a monthly reset cannot ask the caller to wait ~27 days.
314
+ // The JSON body still sends the original resets_at. null when resets_at is missing or not a finite time (no header).
315
+ function retryAfterHeader(resetsAt, now) {
316
+ const resets = resetsAt ? Date.parse(resetsAt) : NaN;
317
+ if (!Number.isFinite(resets)) return null;
318
+ return Math.min(3600, Math.max(1, Math.ceil((resets - now) / 1000)));
319
+ }
320
+
309
321
  function denyResponse(result, now = Date.now()) {
310
322
  const requestIdHeader = result.requestId ? { 'X-Request-Id': result.requestId } : {};
311
323
  // 429 rate_limited: a real "slow down", so Retry-After (the server's value).
@@ -363,9 +375,9 @@ function denyResponse(result, now = Date.now()) {
363
375
  };
364
376
  }
365
377
  if (result.reason === TEST_LIMIT_REASON) {
366
- const resets = result.resetsAt ? Date.parse(result.resetsAt) : NaN;
367
378
  const headers = { ...requestIdHeader };
368
- if (Number.isFinite(resets)) headers['Retry-After'] = String(Math.max(1, Math.ceil((resets - now) / 1000)));
379
+ const retryAfter = retryAfterHeader(result.resetsAt, now);
380
+ if (retryAfter != null) headers['Retry-After'] = String(retryAfter);
369
381
  return {
370
382
  status: 429,
371
383
  headers,
@@ -382,9 +394,9 @@ function denyResponse(result, now = Date.now()) {
382
394
  }
383
395
  if (result.reason === 'quota_exceeded') {
384
396
  const label = safeKeyLabel(result.label);
385
- const resets = result.resetsAt ? Date.parse(result.resetsAt) : NaN;
386
397
  const headers = { ...requestIdHeader };
387
- if (Number.isFinite(resets)) headers['Retry-After'] = String(Math.max(1, Math.ceil((resets - now) / 1000)));
398
+ const retryAfter = retryAfterHeader(result.resetsAt, now);
399
+ if (retryAfter != null) headers['Retry-After'] = String(retryAfter);
388
400
  return {
389
401
  status: result.denyStatus || 429,
390
402
  headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ordainer",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
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",