ordainer 1.0.0 → 1.2.4
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 +280 -0
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/examples/express-server.js +18 -0
- package/index.d.ts +146 -0
- package/index.js +1012 -0
- package/package.json +38 -7
package/index.js
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ordainer: call Ordainer's POST /v1/decide before your tool handler runs.
|
|
3
|
+
// Zero dependencies. Node 20+ (global fetch, AbortSignal.timeout).
|
|
4
|
+
//
|
|
5
|
+
// const { Ordainer } = require('ordainer');
|
|
6
|
+
// const ordainer = new Ordainer({ apiKey: process.env.ORDAINER_KEY });
|
|
7
|
+
// app.post('/research', ordainer.middleware({ tool: 'research.run' }), handler);
|
|
8
|
+
//
|
|
9
|
+
// Rules:
|
|
10
|
+
// - decide() never throws. A policy deny is a normal result.
|
|
11
|
+
// - Mode (what to do with a deny):
|
|
12
|
+
// unset (default): follow the plan's mode from the Ordainer console, so you
|
|
13
|
+
// can switch shadow -> enforce with no deploy;
|
|
14
|
+
// 'shadow': never block; log/report "would deny";
|
|
15
|
+
// 'enforce': block on deny.
|
|
16
|
+
// - Fail mode (what to do when Ordainer can't answer: timeout, network error,
|
|
17
|
+
// 5xx, or a 200 that isn't a decision): 'open' (default) lets the call run;
|
|
18
|
+
// 'closed' blocks it. ONLY those. A 4xx answer (400, 401, 403, 409, 429, ...)
|
|
19
|
+
// and a missing agent key are never an unchecked allow: the call is blocked
|
|
20
|
+
// (plan_check_rejected: 500, or 403 for billing; or 429 rate_limited) whatever
|
|
21
|
+
// failMode says, and result.rejected is true (QA S2, Q2). The one exception,
|
|
22
|
+
// 403 subscription_inactive, follows billingLapse (below).
|
|
23
|
+
// - 403 subscription_inactive (the vendor's own Ordainer subscription is inactive) is a
|
|
24
|
+
// billing state, not an outage. It follows ONLY the billing-lapse setting
|
|
25
|
+
// (billingLapse, Terms 10.3(b), Tyler Oct 3: Option A), never failMode:
|
|
26
|
+
// 'open' (default) lets the call run, unchecked (result.allowed, not failOpen);
|
|
27
|
+
// 'closed' (opt-in) blocks it with 403 plan_check_rejected, no Retry-After and the
|
|
28
|
+
// neutral end-user message (result.rejected). Both: its own rate-limited warning,
|
|
29
|
+
// result.billingInactive, result.billingLapse and onBillingInactive on every call.
|
|
30
|
+
// - 403 subscription_required (a live key, never paid) is blocked like any other
|
|
31
|
+
// 4xx, also with the billing warning, result.billingInactive and onBillingInactive.
|
|
32
|
+
// - deny `test_limit_reached` (the vendor's account used its free monthly
|
|
33
|
+
// test-key decisions) is a real deny from Ordainer, not an error: failMode
|
|
34
|
+
// doesn't apply. Enforce: blocked, 429 test_limit_reached with Retry-After.
|
|
35
|
+
// Shadow (1.2.0, the server answers mode 'shadow'): shadow never blocks, so the
|
|
36
|
+
// call runs as a would-deny. Both: its own rate-limited warning,
|
|
37
|
+
// result.testLimitReached, onTestLimit.
|
|
38
|
+
// - 429 safety_limit_reached (1.2.0: shadow calls past the server's safety ceiling,
|
|
39
|
+
// 10x the account's monthly test limit) is a safety limit, not a deny: the call
|
|
40
|
+
// RUNS in every failMode (like billingLapse 'open'), unchecked. result.allowed,
|
|
41
|
+
// result.safetyLimited (failOpen false), its own rate-limited warning, onSafetyLimit.
|
|
42
|
+
// - The base URL's host doesn't resolve (ENOTFOUND / EAI_AGAIN): failMode applies as
|
|
43
|
+
// for any network error, and the first time per host one loud error-level line
|
|
44
|
+
// (not throttled) names the host and says to set ORDAINER_BASE_URL.
|
|
45
|
+
// - 429 rate_limited (this agent key is over Ordainer's per-key abuse limit) is a
|
|
46
|
+
// rate limit, not an outage: blocked with 429 rate_limited + Retry-After in
|
|
47
|
+
// any failMode, and never silent: its own rate-limited warning,
|
|
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
|
|
51
|
+
// (result.rateLimited, reason 'concurrency_limited', error.limitScope
|
|
52
|
+
// 'per_key_concurrency', the end user gets 429 rate_limited + Retry-After). Never a fail-open.
|
|
53
|
+
// - 429 too_many_failed_auth (Ordainer's per-address limit on missing/unknown keys;
|
|
54
|
+
// never sent for a valid key) is a bad key, not a rate problem: rejected, with
|
|
55
|
+
// result.error.limitScope 'per_address' and its own warning (H3). rateLimited stays false.
|
|
56
|
+
// - Idempotency: every decide sends a key. The middleware NEVER forwards the
|
|
57
|
+
// incoming request's Idempotency-Key header (an agent could replay one key to
|
|
58
|
+
// get one stored allow many times, QA S1): each incoming request gets a fresh
|
|
59
|
+
// random key unless you pass idempotencyKey(req) built from your own trusted
|
|
60
|
+
// data. Use one key per real operation. retries (default 1) retries a timeout,
|
|
61
|
+
// network error or 503 with the SAME key, so a retry is replayed, not counted.
|
|
62
|
+
// - 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
|
|
64
|
+
// 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
|
|
66
|
+
// that outage. A block a retry can't fix (Q2) is plan_check_rejected with no
|
|
67
|
+
// Retry-After and no "try again": 403 for billing (subscription_required, or
|
|
68
|
+
// the subscription_inactive lapse under billingLapse 'closed'; neutral message), 500 for everything else
|
|
69
|
+
// (missing, unknown or revoked key, too_many_failed_auth, 400, 409, other 4xx).
|
|
70
|
+
// - Sticky deny (QA N7-3 remainder): after a quota_exceeded deny for an (agent key, tool), a later call for the
|
|
71
|
+
// same pair that would fail open (timeout, network error, 5xx, malformed answer) is denied instead (429
|
|
72
|
+
// plan_limit_reached, result.stickyDeny) until stickyDenyMs (default 60 s, max 5 min, 0 = off) or the plan's
|
|
73
|
+
// reset passes, or an allow arrives for the pair. Shadow (requested, or the plan's mode on that deny) still
|
|
74
|
+
// runs the call and reports it (result.stickyDeny, wouldDeny). One agent key is one customer, so the pair is
|
|
75
|
+
// (customer, tool). In memory, per Ordainer instance.
|
|
76
|
+
// - Result flags, distinct: failOpen = failMode 'open' let it run without a
|
|
77
|
+
// decision; failClosed = failMode 'closed' blocked it (no decision); rejected
|
|
78
|
+
// = Ordainer refused it (4xx) or it couldn't be sent, blocked in any failMode.
|
|
79
|
+
const crypto = require('crypto');
|
|
80
|
+
const { version: SDK_VERSION } = require('./package.json'); // npm pack always ships package.json
|
|
81
|
+
|
|
82
|
+
// Brand strings in one place ("Ordainer" is a working name; the rename sweep edits this block).
|
|
83
|
+
const BRAND = Object.freeze({
|
|
84
|
+
name: 'Ordainer',
|
|
85
|
+
baseUrl: 'https://api.ordainer.dev', // docs-site download (the npm release defaults to the launch host)
|
|
86
|
+
envKey: 'ORDAINER_KEY',
|
|
87
|
+
envBaseUrl: 'ORDAINER_BASE_URL',
|
|
88
|
+
logPrefix: '[ordainer]',
|
|
89
|
+
userAgent: `ordainer-node/${SDK_VERSION}`,
|
|
90
|
+
unavailableCode: 'ordainer_unavailable', // error.code for a real outage under failMode 'closed' (503 + Retry-After)
|
|
91
|
+
rejectedCode: 'plan_check_rejected', // error.code for a block a retry can't fix (Q2): 403 billing, 500 otherwise
|
|
92
|
+
});
|
|
93
|
+
const DEFAULT_BASE_URL = BRAND.baseUrl;
|
|
94
|
+
const DEFAULT_TIMEOUT_MS = 300;
|
|
95
|
+
const MODES = new Set(['enforce', 'shadow']);
|
|
96
|
+
const FAIL_MODES = new Set(['open', 'closed']);
|
|
97
|
+
// Terms 10.3(b): the billing-lapse setting, separate from failMode. Default 'open'.
|
|
98
|
+
const BILLING_LAPSE_MODES = FAIL_MODES;
|
|
99
|
+
// Legal-approved (Oct 3, 5:23 PM ET; tech/billing-lapse-scope.md item 2), verbatim: what a blocked end user sees when
|
|
100
|
+
// the vendor's Ordainer billing blocks the call. No mention of billing or of Ordainer, no retry hint.
|
|
101
|
+
const BILLING_BLOCKED_MESSAGE = "This action isn't available right now. This is a setup issue on the service's side; retrying won't help.";
|
|
102
|
+
const BILLING_WARNING_INTERVAL_MS = 60_000;
|
|
103
|
+
const BILLING_CODES = new Set(['subscription_inactive', 'subscription_required']);
|
|
104
|
+
const TEST_LIMIT_REASON = 'test_limit_reached';
|
|
105
|
+
// 429 from the server's per-key in-flight cap (QA round 3 M-1); handled like rate_limited.
|
|
106
|
+
const CONCURRENCY_CODE = 'concurrency_limited';
|
|
107
|
+
const LIMIT_SCOPES = new Set(['per_key', 'per_address', 'per_key_concurrency', 'org_safety']);
|
|
108
|
+
// 429 from the server's shadow safety ceiling (1.2.0): a safety limit, not a deny. The call runs in any failMode.
|
|
109
|
+
const SAFETY_LIMIT_CODE = 'safety_limit_reached';
|
|
110
|
+
// Host lookup failures on the configured base URL (a typo or a missing ORDAINER_BASE_URL): loud on first occurrence.
|
|
111
|
+
const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN']);
|
|
112
|
+
// The server rejects idempotency keys longer than this (400 invalid_idempotency_key).
|
|
113
|
+
const MAX_IDEMPOTENCY_LEN = 255;
|
|
114
|
+
const MAX_RETRIES = 5;
|
|
115
|
+
const RETRY_DELAY_MS = 50;
|
|
116
|
+
// Architecture review #1: one retry by default, only for a timeout, a network error or a 503 (the server's
|
|
117
|
+
// fast decide_timeout / db_timeout / db_unavailable / outage answers), and only inside a small total time budget for the
|
|
118
|
+
// whole decide() (all attempts and the pauses between them). 4xx and other 5xx are never retried.
|
|
119
|
+
const DEFAULT_RETRIES = 1;
|
|
120
|
+
const DEFAULT_RETRY_BUDGET_MS = 650; // one 300 ms attempt + 50 ms pause + one more 300 ms attempt
|
|
121
|
+
const MIN_ATTEMPT_MS = 50; // a retry with less time left than this isn't started
|
|
122
|
+
const DEFAULT_STICKY_DENY_MS = 60_000;
|
|
123
|
+
const MAX_STICKY_DENY_MS = 300_000;
|
|
124
|
+
const MAX_STICKY_ENTRIES = 10_000;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The idempotency key actually sent for `raw`. Deterministic, so a retry with the
|
|
128
|
+
* same raw key always maps to the same sent key (and is replayed, not counted).
|
|
129
|
+
* - prefixTool: the middleware form `${tool}:${raw}`.
|
|
130
|
+
* - If the key would be longer than 255 characters, send `${tool}:` + sha256hex(raw)
|
|
131
|
+
* instead, or the bare sha256hex(raw) if even that is too long. Without this an
|
|
132
|
+
* oversized key got a 400 from the server, the SDK failed open, and a call that
|
|
133
|
+
* should have been denied at the cap ran uncounted.
|
|
134
|
+
* - QA N8-3: the same when the key holds characters the server refuses (control
|
|
135
|
+
* characters U+0000-U+001F, U+007F, or an unpaired surrogate), so a shadow-mode
|
|
136
|
+
* caller with such a key is not blocked by a 400. Unpaired surrogates are hashed
|
|
137
|
+
* from the UTF-16 code units (UTF-8 would turn them all into U+FFFD, so two
|
|
138
|
+
* different keys could collide and one be replayed instead of counted).
|
|
139
|
+
*/
|
|
140
|
+
const BAD_KEY_TEXT = /[\u0000-\u001f\u007f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
141
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
142
|
+
function idempotencyKeyFor(tool, raw, { prefixTool = false } = {}) {
|
|
143
|
+
const candidate = prefixTool ? `${tool}:${raw}` : raw;
|
|
144
|
+
if (candidate.length <= MAX_IDEMPOTENCY_LEN && !BAD_KEY_TEXT.test(candidate)) return candidate;
|
|
145
|
+
const bytes = LONE_SURROGATE.test(raw) ? Buffer.from(raw, 'utf16le') : Buffer.from(raw, 'utf8');
|
|
146
|
+
const digest = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
147
|
+
const withTool = `${tool}:${digest}`;
|
|
148
|
+
return withTool.length <= MAX_IDEMPOTENCY_LEN && !BAD_KEY_TEXT.test(withTool) ? withTool : digest;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class OrdainerDeniedError extends Error {
|
|
152
|
+
constructor(result) {
|
|
153
|
+
const response = denyResponse(result);
|
|
154
|
+
super(response.body.error.message);
|
|
155
|
+
this.name = 'OrdainerDeniedError';
|
|
156
|
+
this.result = result;
|
|
157
|
+
this.status = response.status;
|
|
158
|
+
this.headers = response.headers;
|
|
159
|
+
this.body = response.body;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkMode(mode, where) {
|
|
164
|
+
if (mode !== undefined && mode !== null && !MODES.has(mode)) {
|
|
165
|
+
throw new TypeError(`${where}: mode must be "enforce", "shadow" or unset`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkFailMode(failMode, where) {
|
|
170
|
+
if (failMode !== undefined && !FAIL_MODES.has(failMode)) {
|
|
171
|
+
throw new TypeError(`${where}: failMode must be "open" or "closed"`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkBillingLapse(billingLapse, where) {
|
|
176
|
+
if (billingLapse !== undefined && !BILLING_LAPSE_MODES.has(billingLapse)) {
|
|
177
|
+
throw new TypeError(`${where}: billingLapse must be "open" or "closed"`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Quota denies answer 429 Too Many Requests (default) or 402 Payment Required.
|
|
182
|
+
// Anything else is a typo that would confuse the agent, so it throws up front.
|
|
183
|
+
const DENY_STATUSES = new Set([429, 402]);
|
|
184
|
+
function checkRetries(retries, where) {
|
|
185
|
+
if (retries !== undefined && (!Number.isInteger(retries) || retries < 0 || retries > MAX_RETRIES)) {
|
|
186
|
+
throw new TypeError(`${where}: retries must be an integer from 0 to ${MAX_RETRIES}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function checkDenyStatus(denyStatus, where) {
|
|
191
|
+
if (denyStatus !== undefined && denyStatus !== null && !DENY_STATUSES.has(denyStatus)) {
|
|
192
|
+
throw new TypeError(`${where}: denyStatus must be 429 or 402 (got ${JSON.stringify(denyStatus)})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Caller-facing text for a 429 rate_limited / concurrency_limited block (Content & DevRel, Oct 18). Only the message differs:
|
|
197
|
+
// the code field stays rate_limited for both, and status, Retry-After and retry_after are unchanged.
|
|
198
|
+
const RATE_LIMITED_MESSAGE = "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)";
|
|
199
|
+
const CONCURRENCY_LIMITED_MESSAGE = "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)";
|
|
200
|
+
|
|
201
|
+
// T18-P40: the caller-facing sentence for a block a retry can't fix (500 plan_check_rejected). Plain words first, the
|
|
202
|
+
// API's own code last, for the service's team (the code field stays plan_check_rejected; nothing else changes: status,
|
|
203
|
+
// headers, allowed/rejected). Only the human message depends on the cause. The code is echoed only if it looks like one
|
|
204
|
+
// of the API's codes (lowercase letters and _ only, <= 40 chars), so a strange answer can't inject text.
|
|
205
|
+
const KEY_NOT_RECOGNIZED_CODES = new Set(['unknown_key', 'old_key_format', 'too_many_failed_auth']);
|
|
206
|
+
// Content & DevRel final wording (Oct 18, content/persona-2-2026-10-18/p40-sdk-key-messages.md): no "plan-check key"
|
|
207
|
+
// (our jargon; the reader is the service's customer), no "It isn't an outage" (not always true: a too_many_failed_auth
|
|
208
|
+
// for a key the server couldn't check in time, or a restore on our side), and "setup issue on the service's side" to match
|
|
209
|
+
// the Legal-approved billing message. too_many_failed_auth stays in the key-not-recognized group: the API answers the
|
|
210
|
+
// "couldn't check this key just now" case with the same status, code, limit_scope and headers; only its English message
|
|
211
|
+
// differs (src/app.js FAILED_AUTH_MESSAGE vs POSSIBLE_WRONG_DENY_MESSAGE), and the SDK doesn't parse messages.
|
|
212
|
+
const REJECTED_TAIL = "It isn't something you did, and retrying won't help until the service";
|
|
213
|
+
function rejectedMessage(code) {
|
|
214
|
+
let msg;
|
|
215
|
+
if (code === 'missing_key') {
|
|
216
|
+
msg = `This call wasn't run. This is a setup issue on the service's side: a key it needs is missing. ${REJECTED_TAIL} adds the key.`;
|
|
217
|
+
} else if (code === 'revoked_key') {
|
|
218
|
+
msg = `This call wasn't run. This is a setup issue on the service's side: one of its keys has been turned off. ${REJECTED_TAIL} switches to a new key.`;
|
|
219
|
+
} else if (KEY_NOT_RECOGNIZED_CODES.has(code)) {
|
|
220
|
+
msg = `This call wasn't run. This is a setup issue on the service's side: one of its keys isn't recognized. ${REJECTED_TAIL} fixes the key.`;
|
|
221
|
+
} else {
|
|
222
|
+
msg = `This call wasn't run because of a setup issue on the service's side. ${REJECTED_TAIL} fixes it.`;
|
|
223
|
+
}
|
|
224
|
+
return typeof code === 'string' && /^[a-z][a-z_]{0,39}$/.test(code) ? `${msg} (For the service's team: ${code}.)` : msg;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// P95R-N3 (Content & DevRel, Oct 18, content/persona-2-2026-10-18/p40-sdk-key-messages.md Addendum 3): caller-facing
|
|
228
|
+
// text for the 503 fail-closed outage, the 429 test allowance and the 403 plan deny. No product name and none of our
|
|
229
|
+
// terms; same bytes as the Ruby SDK. Only the message changes: status, code (ordainer_unavailable is kept), headers and
|
|
230
|
+
// the other fields are unchanged. Text replaced by the PM's final wording (Oct 18); codes and fields are as before.
|
|
231
|
+
const UNAVAILABLE_MESSAGE = "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."
|
|
232
|
+
+ ` (${BRAND.unavailableCode})`;
|
|
233
|
+
// Addendum 3 amendment (Oct 18, CoS): the reset day as "<Month D>" in UTC (full English month, day without a leading
|
|
234
|
+
// zero, no year), e.g. "2026-11-01T00:00:00.000Z" -> "November 1". Only for a string that is a valid ISO-8601 date-time
|
|
235
|
+
// (YYYY-MM-DDTHH:MM:SS, optional .fraction, then Z or +HH:MM/-HH:MM); anything else (missing, null, "", 0, false, a
|
|
236
|
+
// number, garbage, trailing text, an impossible date) gives null and the sentence is left out. Fixed month array and
|
|
237
|
+
// integer UTC math only (no Date.parse, no locale APIs), so the bytes match the Ruby SDK's reset_day_utc exactly.
|
|
238
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
239
|
+
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|([+-])(\d{2}):(\d{2}))$/;
|
|
240
|
+
function resetDayUtc(v) {
|
|
241
|
+
if (typeof v !== 'string') return null;
|
|
242
|
+
const m = ISO_DATE_TIME.exec(v);
|
|
243
|
+
if (!m) return null;
|
|
244
|
+
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
245
|
+
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
246
|
+
const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
247
|
+
if (mo < 1 || mo > 12 || d < 1 || d > dim[mo - 1] || h > 23 || mi > 59 || s > 59) return null;
|
|
248
|
+
let off = 0;
|
|
249
|
+
if (m[7]) {
|
|
250
|
+
const oh = Number(m[8]);
|
|
251
|
+
const om = Number(m[9]);
|
|
252
|
+
if (oh > 23 || om > 59) return null;
|
|
253
|
+
off = (m[7] === '-' ? -1 : 1) * (oh * 60 + om);
|
|
254
|
+
}
|
|
255
|
+
// Minutes from local midnight, shifted to UTC; at most one day either way.
|
|
256
|
+
const mins = h * 60 + mi - off;
|
|
257
|
+
let uy = y; let um = mo; let ud = d;
|
|
258
|
+
if (mins < 0) {
|
|
259
|
+
ud -= 1;
|
|
260
|
+
if (ud < 1) { um -= 1; if (um < 1) { um = 12; uy -= 1; } ud = dim[um - 1]; } // Feb only from Mar 1 (same year)
|
|
261
|
+
} else if (mins >= 1440) {
|
|
262
|
+
ud += 1;
|
|
263
|
+
if (ud > dim[mo - 1]) { ud = 1; um += 1; if (um > 12) { um = 1; uy += 1; } }
|
|
264
|
+
}
|
|
265
|
+
return `${MONTHS[um - 1]} ${ud}`;
|
|
266
|
+
}
|
|
267
|
+
function testLimitMessage(resetsAt) {
|
|
268
|
+
const day = resetDayUtc(resetsAt);
|
|
269
|
+
return "This call wasn't run. This service's test calls for the month are used up."
|
|
270
|
+
+ (day ? ` They reset on ${day} (UTC).` : '')
|
|
271
|
+
+ " 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)";
|
|
272
|
+
}
|
|
273
|
+
// PM final wording (Oct 18). tool_not_in_plan names the tool the SDK sent in decide(), in double quotes, only when it
|
|
274
|
+
// looks like a plain tool name (^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$); otherwise (missing, not a string, spaces, quotes,
|
|
275
|
+
// control or bidi characters, 65+ chars) "this action". Any other reason is echoed in brackets only if it looks like one
|
|
276
|
+
// of the API's codes (same guard as rejectedMessage). Same bytes as the Ruby SDK.
|
|
277
|
+
const TOOL_NAME_SHAPE = /^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,63}$/;
|
|
278
|
+
// QA P95F-N3: nothing URL-like is echoed either: no "//" anywhere and no leading "scheme:" (RFC 3986 shape).
|
|
279
|
+
const TOOL_SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
280
|
+
function showableTool(tool) {
|
|
281
|
+
return typeof tool === 'string' && TOOL_NAME_SHAPE.test(tool) && !tool.includes('//') && !TOOL_SCHEME_PREFIX.test(tool);
|
|
282
|
+
}
|
|
283
|
+
function planDeniedMessage(reason, tool) {
|
|
284
|
+
if (reason === 'tool_not_in_plan') {
|
|
285
|
+
const what = showableTool(tool) ? `"${tool}"` : 'this action';
|
|
286
|
+
return `This call wasn't run because your plan doesn't include ${what}. If you need it, ask whoever manages your plan. (tool_not_in_plan)`;
|
|
287
|
+
}
|
|
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
|
+
return typeof reason === 'string' && /^[a-z][a-z_]{0,39}$/.test(reason) ? `${msg} (${reason})` : msg;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// T18-P39: the agent key's name (label) from a deny answer, made safe for a JSON body, a sentence and a log line:
|
|
293
|
+
// control (C0, DEL and C1 U+0080-U+009F), lone-surrogate and invisible/format characters removed, whitespace collapsed, at most MAX_LABEL_LENGTH
|
|
294
|
+
// characters (a cut label ends with "…"). null when there's no usable label. The server cleans it too; this doesn't
|
|
295
|
+
// trust that.
|
|
296
|
+
const MAX_LABEL_LENGTH = 64;
|
|
297
|
+
const STRIP_LABEL = /[\u0000-\u001f\u007f-\u009f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]|[\p{Cf}\u115f\u1160\u3164\uffa0\ufe00-\ufe0f\u{e0000}-\u{e007f}\u{e0100}-\u{e01ef}]/gu;
|
|
298
|
+
function safeKeyLabel(v) {
|
|
299
|
+
if (typeof v !== 'string' || !v) return null;
|
|
300
|
+
const clean = v.replace(/\s+/gu, ' ').replace(STRIP_LABEL, '').replace(/ {2,}/g, ' ').trim();
|
|
301
|
+
if (!clean) return null;
|
|
302
|
+
const chars = Array.from(clean);
|
|
303
|
+
return chars.length <= MAX_LABEL_LENGTH ? clean : `${chars.slice(0, MAX_LABEL_LENGTH - 1).join('').trimEnd()}…`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// What a blocked call should answer. Safe to return verbatim to the agent.
|
|
307
|
+
// Same conventions as the Ordainer API: an X-Request-Id header and
|
|
308
|
+
// {"error": {"code", "message", "request_id", ...}}.
|
|
309
|
+
function denyResponse(result, now = Date.now()) {
|
|
310
|
+
const requestIdHeader = result.requestId ? { 'X-Request-Id': result.requestId } : {};
|
|
311
|
+
// 429 rate_limited: a real "slow down", so Retry-After (the server's value).
|
|
312
|
+
if (!result.allowed && result.rateLimited) {
|
|
313
|
+
const retry = String(result.retryAfterSec || 1);
|
|
314
|
+
return {
|
|
315
|
+
status: 429,
|
|
316
|
+
headers: { 'Retry-After': retry, ...requestIdHeader },
|
|
317
|
+
body: {
|
|
318
|
+
error: {
|
|
319
|
+
code: 'rate_limited',
|
|
320
|
+
// Content & DevRel (Oct 18): plain words for the service's customers; no product name. Retry-After still carries the seconds.
|
|
321
|
+
message: result.reason === CONCURRENCY_CODE ? CONCURRENCY_LIMITED_MESSAGE : RATE_LIMITED_MESSAGE,
|
|
322
|
+
request_id: result.requestId || null,
|
|
323
|
+
retry_after: Number(retry),
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// G5/Q2: a block a retry can't fix (missing/unknown/revoked key, any other
|
|
329
|
+
// rejected 4xx, or the vendor's billing): plan_check_rejected, never 503 (a 503
|
|
330
|
+
// says "outage, retry"), no Retry-After and no "try again", so agents don't
|
|
331
|
+
// hammer it. 403 when it's the vendor's billing (subscription_required, or the
|
|
332
|
+
// subscription_inactive lapse under billingLapse 'closed'), with the neutral
|
|
333
|
+
// Legal-approved message (Terms 10.3(b)); 500 otherwise (a key, config, 400 or
|
|
334
|
+
// 409 problem on the vendor's side).
|
|
335
|
+
if (result.rejected || (result.failClosed && result.billingInactive)) {
|
|
336
|
+
const billing = result.billingInactive;
|
|
337
|
+
return {
|
|
338
|
+
status: billing ? 403 : 500,
|
|
339
|
+
headers: { ...requestIdHeader },
|
|
340
|
+
body: {
|
|
341
|
+
error: {
|
|
342
|
+
code: BRAND.rejectedCode,
|
|
343
|
+
message: billing
|
|
344
|
+
? BILLING_BLOCKED_MESSAGE
|
|
345
|
+
: rejectedMessage(result.error && result.error.code),
|
|
346
|
+
request_id: result.requestId || null,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// A real outage (timeout, network error, 5xx, malformed answer) under failMode 'closed'.
|
|
352
|
+
if (result.failClosed) {
|
|
353
|
+
return {
|
|
354
|
+
status: 503,
|
|
355
|
+
headers: { 'Retry-After': '5', ...requestIdHeader },
|
|
356
|
+
body: {
|
|
357
|
+
error: {
|
|
358
|
+
code: BRAND.unavailableCode,
|
|
359
|
+
message: UNAVAILABLE_MESSAGE,
|
|
360
|
+
request_id: result.requestId || null,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (result.reason === TEST_LIMIT_REASON) {
|
|
366
|
+
const resets = result.resetsAt ? Date.parse(result.resetsAt) : NaN;
|
|
367
|
+
const headers = { ...requestIdHeader };
|
|
368
|
+
if (Number.isFinite(resets)) headers['Retry-After'] = String(Math.max(1, Math.ceil((resets - now) / 1000)));
|
|
369
|
+
return {
|
|
370
|
+
status: 429,
|
|
371
|
+
headers,
|
|
372
|
+
body: {
|
|
373
|
+
error: {
|
|
374
|
+
code: TEST_LIMIT_REASON,
|
|
375
|
+
message: testLimitMessage(result.resetsAt),
|
|
376
|
+
request_id: result.requestId,
|
|
377
|
+
reason: result.reason,
|
|
378
|
+
resets_at: result.resetsAt,
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (result.reason === 'quota_exceeded') {
|
|
384
|
+
const label = safeKeyLabel(result.label);
|
|
385
|
+
const resets = result.resetsAt ? Date.parse(result.resetsAt) : NaN;
|
|
386
|
+
const headers = { ...requestIdHeader };
|
|
387
|
+
if (Number.isFinite(resets)) headers['Retry-After'] = String(Math.max(1, Math.ceil((resets - now) / 1000)));
|
|
388
|
+
return {
|
|
389
|
+
status: result.denyStatus || 429,
|
|
390
|
+
headers,
|
|
391
|
+
body: {
|
|
392
|
+
error: {
|
|
393
|
+
code: 'plan_limit_reached',
|
|
394
|
+
// T18-P39: name the agent when the API sent its key's label (and exposeKeyLabel isn't false).
|
|
395
|
+
message: (label
|
|
396
|
+
? `This agent (${label}) has reached its plan limit (${result.remaining ?? 0} of ${result.limit ?? '?'} left).`
|
|
397
|
+
: `This agent's plan limit is reached (${result.remaining ?? 0} of ${result.limit ?? '?'} left).`)
|
|
398
|
+
+ (result.resetsAt ? ` It resets at ${result.resetsAt}.` : '')
|
|
399
|
+
+ ' Retrying before then will not succeed. This call was not run.',
|
|
400
|
+
...(label ? { label } : {}),
|
|
401
|
+
request_id: result.requestId,
|
|
402
|
+
reason: result.reason,
|
|
403
|
+
remaining: result.remaining,
|
|
404
|
+
limit: result.limit,
|
|
405
|
+
resets_at: result.resetsAt,
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
status: 403,
|
|
412
|
+
headers: { ...requestIdHeader },
|
|
413
|
+
body: {
|
|
414
|
+
error: {
|
|
415
|
+
code: 'plan_denied',
|
|
416
|
+
message: planDeniedMessage(result.reason, result.tool),
|
|
417
|
+
request_id: result.requestId,
|
|
418
|
+
reason: result.reason,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function sendDeny(res, result) {
|
|
425
|
+
const { status, headers, body } = denyResponse(result);
|
|
426
|
+
const payload = JSON.stringify(body);
|
|
427
|
+
res.statusCode = status;
|
|
428
|
+
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
|
|
429
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
430
|
+
res.setHeader('Content-Length', Buffer.byteLength(payload));
|
|
431
|
+
res.end(payload);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Hooks must never break the request.
|
|
435
|
+
function callHook(hook, result) {
|
|
436
|
+
if (!hook) return;
|
|
437
|
+
try {
|
|
438
|
+
const p = hook(result);
|
|
439
|
+
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
440
|
+
} catch {
|
|
441
|
+
// ignored
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ENOTFOUND / EAI_AGAIN from fetch (undici puts the system error in err.cause), else null.
|
|
446
|
+
function dnsErrorCode(err) {
|
|
447
|
+
for (let e = err, depth = 0; e && depth < 4; e = e.cause, depth += 1) {
|
|
448
|
+
if (DNS_CODES.has(e.code)) return e.code;
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function resolve(value, ...args) {
|
|
454
|
+
return typeof value === 'function' ? value(...args) : value;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
class Ordainer {
|
|
458
|
+
/**
|
|
459
|
+
* @param {object} [opts]
|
|
460
|
+
* @param {string|function} [opts.apiKey] agent key (ord_test_/ord_live_), or (…args) => key. Default env ORDAINER_KEY.
|
|
461
|
+
* @param {string} [opts.baseUrl] default env ORDAINER_BASE_URL or https://api.ordainer.dev
|
|
462
|
+
* @param {number} [opts.timeoutMs] default 300
|
|
463
|
+
* @param {'enforce'|'shadow'} [opts.mode] unset = follow the plan's mode in the console
|
|
464
|
+
* @param {'open'|'closed'} [opts.failMode] default 'open'. Applies ONLY to timeouts, network errors, 5xx and malformed
|
|
465
|
+
* answers; any 4xx or a missing key always blocks (result.rejected), except
|
|
466
|
+
* 403 subscription_inactive, which follows billingLapse.
|
|
467
|
+
* @param {'open'|'closed'} [opts.billingLapse] default 'open' (Terms 10.3(b)). What to do when the API answers
|
|
468
|
+
* 403 subscription_inactive (your Ordainer subscription is inactive): 'open' runs
|
|
469
|
+
* the call unchecked; 'closed' blocks it (403 plan_check_rejected, no Retry-After,
|
|
470
|
+
* neutral message). Separate from failMode. Override per call/route.
|
|
471
|
+
* @param {number} [opts.retries] extra attempts after a timeout, network error or 503 (default 1, max 5; 0 = off).
|
|
472
|
+
* Each retry sends the SAME idempotency key, so it is replayed, never counted twice.
|
|
473
|
+
* 4xx and other 5xx are never retried.
|
|
474
|
+
* @param {number} [opts.retryBudgetMs] total time for all attempts of one decide() (default 650). A retry starts only
|
|
475
|
+
* with at least 50 ms left, and its timeout is cut to the time left.
|
|
476
|
+
* @param {number} [opts.stickyDenyMs] after a quota_exceeded deny for an (agent key, tool), deny a would-be fail-open for
|
|
477
|
+
* that pair for this long (default 60000, 0 = off, max 300000; ends early at the
|
|
478
|
+
* plan's reset or on an allow). Shadow still runs the call and reports it.
|
|
479
|
+
* @param {429|402} [opts.denyStatus] HTTP status for quota denies: 429 (default) or 402. Anything else throws
|
|
480
|
+
* here. Override per route with middleware({ denyStatus }).
|
|
481
|
+
* @param {function} [opts.onDecision] called with every result (never throws into your request)
|
|
482
|
+
* @param {function} [opts.onBillingInactive] called with the result whenever the API answers 403 subscription_inactive
|
|
483
|
+
* (subscription ended) or 403 subscription_required (live key, no paid
|
|
484
|
+
* subscription yet). Both set result.billingInactive; check result.error.code.
|
|
485
|
+
* @param {function} [opts.onTestLimit] called with the result whenever the API denies with test_limit_reached
|
|
486
|
+
* @param {function} [opts.onRateLimited] called with the result whenever the API answers 429 rate_limited
|
|
487
|
+
* @param {function} [opts.onSafetyLimit] called with the result whenever the API answers 429 safety_limit_reached
|
|
488
|
+
* (shadow calls past the safety ceiling; the call runs in any failMode)
|
|
489
|
+
* @param {number} [opts.warningIntervalMs] min gap between log warnings of one kind (fail-open/fail-closed per error code,
|
|
490
|
+
* billing, test limit, rate limit, safety limit), default 60000. Old name: billingWarningIntervalMs
|
|
491
|
+
* @param {object|false} [opts.logger] { warn(msg), error?(msg) }, default console; false = silent. error() (else warn())
|
|
492
|
+
* gets the one-time "base URL host doesn't resolve" line
|
|
493
|
+
* @param {function} [opts.fetch] fetch implementation (tests)
|
|
494
|
+
*/
|
|
495
|
+
constructor(opts = {}) {
|
|
496
|
+
checkMode(opts.mode, BRAND.name);
|
|
497
|
+
checkFailMode(opts.failMode, BRAND.name);
|
|
498
|
+
checkBillingLapse(opts.billingLapse, BRAND.name);
|
|
499
|
+
checkDenyStatus(opts.denyStatus, BRAND.name);
|
|
500
|
+
checkRetries(opts.retries, BRAND.name);
|
|
501
|
+
this.retries = opts.retries ?? DEFAULT_RETRIES;
|
|
502
|
+
if (opts.retryBudgetMs !== undefined && (!Number.isInteger(opts.retryBudgetMs) || opts.retryBudgetMs < 0)) {
|
|
503
|
+
throw new TypeError(`${BRAND.name}: retryBudgetMs must be an integer >= 0`);
|
|
504
|
+
}
|
|
505
|
+
this.retryBudgetMs = opts.retryBudgetMs ?? DEFAULT_RETRY_BUDGET_MS;
|
|
506
|
+
if (opts.stickyDenyMs !== undefined && (!Number.isInteger(opts.stickyDenyMs) || opts.stickyDenyMs < 0 || opts.stickyDenyMs > MAX_STICKY_DENY_MS)) {
|
|
507
|
+
throw new TypeError(`${BRAND.name}: stickyDenyMs must be an integer from 0 (off) to ${MAX_STICKY_DENY_MS}`);
|
|
508
|
+
}
|
|
509
|
+
this.stickyDenyMs = opts.stickyDenyMs ?? DEFAULT_STICKY_DENY_MS;
|
|
510
|
+
this._sticky = new Map(); // sha256(key)\0tool -> { until, mode, limit, resetsAt }
|
|
511
|
+
this._sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); // tests
|
|
512
|
+
this.apiKey = opts.apiKey ?? process.env[BRAND.envKey];
|
|
513
|
+
this.baseUrl = String(opts.baseUrl || process.env[BRAND.envBaseUrl] || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
514
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
515
|
+
this.mode = opts.mode || undefined;
|
|
516
|
+
this.failMode = opts.failMode || 'open';
|
|
517
|
+
this.billingLapse = opts.billingLapse || 'open';
|
|
518
|
+
this.denyStatus = opts.denyStatus || 429;
|
|
519
|
+
// T18-P39: put the agent key's label (from a deny answer) in the 429 plan_limit_reached body and sentence.
|
|
520
|
+
// Labels are yours to choose; set false if they are internal names your callers shouldn't see.
|
|
521
|
+
if (opts.exposeKeyLabel !== undefined && typeof opts.exposeKeyLabel !== 'boolean') {
|
|
522
|
+
throw new TypeError(`${BRAND.name}: exposeKeyLabel must be true or false`);
|
|
523
|
+
}
|
|
524
|
+
this.exposeKeyLabel = opts.exposeKeyLabel === true; // Tyler (Oct 18 2026, via CoS): off by default; opt in with true.
|
|
525
|
+
this.onDecision = opts.onDecision || null;
|
|
526
|
+
this.onBillingInactive = opts.onBillingInactive || null;
|
|
527
|
+
// How often each kind of warning may be logged (fail-open/closed per error code,
|
|
528
|
+
// billing, rate limit, test limit). billingWarningIntervalMs is the old name.
|
|
529
|
+
this.billingWarningIntervalMs = opts.warningIntervalMs ?? opts.billingWarningIntervalMs ?? BILLING_WARNING_INTERVAL_MS;
|
|
530
|
+
this._now = opts.now || Date.now; // tests
|
|
531
|
+
this.onTestLimit = opts.onTestLimit || null;
|
|
532
|
+
this.onRateLimited = opts.onRateLimited || null;
|
|
533
|
+
this.onSafetyLimit = opts.onSafetyLimit || null;
|
|
534
|
+
this._warned = new Map(); // kind -> { at, suppressed }
|
|
535
|
+
this._dnsReported = new Set(); // hosts already reported as not resolving (loud once each)
|
|
536
|
+
this.logger = opts.logger === false ? null : (opts.logger || console);
|
|
537
|
+
this.fetch = opts.fetch || globalThis.fetch;
|
|
538
|
+
if (typeof this.fetch !== 'function') throw new Error(`${BRAND.name} needs Node 20+ (global fetch) or opts.fetch`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Ask Ordainer whether this call may run. Never throws.
|
|
543
|
+
* @returns {Promise<DecideResult>}
|
|
544
|
+
*/
|
|
545
|
+
async decide({ tool, apiKey, idempotencyKey, mode, failMode, billingLapse, retries } = {}) {
|
|
546
|
+
checkMode(mode, 'decide');
|
|
547
|
+
checkFailMode(failMode, 'decide');
|
|
548
|
+
checkBillingLapse(billingLapse, 'decide');
|
|
549
|
+
checkRetries(retries, 'decide');
|
|
550
|
+
const started = Date.now();
|
|
551
|
+
const key = apiKey ?? (typeof this.apiKey === 'function' ? undefined : this.apiKey);
|
|
552
|
+
const effectiveFailMode = failMode || this.failMode;
|
|
553
|
+
const effectiveBillingLapse = billingLapse || this.billingLapse;
|
|
554
|
+
const requestedMode = mode || this.mode;
|
|
555
|
+
const rawIdem = typeof idempotencyKey === 'number' ? String(idempotencyKey) : idempotencyKey;
|
|
556
|
+
const idem = typeof rawIdem === 'string' && rawIdem
|
|
557
|
+
? idempotencyKeyFor(typeof tool === 'string' ? tool : '', rawIdem)
|
|
558
|
+
: (rawIdem || crypto.randomUUID());
|
|
559
|
+
const base = {
|
|
560
|
+
tool, idempotencyKey: idem, decision: null, reason: null, remaining: null, limit: null, resetsAt: null,
|
|
561
|
+
requestId: null, mode: requestedMode || null, wouldDeny: false, failOpen: false, failClosed: false,
|
|
562
|
+
error: null, denyStatus: this.denyStatus, billingInactive: false, testLimitReached: false,
|
|
563
|
+
rateLimited: false, retryAfterSec: null, rejected: false, replayed: false, attempts: 0, stickyDeny: false,
|
|
564
|
+
billingLapse: null, safetyLimited: false, label: null,
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
let result;
|
|
568
|
+
if (!key) {
|
|
569
|
+
result = this._rejected(base, { code: 'missing_key', message: `No ${BRAND.name} agent key (apiKey / ${BRAND.envKey}).` });
|
|
570
|
+
} else if (typeof tool !== 'string' || !tool) {
|
|
571
|
+
result = this._rejected(base, { code: 'invalid_request', message: 'tool is required.' });
|
|
572
|
+
} else {
|
|
573
|
+
// Retries reuse `idem`, so the server replays instead of counting again.
|
|
574
|
+
const maxAttempts = 1 + (retries ?? this.retries);
|
|
575
|
+
const budgetEnd = started + this.retryBudgetMs;
|
|
576
|
+
let timeoutMs = this.timeoutMs;
|
|
577
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
578
|
+
result = await this._call(base, { key, tool, idem, requestedMode, effectiveFailMode, effectiveBillingLapse, timeoutMs });
|
|
579
|
+
result.attempts = attempt;
|
|
580
|
+
if (!result.retryable || attempt >= maxAttempts) break;
|
|
581
|
+
const pause = RETRY_DELAY_MS * 2 ** (attempt - 1);
|
|
582
|
+
const left = budgetEnd - Date.now() - pause;
|
|
583
|
+
if (left < MIN_ATTEMPT_MS) break; // out of budget: failMode applies now
|
|
584
|
+
timeoutMs = Math.min(this.timeoutMs, left);
|
|
585
|
+
await this._sleep(pause);
|
|
586
|
+
}
|
|
587
|
+
delete result.retryable;
|
|
588
|
+
result = this._applyStickyDeny(result, { key, tool, requestedMode });
|
|
589
|
+
}
|
|
590
|
+
result.latencyMs = Date.now() - started;
|
|
591
|
+
this._report(result);
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async _call(base, { key, tool, idem, requestedMode, effectiveFailMode, effectiveBillingLapse = this.billingLapse, timeoutMs = this.timeoutMs }) {
|
|
596
|
+
let res;
|
|
597
|
+
try {
|
|
598
|
+
const body = { tool, idempotency_key: idem };
|
|
599
|
+
if (requestedMode) body.mode = requestedMode;
|
|
600
|
+
res = await this.fetch(`${this.baseUrl}/v1/decide`, {
|
|
601
|
+
method: 'POST',
|
|
602
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'User-Agent': BRAND.userAgent },
|
|
603
|
+
body: JSON.stringify(body),
|
|
604
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
605
|
+
});
|
|
606
|
+
} catch (err) {
|
|
607
|
+
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
608
|
+
const dnsCode = timedOut ? null : dnsErrorCode(err);
|
|
609
|
+
const failure = this._failure(base, effectiveFailMode, {
|
|
610
|
+
code: timedOut ? 'timeout' : 'network_error',
|
|
611
|
+
message: timedOut ? `${BRAND.name} did not answer within ${timeoutMs}ms`
|
|
612
|
+
: dnsCode ? `${String(err && err.message)} (${dnsCode}: host ${this._host()} not found)` : String(err && err.message),
|
|
613
|
+
});
|
|
614
|
+
if (dnsCode) {
|
|
615
|
+
failure.error.dnsCode = dnsCode;
|
|
616
|
+
this._reportDns(dnsCode, effectiveFailMode);
|
|
617
|
+
}
|
|
618
|
+
failure.retryable = true;
|
|
619
|
+
return failure;
|
|
620
|
+
}
|
|
621
|
+
const header = (name) => (res.headers && typeof res.headers.get === 'function' ? res.headers.get(name) : null);
|
|
622
|
+
let json = null;
|
|
623
|
+
try {
|
|
624
|
+
json = await res.json();
|
|
625
|
+
} catch {
|
|
626
|
+
// fall through: treated as a failure below
|
|
627
|
+
}
|
|
628
|
+
if (res.status !== 200 || !json || (json.decision !== 'allow' && json.decision !== 'deny')) {
|
|
629
|
+
const env = json && json.error ? json.error : {};
|
|
630
|
+
const error = {
|
|
631
|
+
code: env.code || (res.status >= 500 ? 'server_error' : 'bad_response'),
|
|
632
|
+
message: env.message || `${BRAND.name} answered HTTP ${res.status}`,
|
|
633
|
+
status: res.status,
|
|
634
|
+
requestId: env.request_id || header('x-request-id') || null,
|
|
635
|
+
};
|
|
636
|
+
// H3: which limit a 429 came from ('per_key' | 'per_address'), from the server's
|
|
637
|
+
// error.limit_scope (or, from an older server, inferred from the code). Named
|
|
638
|
+
// limitScope, not limit: result.limit is the plan's cap (number | null).
|
|
639
|
+
if (res.status === 429) {
|
|
640
|
+
error.limitScope = LIMIT_SCOPES.has(env.limit_scope) ? env.limit_scope
|
|
641
|
+
: env.code === 'too_many_failed_auth' ? 'per_address' : env.code === 'rate_limited' ? 'per_key'
|
|
642
|
+
: env.code === CONCURRENCY_CODE ? 'per_key_concurrency' : env.code === SAFETY_LIMIT_CODE ? 'org_safety' : undefined;
|
|
643
|
+
}
|
|
644
|
+
// 1.2.0: 429 safety_limit_reached (shadow calls past the safety ceiling, 10x the account's monthly test limit) is a
|
|
645
|
+
// safety limit, not a deny: the call runs in ANY failMode, like billingLapse 'open'. Not failOpen (failMode didn't
|
|
646
|
+
// decide it), not rejected.
|
|
647
|
+
if (res.status === 429 && env.code === SAFETY_LIMIT_CODE) {
|
|
648
|
+
const secs = Number.parseInt(header('retry-after'), 10);
|
|
649
|
+
return {
|
|
650
|
+
...base,
|
|
651
|
+
allowed: true,
|
|
652
|
+
failOpen: false,
|
|
653
|
+
failClosed: false,
|
|
654
|
+
safetyLimited: true,
|
|
655
|
+
reason: SAFETY_LIMIT_CODE,
|
|
656
|
+
resetsAt: typeof env.resets_at === 'string' ? env.resets_at : null,
|
|
657
|
+
retryAfterSec: Number.isFinite(secs) && secs > 0 ? secs : null,
|
|
658
|
+
requestId: error.requestId,
|
|
659
|
+
error: { code: error.code, message: error.message, status: error.status, limitScope: 'org_safety' },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// failMode covers "Ordainer couldn't answer" only: 5xx and malformed
|
|
663
|
+
// answers. A 4xx is Ordainer answering "no" (bad key, revoked, billing,
|
|
664
|
+
// rate limit, conflict): never an unchecked allow (QA S2).
|
|
665
|
+
const is4xx = res.status >= 400 && res.status < 500;
|
|
666
|
+
// Terms 10.3(b) (Option A, Tyler Oct 3): 403 subscription_inactive (the vendor's subscription is inactive)
|
|
667
|
+
// follows only the billing-lapse setting, never failMode.
|
|
668
|
+
const lapse = res.status === 403 && env.code === 'subscription_inactive';
|
|
669
|
+
const failure = lapse ? this._lapse(base, effectiveBillingLapse, error)
|
|
670
|
+
: is4xx ? this._rejected(base, error) : this._failure(base, effectiveFailMode, error);
|
|
671
|
+
// Only a 503 (decide_timeout, db_timeout, db_unavailable, an outage) is retried; a 500 or another 5xx is not.
|
|
672
|
+
if (res.status === 503) failure.retryable = true;
|
|
673
|
+
// Billing state, not an outage.
|
|
674
|
+
if (res.status === 403 && BILLING_CODES.has(env.code)) {
|
|
675
|
+
failure.billingInactive = true;
|
|
676
|
+
failure.reason = env.code;
|
|
677
|
+
}
|
|
678
|
+
// Per-key rate limit, not an outage; never silent. (The per-address
|
|
679
|
+
// too_many_failed_auth 429 is a bad key, not a rate problem: rejected, with
|
|
680
|
+
// its own warning; rateLimited stays false.)
|
|
681
|
+
// 429 concurrency_limited (QA round 3 M-1: too many decides in flight for this key)
|
|
682
|
+
// is the same kind of "slow down": blocked in any failMode, never a fail-open.
|
|
683
|
+
if (res.status === 429 && (env.code === 'rate_limited' || env.code === CONCURRENCY_CODE)) {
|
|
684
|
+
const raw = header('retry-after');
|
|
685
|
+
const secs = Number.parseInt(raw, 10);
|
|
686
|
+
failure.rateLimited = true;
|
|
687
|
+
failure.reason = env.code;
|
|
688
|
+
failure.retryAfterSec = Number.isFinite(secs) && secs > 0 ? secs : 1;
|
|
689
|
+
}
|
|
690
|
+
return failure;
|
|
691
|
+
}
|
|
692
|
+
const testLimitReached = json.decision === 'deny' && json.reason === TEST_LIMIT_REASON;
|
|
693
|
+
// test_limit_reached is Ordainer's own limit, not the plan's. From 1.2.0 the server answers it with mode 'shadow'
|
|
694
|
+
// for a shadow call (shadow never blocks: the call runs as a would-deny); anything else blocks (an older server
|
|
695
|
+
// answers mode 'enforce' even on a shadow plan).
|
|
696
|
+
const effectiveMode = testLimitReached ? (json.mode === 'shadow' ? 'shadow' : 'enforce')
|
|
697
|
+
: (json.mode === 'shadow' || json.mode === 'enforce' ? json.mode : (requestedMode || 'enforce'));
|
|
698
|
+
const denied = json.decision === 'deny';
|
|
699
|
+
// 1.2.2: wouldDeny means what the docs say, a shadow deny (Ordainer said deny and the call ran anyway). An enforce
|
|
700
|
+
// deny is a real deny: allowed false, wouldDeny false. 1.2.1 and earlier set it on every deny.
|
|
701
|
+
const wouldDeny = denied && effectiveMode === 'shadow';
|
|
702
|
+
return {
|
|
703
|
+
...base,
|
|
704
|
+
testLimitReached,
|
|
705
|
+
allowed: !denied || effectiveMode === 'shadow',
|
|
706
|
+
decision: json.decision,
|
|
707
|
+
reason: json.reason,
|
|
708
|
+
remaining: json.remaining,
|
|
709
|
+
limit: json.limit,
|
|
710
|
+
resetsAt: json.resets_at,
|
|
711
|
+
requestId: json.request_id,
|
|
712
|
+
mode: effectiveMode,
|
|
713
|
+
wouldDeny,
|
|
714
|
+
// T18-P39: the server sends the key's label on denies only.
|
|
715
|
+
label: denied && this.exposeKeyLabel ? safeKeyLabel(json.label) : null,
|
|
716
|
+
// The server answered from a stored decision for this idempotency key (not counted again).
|
|
717
|
+
replayed: header('idempotent-replayed') === 'true',
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Sticky deny: remember a quota_exceeded deny per (key, tool); a later would-be fail-open for the pair is denied.
|
|
722
|
+
_applyStickyDeny(result, { key, tool, requestedMode }) {
|
|
723
|
+
if (!this.stickyDenyMs) return result;
|
|
724
|
+
const id = `${crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 32)}\u0000${tool}`;
|
|
725
|
+
const now = this._now();
|
|
726
|
+
if (result.decision === 'deny' && result.reason === 'quota_exceeded') {
|
|
727
|
+
const resets = result.resetsAt ? Date.parse(result.resetsAt) : NaN;
|
|
728
|
+
const until = Math.min(now + this.stickyDenyMs, Number.isFinite(resets) ? resets : Infinity);
|
|
729
|
+
if (until > now) {
|
|
730
|
+
this._sticky.delete(id);
|
|
731
|
+
if (this._sticky.size >= MAX_STICKY_ENTRIES) {
|
|
732
|
+
for (const [k, v] of this._sticky) if (v.until <= now) this._sticky.delete(k);
|
|
733
|
+
if (this._sticky.size >= MAX_STICKY_ENTRIES) this._sticky.delete(this._sticky.keys().next().value);
|
|
734
|
+
}
|
|
735
|
+
this._sticky.set(id, { until, mode: result.mode, limit: result.limit, resetsAt: result.resetsAt, label: result.label });
|
|
736
|
+
}
|
|
737
|
+
return result;
|
|
738
|
+
}
|
|
739
|
+
if (result.decision === 'allow') {
|
|
740
|
+
this._sticky.delete(id);
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
if (!result.failOpen || result.billingInactive) return result;
|
|
744
|
+
const entry = this._sticky.get(id);
|
|
745
|
+
if (!entry) return result;
|
|
746
|
+
if (entry.until <= now) {
|
|
747
|
+
this._sticky.delete(id);
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
const shadow = requestedMode === 'shadow' || (!requestedMode && entry.mode === 'shadow');
|
|
751
|
+
if (shadow) return { ...result, stickyDeny: true, wouldDeny: true, mode: 'shadow', label: entry.label || null }; // still runs, reported
|
|
752
|
+
return {
|
|
753
|
+
...result,
|
|
754
|
+
allowed: false,
|
|
755
|
+
failOpen: false,
|
|
756
|
+
stickyDeny: true,
|
|
757
|
+
wouldDeny: false, // 1.2.2: blocked, so a real deny, not a would-deny
|
|
758
|
+
reason: 'quota_exceeded',
|
|
759
|
+
remaining: 0,
|
|
760
|
+
limit: entry.limit,
|
|
761
|
+
resetsAt: entry.resetsAt,
|
|
762
|
+
label: entry.label || null,
|
|
763
|
+
mode: 'enforce',
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Ordainer answered with a 4xx (or the call can't be made: no key, no tool).
|
|
768
|
+
// Blocked whatever failMode says: these are answers, not outages. G5:
|
|
769
|
+
// failClosed stays false (failMode didn't decide this); rejected says why.
|
|
770
|
+
_rejected(base, error) {
|
|
771
|
+
return {
|
|
772
|
+
...base,
|
|
773
|
+
allowed: false,
|
|
774
|
+
failOpen: false,
|
|
775
|
+
failClosed: false,
|
|
776
|
+
rejected: true,
|
|
777
|
+
requestId: error.requestId || null,
|
|
778
|
+
error: { code: error.code, message: error.message, status: error.status, ...(error.limitScope ? { limitScope: error.limitScope } : {}) },
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// 403 subscription_inactive under the billing-lapse setting. 'open': the call runs, unchecked (allowed, but not
|
|
783
|
+
// failOpen: failMode didn't decide it). 'closed': blocked like a rejection (403 plan_check_rejected, no Retry-After).
|
|
784
|
+
_lapse(base, billingLapse, error) {
|
|
785
|
+
const result = billingLapse === 'closed' ? this._rejected(base, error) : {
|
|
786
|
+
...base,
|
|
787
|
+
allowed: true,
|
|
788
|
+
failOpen: false,
|
|
789
|
+
failClosed: false,
|
|
790
|
+
requestId: error.requestId || null,
|
|
791
|
+
error: { code: error.code, message: error.message, status: error.status },
|
|
792
|
+
};
|
|
793
|
+
result.billingLapse = billingLapse;
|
|
794
|
+
return result;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
_failure(base, failMode, error) {
|
|
798
|
+
return {
|
|
799
|
+
...base,
|
|
800
|
+
allowed: failMode === 'open',
|
|
801
|
+
failOpen: failMode === 'open',
|
|
802
|
+
failClosed: failMode === 'closed',
|
|
803
|
+
requestId: error.requestId || null,
|
|
804
|
+
error: { code: error.code, message: error.message, status: error.status },
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
_report(result) {
|
|
809
|
+
if (result.safetyLimited) {
|
|
810
|
+
this._reportSafetyLimit(result);
|
|
811
|
+
} else if (result.billingInactive) {
|
|
812
|
+
this._reportBillingInactive(result);
|
|
813
|
+
} else if (result.rateLimited) {
|
|
814
|
+
this._reportRateLimited(result);
|
|
815
|
+
} else if (result.error && result.error.limitScope === 'per_address') {
|
|
816
|
+
this._reportFailedAuth(result);
|
|
817
|
+
} else if (result.testLimitReached) {
|
|
818
|
+
this._reportTestLimit(result);
|
|
819
|
+
} else if (this.logger) {
|
|
820
|
+
if (result.stickyDeny) {
|
|
821
|
+
this._rateLimitedWarn(`sticky_deny:${result.error.code}`, () => `${BRAND.logPrefix} sticky deny: ${result.error.code} while tool=${result.tool} was at its plan limit`
|
|
822
|
+
+ ` (quota_exceeded within the last ${Math.round(this.stickyDenyMs / 1000)}s): `
|
|
823
|
+
+ (result.allowed ? 'shadow, call allowed (would deny)' : 'call blocked (429 plan_limit_reached), not failed open')
|
|
824
|
+
+ ` X-Request-Id=${result.requestId || 'none'}`);
|
|
825
|
+
} else if (result.error) {
|
|
826
|
+
// One line per failure mode and error code per interval (an outage would
|
|
827
|
+
// otherwise log once per call), with the suppressed count.
|
|
828
|
+
const kind = `${result.rejected ? 'rejected' : result.failOpen ? 'fail_open' : 'fail_closed'}:${result.error.code}`;
|
|
829
|
+
const what = result.rejected ? 'rejected (call blocked in any failMode)'
|
|
830
|
+
: result.failOpen ? 'fail-open (call allowed)' : 'fail-closed (call blocked)';
|
|
831
|
+
// N4: with X-Request-Id, like the billing and rate-limit lines, so Support can match the server's log line.
|
|
832
|
+
this._rateLimitedWarn(kind, () => `${BRAND.logPrefix} ${what}: ${result.error.code}: ${result.error.message} X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
833
|
+
} else if (result.wouldDeny && result.mode === 'shadow') {
|
|
834
|
+
this.logger.warn(`${BRAND.logPrefix} shadow: would deny tool=${result.tool}${result.label ? ` label=${JSON.stringify(result.label)}` : ''} reason=${result.reason} remaining=${result.limit === null ? 'no cap' : `${result.remaining}/${result.limit}`} request_id=${result.requestId} (call allowed)`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
callHook(this.onDecision, result);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// At most one warning of each kind per billingWarningIntervalMs (default: once
|
|
841
|
+
// a minute), with a count of what was suppressed, so the logs say what's going
|
|
842
|
+
// on without flooding.
|
|
843
|
+
_rateLimitedWarn(kind, build) {
|
|
844
|
+
if (!this.logger) return;
|
|
845
|
+
const now = this._now();
|
|
846
|
+
const state = this._warned.get(kind);
|
|
847
|
+
if (state && now - state.at < this.billingWarningIntervalMs) {
|
|
848
|
+
state.suppressed += 1;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const suppressed = state ? state.suppressed : 0;
|
|
852
|
+
this._warned.set(kind, { at: now, suppressed: 0 });
|
|
853
|
+
this.logger.warn(build()
|
|
854
|
+
+ (suppressed ? ` (${suppressed} more since the last warning)` : '')
|
|
855
|
+
+ ` (next warning in ${Math.round(this.billingWarningIntervalMs / 1000)}s at most)`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
_reportBillingInactive(result) {
|
|
859
|
+
callHook(this.onBillingInactive, result);
|
|
860
|
+
if (result.reason === 'subscription_required') {
|
|
861
|
+
// H3: its own wording (never "subscription inactive"): the account has never paid.
|
|
862
|
+
this._rateLimitedWarn('billing', () => `${BRAND.logPrefix} subscription required: this ${BRAND.name} account has no paid subscription yet, so its live key gets 403 subscription_required.`
|
|
863
|
+
+ ` This is a billing state, not an outage. Calls with this live key are blocked in any failMode (test keys still work).`
|
|
864
|
+
+ ` Subscribe in the ${BRAND.name} console under Billing. X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// Terms 10.3(b): subscription_inactive follows the billing-lapse setting, not failMode.
|
|
868
|
+
const doing = result.allowed
|
|
869
|
+
? "Calls are being let through, unchecked, because billingLapse is 'open' (the default)"
|
|
870
|
+
: "Calls are being blocked (403 plan_check_rejected) because billingLapse is 'closed'";
|
|
871
|
+
// QA CC-L3 (Oct 6): UI/UX-approved wording, exactly; the "doing" clause (what the SDK did with the call) sits
|
|
872
|
+
// right after the first sentence. The "subscription inactive:" tag and the X-Request-Id/tool fields are log
|
|
873
|
+
// fields, not wording. Pinned in test/sdk.test.js (BILLING_INACTIVE_WARNING).
|
|
874
|
+
this._rateLimitedWarn('billing', () => `${BRAND.logPrefix} subscription inactive: ${BRAND.name} refused this live key because of a billing problem on your account.`
|
|
875
|
+
+ ` ${doing}.`
|
|
876
|
+
+ ` To fix this, sign in to the ${BRAND.name} console and open Billing. If Billing shows an unpaid invoice, pay it and these same live keys turn back on. If your subscription has ended, subscribe again under Billing, then issue new live keys under Keys.`
|
|
877
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
_reportRateLimited(result) {
|
|
881
|
+
callHook(this.onRateLimited, result);
|
|
882
|
+
const doing = 'Calls are being blocked (429 rate_limited) in any failMode';
|
|
883
|
+
if (result.reason === CONCURRENCY_CODE) {
|
|
884
|
+
this._rateLimitedWarn('concurrency', () => `${BRAND.logPrefix} concurrency limited: this agent key has too many plan checks in flight at once (${BRAND.name}'s per-key concurrency limit, 429 ${CONCURRENCY_CODE}).`
|
|
885
|
+
+ ` This is a rate limit, not an outage. ${doing}. Send fewer calls in parallel: retry after ${result.retryAfterSec}s.`
|
|
886
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this._rateLimitedWarn('rate_limit', () => `${BRAND.logPrefix} rate limited: this agent key is over ${BRAND.name}'s per-key request limit (429 rate_limited).`
|
|
890
|
+
+ ` This is a rate limit, not an outage. ${doing}. Slow down: retry after ${result.retryAfterSec}s.`
|
|
891
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// H3: the per-address limit (429 too_many_failed_auth). Never "per-key": it fires
|
|
895
|
+
// only for missing or unrecognized keys, so the fix is the key, not the traffic.
|
|
896
|
+
_reportFailedAuth(result) {
|
|
897
|
+
this._rateLimitedWarn('failed_auth', () => `${BRAND.logPrefix} rejected (call blocked in any failMode): too many requests with a missing or unrecognized agent key from this server's address`
|
|
898
|
+
+ ` (${BRAND.name}'s per-address limit, 429 ${result.error.code}). Check the agent key (apiKey / ${BRAND.envKey}); calls with a valid key are not affected by this limit.`
|
|
899
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
_reportTestLimit(result) {
|
|
903
|
+
callHook(this.onTestLimit, result);
|
|
904
|
+
if (result.allowed) {
|
|
905
|
+
// Shadow (1.2.0): the call ran; the server counted it and recorded a would-deny.
|
|
906
|
+
this._rateLimitedWarn('test_limit_shadow', () => `${BRAND.logPrefix} test limit reached (shadow): this ${BRAND.name} account has used its free test-key decisions for this month (UTC).`
|
|
907
|
+
+ ' Shadow calls keep running: each is counted and recorded as would deny test_limit_reached, up to a safety ceiling of 10 times the limit (then 429 safety_limit_reached, and calls still run).'
|
|
908
|
+
+ ` Enforce calls with test keys are blocked until ${result.resetsAt || 'the 1st of next month'}. Live keys are not affected.`
|
|
909
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
this._rateLimitedWarn('test_limit', () => `${BRAND.logPrefix} test limit reached: this ${BRAND.name} account has used its free test-key decisions for this month (UTC).`
|
|
913
|
+
+ ` Calls with test keys are being blocked (429 test_limit_reached) until ${result.resetsAt || 'the 1st of next month'}, in enforce mode and regardless of failMode (shadow calls still run).`
|
|
914
|
+
+ ` This is a sandbox limit, not an outage and not your customer's plan. Live keys are not affected.`
|
|
915
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// 1.2.0: 429 safety_limit_reached. The call ran (any failMode); one warning per warningIntervalMs.
|
|
919
|
+
_reportSafetyLimit(result) {
|
|
920
|
+
callHook(this.onSafetyLimit, result);
|
|
921
|
+
this._rateLimitedWarn('safety_limit', () => `${BRAND.logPrefix} safety limit reached: this ${BRAND.name} account passed the safety ceiling for shadow calls (10 times its monthly test limit; 429 ${SAFETY_LIMIT_CODE}).`
|
|
922
|
+
+ ` This is a safety limit, not a deny: calls are being let through, unchecked and not recorded, in any failMode, until ${result.resetsAt || 'the start of next month (UTC)'}.`
|
|
923
|
+
+ ` X-Request-Id=${result.requestId || 'none'} tool=${result.tool}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
_host() {
|
|
927
|
+
try {
|
|
928
|
+
return new URL(this.baseUrl).host;
|
|
929
|
+
} catch {
|
|
930
|
+
return this.baseUrl;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// The configured base URL's host doesn't resolve. Loud (error level, never throttled) the first time per host per
|
|
935
|
+
// instance: it is almost always a typo or a missing ORDAINER_BASE_URL, and failing open would otherwise hide it behind
|
|
936
|
+
// the once-a-minute fail-open warning. Later calls fall back to that normal (throttled) warning.
|
|
937
|
+
_reportDns(code, failMode) {
|
|
938
|
+
const host = this._host();
|
|
939
|
+
if (!this.logger || this._dnsReported.has(host)) return;
|
|
940
|
+
this._dnsReported.add(host);
|
|
941
|
+
const line = `${BRAND.logPrefix} ERROR: cannot resolve the ${BRAND.name} API host "${host}" (${code}) from baseUrl ${this.baseUrl}.`
|
|
942
|
+
+ ` Check the URL and set ${BRAND.envBaseUrl} (or the baseUrl option) to your ${BRAND.name} API URL.`
|
|
943
|
+
+ ` Until then every plan check fails: calls are ${failMode === 'closed' ? 'blocked (failMode \'closed\')' : 'let through unchecked (failMode \'open\')'}.`;
|
|
944
|
+
(typeof this.logger.error === 'function' ? this.logger.error : this.logger.warn).call(this.logger, line);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Express/Connect-style middleware (also works with plain http handlers that call next).
|
|
949
|
+
* Sets req.ordainer to the result. On a blocked call it answers 429/402/403/500/503 itself,
|
|
950
|
+
* or calls opts.onDeny(req, res, result) if given.
|
|
951
|
+
* opts.denyStatus (429 or 402) overrides the client's denyStatus for this route's quota denies.
|
|
952
|
+
*
|
|
953
|
+
* opts.tool, opts.apiKey, opts.idempotencyKey may be values or (req) => value (sync or async).
|
|
954
|
+
* Default idempotency key: a fresh random key per incoming request. The incoming
|
|
955
|
+
* request's Idempotency-Key header is NEVER forwarded: the agent controls it, and
|
|
956
|
+
* replaying one key would get one stored allow many times without counting (QA S1).
|
|
957
|
+
* Pass idempotencyKey(req) only from your own trusted data (e.g. your job id),
|
|
958
|
+
* one key per real operation. Keys over 255 characters are hashed (idempotencyKeyFor).
|
|
959
|
+
*/
|
|
960
|
+
middleware(opts = {}) {
|
|
961
|
+
checkMode(opts.mode, 'middleware');
|
|
962
|
+
checkFailMode(opts.failMode, 'middleware');
|
|
963
|
+
checkBillingLapse(opts.billingLapse, 'middleware');
|
|
964
|
+
checkDenyStatus(opts.denyStatus, 'middleware');
|
|
965
|
+
checkRetries(opts.retries, 'middleware');
|
|
966
|
+
if (!opts.tool) throw new TypeError('middleware: tool is required');
|
|
967
|
+
return (req, res, next) => {
|
|
968
|
+
(async () => {
|
|
969
|
+
const tool = await resolve(opts.tool, req);
|
|
970
|
+
const apiKey = await resolve(opts.apiKey ?? this.apiKey, req);
|
|
971
|
+
// Vendor-supplied only (trusted data). Never req.headers['idempotency-key'].
|
|
972
|
+
const idempotencyKey = await resolve(opts.idempotencyKey, req);
|
|
973
|
+
const result = await this.decide({ tool, apiKey, idempotencyKey, mode: opts.mode, failMode: opts.failMode, billingLapse: opts.billingLapse, retries: opts.retries });
|
|
974
|
+
if (opts.denyStatus) result.denyStatus = opts.denyStatus;
|
|
975
|
+
req.ordainer = result;
|
|
976
|
+
if (result.allowed) return next();
|
|
977
|
+
if (opts.onDeny) return opts.onDeny(req, res, result);
|
|
978
|
+
sendDeny(res, result);
|
|
979
|
+
})().catch(next);
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Wrap a plain async function. The wrapped function runs only if allowed;
|
|
985
|
+
* otherwise it throws OrdainerDeniedError (or returns opts.onDeny(result, ...args)).
|
|
986
|
+
* opts.tool / opts.apiKey / opts.idempotencyKey may be values or (...args) => value.
|
|
987
|
+
*/
|
|
988
|
+
wrap(fn, opts = {}) {
|
|
989
|
+
checkMode(opts.mode, 'wrap');
|
|
990
|
+
checkFailMode(opts.failMode, 'wrap');
|
|
991
|
+
checkBillingLapse(opts.billingLapse, 'wrap');
|
|
992
|
+
checkRetries(opts.retries, 'wrap');
|
|
993
|
+
if (!opts.tool) throw new TypeError('wrap: tool is required');
|
|
994
|
+
const self = this;
|
|
995
|
+
return async function ordainerWrapped(...args) {
|
|
996
|
+
const result = await self.decide({
|
|
997
|
+
tool: await resolve(opts.tool, ...args),
|
|
998
|
+
apiKey: await resolve(opts.apiKey ?? self.apiKey, ...args),
|
|
999
|
+
idempotencyKey: await resolve(opts.idempotencyKey, ...args),
|
|
1000
|
+
mode: opts.mode,
|
|
1001
|
+
failMode: opts.failMode,
|
|
1002
|
+
billingLapse: opts.billingLapse,
|
|
1003
|
+
retries: opts.retries,
|
|
1004
|
+
});
|
|
1005
|
+
if (result.allowed) return fn.apply(this, args);
|
|
1006
|
+
if (opts.onDeny) return opts.onDeny(result, ...args);
|
|
1007
|
+
throw new OrdainerDeniedError(result);
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
module.exports = { Ordainer, OrdainerDeniedError, denyResponse, safeKeyLabel, MAX_LABEL_LENGTH, rejectedMessage, sendDeny, idempotencyKeyFor, DEFAULT_TIMEOUT_MS, BILLING_WARNING_INTERVAL_MS, TEST_LIMIT_REASON, SAFETY_LIMIT_CODE, MAX_IDEMPOTENCY_LEN, MAX_RETRIES, BRAND, BILLING_BLOCKED_MESSAGE, RATE_LIMITED_MESSAGE, CONCURRENCY_LIMITED_MESSAGE };
|