@friggframework/core 2.0.0--canary.654.6d3a665.0 → 2.0.0--canary.656.10f1676.0

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/CLAUDE.md CHANGED
@@ -369,6 +369,48 @@ class MyIntegration extends IntegrationBase {
369
369
  - `ApiKeyRequester` - API key authentication
370
370
  - `BasicAuthRequester` - Basic authentication
371
371
 
372
+ **Rate limits** (ADR-049): an API module declares `static rateLimit` on its
373
+ `Requester` subclass. For a throttled response the Requester looks for a hint
374
+ in this order:
375
+
376
+ | Step | Source |
377
+ |---|---|
378
+ | 1 | `classify({ status, headers, body })` of the module. It recognises a limit that is not a plain 429 (a 403 limit code, a `policyName` in the body) and may name only a `reason` |
379
+ | 2 | The header parsers in `parsers` order. Default: `retryAfter`, `resetHeaders`, `ietf` |
380
+ | 3 | The static policy: `minRetryAfterMs`, or the `perMs` of the window named by the `reason` |
381
+ | 4 | The fixed `backOff` ladder (1, 3, 10, 30, 60, 180 s) |
382
+
383
+ - A 429 with no hint from steps 1 to 3 keeps today's ladder: the same calls,
384
+ the same delays, then a plain `FetchError`. Nothing budgets it.
385
+ - With a hint, the wait is `max(hint, minRetryAfterMs, 1 s)` plus at most 10 %
386
+ jitter. The total sleep of one request is capped at `maxInProcessWaitMs`
387
+ (default 5 minutes) and at the time left in the invocation less one request
388
+ timeout (`remainingInvocationMs()`).
389
+ - A wait that does not fit throws `RateLimitError` (`isRateLimited`, `retryAt`,
390
+ `waitMs`, `reason`, `policy`, `source`, `module`, `scopeKey`). The queue
391
+ worker does not halt it, whatever its status.
392
+ - `Retry-After` is read on a 429 only. Another status counts only when
393
+ `classify` recognises it. A 5xx keeps the 5xx ladder.
394
+ - `maxInProcessWaitMs: 0` turns off sleeping: every hinted wait throws.
395
+ - A module that does not use the Requester (for example one on jsforce) calls
396
+ `classifyRateLimit(policy, { status, headers, body })` around its own client
397
+ and throws `RateLimitError` itself.
398
+
399
+ ```javascript
400
+ class Api extends OAuth2Requester {
401
+ static rateLimit = {
402
+ scope: 'entity',
403
+ minRetryAfterMs: 1_000,
404
+ classify({ status, body }) {
405
+ if (status === 403 && body?.code === 'DAILY_LIMIT') {
406
+ return { reason: 'daily', waitMs: 60 * 60_000 };
407
+ }
408
+ return null;
409
+ },
410
+ };
411
+ }
412
+ ```
413
+
372
414
  **Module Factory**:
373
415
  - `ModuleFactory` - Creates and configures API module instances
374
416
  - Handles credential injection
@@ -477,7 +519,8 @@ handlers (`{ req, res, next }`) and `this.on` events do not.
477
519
  `isLastAttempt` is `false` when either count is unknown: a local or non-SQS
478
520
  invocation, or a queue whose redrive policy the stack does not own
479
521
  (`ownership.queue: 'external'`). The value is information only: core still
480
- rethrows retryable errors and discards halt errors (4xx except 408/429).
522
+ rethrows retryable errors and discards halt errors (4xx except 408, 429 and
523
+ errors with `isRateLimited`).
481
524
 
482
525
  Use it to end a run or count lost work on the final try: when a retryable
483
526
  error (429, 5xx, network) is about to be rethrown and `isLastAttempt` is
@@ -507,6 +550,7 @@ it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
507
550
  **Error Types**:
508
551
  - `BaseError` - Base error class
509
552
  - `FetchError` - HTTP request failures
553
+ - `RateLimitError` - A `FetchError` for a limit the provider or the module's policy says when to retry (`retryAt`)
510
554
  - `HaltError` - Stop processing without retry
511
555
  - `RequiredPropertyError` - Missing required parameters
512
556
  - `ParameterTypeError` - Invalid parameter type
package/core/CLAUDE.md CHANGED
@@ -130,7 +130,7 @@ class MyIntegration extends Delegate {
130
130
  ### Lambda Handler Lifecycle
131
131
  1. **Pre-Execution Setup**:
132
132
  ```javascript
133
- runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope for the invocation
133
+ runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope + invocation deadline (remainingInvocationMs())
134
134
  log.info('Handler invoked', { eventName: 'frigg.handler.invoked' });
135
135
  await secretsToEnv(); // Secrets Manager injection
136
136
  await parametersToEnv(); // SSM Parameter Store fetch (only when SSM_PARAMETER_PREFIX + FRIGG_SSM_OFFLOADED_KEYS are set)
@@ -226,6 +226,8 @@ const handler = createHandler({
226
226
  - Logs error but returns success
227
227
  - Prevents infinite retries for known issues
228
228
  - Used for graceful degradation scenarios
229
+ - The queue worker sets it on a 4xx except 408, 429 and errors with
230
+ `isRateLimited` (a provider limit signalled with 403 clears with time)
229
231
 
230
232
  ### Logging Strategy
231
233
  ```javascript
package/core/index.js CHANGED
@@ -3,6 +3,10 @@ const { Worker } = require('./Worker');
3
3
  const { loadInstalledModules } = require('./load-installed-modules');
4
4
  const { createHandler } = require('./create-handler');
5
5
  const { runInvocationScope } = require('./invocation-scope');
6
+ const {
7
+ runWithInvocationDeadline,
8
+ remainingInvocationMs,
9
+ } = require('./invocation-deadline');
6
10
 
7
11
  module.exports = {
8
12
  Delegate,
@@ -10,4 +14,6 @@ module.exports = {
10
14
  loadInstalledModules,
11
15
  createHandler,
12
16
  runInvocationScope,
17
+ runWithInvocationDeadline,
18
+ remainingInvocationMs,
13
19
  };
@@ -0,0 +1,56 @@
1
+ const { AsyncLocalStorage } = require('node:async_hooks');
2
+
3
+ const STORE_KEY = Symbol.for('@friggframework/core.invocation-deadline');
4
+
5
+ function storage() {
6
+ if (!globalThis[STORE_KEY]) {
7
+ globalThis[STORE_KEY] = new AsyncLocalStorage();
8
+ }
9
+ return globalThis[STORE_KEY];
10
+ }
11
+
12
+ /**
13
+ * Runs fn with the time the invocation ends, as epoch milliseconds. A nested
14
+ * scope can shorten the deadline and never extend it. A deadline that is not
15
+ * a finite number runs fn with no limit.
16
+ */
17
+ function runWithInvocationDeadline(deadlineAt, fn) {
18
+ if (typeof deadlineAt !== 'number' || !Number.isFinite(deadlineAt)) {
19
+ return fn();
20
+ }
21
+ const outer = storage().getStore();
22
+ const effective = outer
23
+ ? Math.min(outer.deadlineAt, deadlineAt)
24
+ : deadlineAt;
25
+ return storage().run(Object.freeze({ deadlineAt: effective }), fn);
26
+ }
27
+
28
+ /**
29
+ * The end of a Lambda invocation, from its context. Undefined when the
30
+ * context cannot tell.
31
+ */
32
+ function deadlineFromContext(context) {
33
+ try {
34
+ const remaining = context?.getRemainingTimeInMillis?.();
35
+ return typeof remaining === 'number' && Number.isFinite(remaining)
36
+ ? Date.now() + remaining
37
+ : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Milliseconds left in the invocation. Infinity outside a deadline scope
45
+ * (tests, `frigg start`, scripts).
46
+ */
47
+ function remainingInvocationMs(now = Date.now()) {
48
+ const store = storage().getStore();
49
+ return store ? Math.max(0, store.deadlineAt - now) : Infinity;
50
+ }
51
+
52
+ module.exports = {
53
+ deadlineFromContext,
54
+ remainingInvocationMs,
55
+ runWithInvocationDeadline,
56
+ };
@@ -1,6 +1,10 @@
1
1
  const { runInContext, LOGGER_SCOPE_KEY } = require('../logs/context');
2
2
  const { summarizeMessageBody } = require('../logs/summarize-event');
3
3
  const { flushSinks, hasFlushableSinks } = require('../logs/logger-runtime');
4
+ const {
5
+ deadlineFromContext,
6
+ runWithInvocationDeadline,
7
+ } = require('./invocation-deadline');
4
8
 
5
9
  // Bounds the tail latency telemetry adds to every warm invocation. Kept low so
6
10
  // an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
@@ -168,21 +172,26 @@ async function runInvocationScope(fields, fn, opts = {}) {
168
172
  flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
169
173
  context,
170
174
  } = opts;
171
- return runInContext({ [LOGGER_SCOPE_KEY]: { ...(fields || {}) } }, async () => {
172
- try {
173
- return await fn();
174
- } finally {
175
- // Every step is guarded, so a flush never changes the result.
176
- await flushInvocation({
177
- telemetry,
178
- usageRollup,
179
- eventSummary,
180
- shouldUseDatabase,
181
- flushTimeoutMs,
182
- context,
183
- });
184
- }
185
- });
175
+ return runWithInvocationDeadline(deadlineFromContext(context), () =>
176
+ runInContext(
177
+ { [LOGGER_SCOPE_KEY]: { ...(fields || {}) } },
178
+ async () => {
179
+ try {
180
+ return await fn();
181
+ } finally {
182
+ // Every step is guarded, so a flush never changes the result.
183
+ await flushInvocation({
184
+ telemetry,
185
+ usageRollup,
186
+ eventSummary,
187
+ shouldUseDatabase,
188
+ flushTimeoutMs,
189
+ context,
190
+ });
191
+ }
192
+ }
193
+ )
194
+ );
186
195
  }
187
196
 
188
197
  function toCount(value) {
@@ -55,12 +55,18 @@ class FetchError extends BaseError {
55
55
 
56
56
  static async create(options = {}) {
57
57
  const { response } = options;
58
- let responseBody =
59
- response && !response.bodyUsed && typeof response.text === 'function'
60
- ? await response.text()
61
- : null;
62
- if (!responseBody) responseBody = options.responseBody ?? options.body;
63
- return new FetchError({ ...options, responseBody });
58
+ const provided = options.responseBody ?? options.body;
59
+ let responseBody = provided;
60
+ if (
61
+ !responseBody &&
62
+ response &&
63
+ !response.bodyUsed &&
64
+ typeof response.text === 'function'
65
+ ) {
66
+ responseBody = (await response.text()) || provided;
67
+ }
68
+ const ErrorClass = typeof this === 'function' ? this : FetchError;
69
+ return new ErrorClass({ ...options, responseBody });
64
70
  }
65
71
  }
66
72
 
package/errors/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { BaseError } = require('./base-error');
2
2
  const { FetchError } = require('./fetch-error');
3
3
  const { HaltError } = require('./halt-error');
4
+ const { RateLimitError } = require('./rate-limit-error');
4
5
  const {
5
6
  RequiredPropertyError,
6
7
  ParameterTypeError,
@@ -11,6 +12,7 @@ module.exports = {
11
12
  BaseError,
12
13
  FetchError,
13
14
  HaltError,
15
+ RateLimitError,
14
16
  RequiredPropertyError,
15
17
  ParameterTypeError,
16
18
  ClientSafeError,
@@ -0,0 +1,44 @@
1
+ const { FetchError } = require('./fetch-error');
2
+
3
+ function resolveTiming({ hint, waitMs, now }) {
4
+ if (waitMs !== undefined) {
5
+ return { waitMs, retryAt: new Date(now + waitMs) };
6
+ }
7
+ if (hint?.retryAt) {
8
+ const retryAt = new Date(hint.retryAt);
9
+ if (!Number.isNaN(retryAt.getTime())) {
10
+ return { waitMs: Math.max(0, retryAt.getTime() - now), retryAt };
11
+ }
12
+ }
13
+ const fallbackWaitMs = hint?.waitMs ?? 0;
14
+ return { waitMs: fallbackWaitMs, retryAt: new Date(now + fallbackWaitMs) };
15
+ }
16
+
17
+ /**
18
+ * A FetchError for a response that said a limit was hit and told us, or the
19
+ * module's policy told us, when to call again. `retryAt` is that time.
20
+ */
21
+ class RateLimitError extends FetchError {
22
+ constructor({
23
+ hint,
24
+ waitMs,
25
+ module,
26
+ scopeKey,
27
+ now = Date.now(),
28
+ ...fetchErrorArgs
29
+ } = {}) {
30
+ super(fetchErrorArgs);
31
+
32
+ const timing = resolveTiming({ hint, waitMs, now });
33
+ this.isRateLimited = true;
34
+ this.waitMs = timing.waitMs;
35
+ this.retryAt = timing.retryAt;
36
+ this.reason = hint?.reason ?? 'unknown';
37
+ this.policy = hint?.policy;
38
+ this.source = hint?.source ?? 'unknown';
39
+ this.module = module;
40
+ this.scopeKey = scopeKey;
41
+ }
42
+ }
43
+
44
+ module.exports = { RateLimitError };
@@ -285,7 +285,8 @@ const createQueueWorker = (integrationClass) => {
285
285
  status >= 400 &&
286
286
  status < 500 &&
287
287
  status !== 408 &&
288
- status !== 429
288
+ status !== 429 &&
289
+ !error.isRateLimited
289
290
  ) {
290
291
  error.isHaltError = true;
291
292
  console.warn(
package/index.js CHANGED
@@ -12,6 +12,8 @@ const {
12
12
  loadInstalledModules,
13
13
  createHandler,
14
14
  runInvocationScope,
15
+ runWithInvocationDeadline,
16
+ remainingInvocationMs,
15
17
  } = require('./core/index');
16
18
  const {
17
19
  prisma,
@@ -55,6 +57,7 @@ const {
55
57
  BaseError,
56
58
  FetchError,
57
59
  HaltError,
60
+ RateLimitError,
58
61
  RequiredPropertyError,
59
62
  ParameterTypeError,
60
63
  } = require('./errors/index');
@@ -100,6 +103,12 @@ const {
100
103
  ModuleConstants,
101
104
  ModuleFactory,
102
105
  } = require('./modules/index');
106
+ const {
107
+ classifyRateLimit,
108
+ parseRetryAfter,
109
+ parseResetHeaders,
110
+ parseIetfRateLimit,
111
+ } = require('./modules/requester/rate-limit');
103
112
  const application = require('./application');
104
113
  const utils = require('./utils');
105
114
 
@@ -120,6 +129,8 @@ module.exports = {
120
129
  loadInstalledModules,
121
130
  createHandler,
122
131
  runInvocationScope,
132
+ runWithInvocationDeadline,
133
+ remainingInvocationMs,
123
134
 
124
135
  // database
125
136
  prisma,
@@ -142,9 +153,16 @@ module.exports = {
142
153
  BaseError,
143
154
  FetchError,
144
155
  HaltError,
156
+ RateLimitError,
145
157
  RequiredPropertyError,
146
158
  ParameterTypeError,
147
159
 
160
+ // rate limit
161
+ classifyRateLimit,
162
+ parseRetryAfter,
163
+ parseResetHeaders,
164
+ parseIetfRateLimit,
165
+
148
166
  // integrations
149
167
  IntegrationBase,
150
168
  Options,
@@ -0,0 +1,4 @@
1
+ const parsers = require('./parsers');
2
+ const policy = require('./policy');
3
+
4
+ module.exports = { ...parsers, ...policy };
@@ -0,0 +1,268 @@
1
+ const MAX_HINT_WAIT_MS = 366 * 24 * 60 * 60 * 1000;
2
+
3
+ const SECONDS_PATTERN = /^\d+(?:\.\d+)?$/;
4
+ const HAS_LETTER = /[A-Za-z]/;
5
+ const EPOCH_SECONDS_MIN = 1e9;
6
+ const EPOCH_MILLISECONDS_MIN = 1e12;
7
+
8
+ const RESET_HEADERS = [
9
+ 'x-ratelimit-reset',
10
+ 'ratelimit-reset',
11
+ 'x-rate-limit-reset',
12
+ ];
13
+ const REMAINING_HEADERS = [
14
+ 'x-ratelimit-remaining',
15
+ 'ratelimit-remaining',
16
+ 'x-rate-limit-remaining',
17
+ ];
18
+
19
+ const isMissing = (value) => value === undefined || value === null;
20
+
21
+ function readEntries(headers, wanted) {
22
+ for (const entry of headers) {
23
+ if (Array.isArray(entry) && String(entry[0]).toLowerCase() === wanted) {
24
+ return entry[1];
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ function readKeys(headers, wanted) {
31
+ for (const key of Object.keys(headers)) {
32
+ if (key.toLowerCase() === wanted) return headers[key];
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * Reads one header from a Headers-like object, a Map, an entries array or a
39
+ * plain object. The name match is case-insensitive.
40
+ * @returns {string|undefined}
41
+ */
42
+ function headerValue(headers, name) {
43
+ if (!headers || typeof headers !== 'object') return undefined;
44
+ const wanted = name.toLowerCase();
45
+ const hasGet = typeof headers.get === 'function';
46
+
47
+ let value;
48
+ if (hasGet) {
49
+ value = headers.get(name);
50
+ if (isMissing(value)) value = headers.get(wanted);
51
+ }
52
+ if (isMissing(value) && typeof headers[Symbol.iterator] === 'function') {
53
+ value = readEntries(headers, wanted);
54
+ }
55
+ if (isMissing(value) && !hasGet && !Array.isArray(headers)) {
56
+ value = readKeys(headers, wanted);
57
+ }
58
+ if (Array.isArray(value)) value = value[0];
59
+ if (isMissing(value)) return undefined;
60
+
61
+ const text = String(value).trim();
62
+ return text === '' ? undefined : text;
63
+ }
64
+
65
+ function definedOnly(fields) {
66
+ const result = {};
67
+ for (const [key, value] of Object.entries(fields)) {
68
+ if (value !== undefined) result[key] = value;
69
+ }
70
+ return result;
71
+ }
72
+
73
+ /**
74
+ * Builds a hint from a wait in ms. Returns null for a wait that is not a
75
+ * finite number in [0, MAX_HINT_WAIT_MS].
76
+ */
77
+ function hintFromWait(waitMs, now, { source = 'header', ...extra } = {}) {
78
+ if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > MAX_HINT_WAIT_MS) {
79
+ return null;
80
+ }
81
+ return {
82
+ waitMs,
83
+ retryAt: new Date(now + waitMs),
84
+ ...definedOnly(extra),
85
+ source,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Builds a hint from an absolute time. A time in the past waits 0 ms.
91
+ */
92
+ function hintFromRetryAt(retryAt, now, { source = 'header', ...extra } = {}) {
93
+ const at = retryAt instanceof Date ? retryAt.getTime() : NaN;
94
+ if (Number.isNaN(at)) return null;
95
+ const waitMs = Math.max(0, at - now);
96
+ if (waitMs > MAX_HINT_WAIT_MS) return null;
97
+ return { waitMs, retryAt: new Date(at), ...definedOnly(extra), source };
98
+ }
99
+
100
+ function parseDate(text) {
101
+ if (!HAS_LETTER.test(text)) return null;
102
+ const at = Date.parse(text);
103
+ return Number.isNaN(at) ? null : new Date(at);
104
+ }
105
+
106
+ /**
107
+ * Retry-After: delta seconds, an HTTP-date or an ISO timestamp.
108
+ */
109
+ function parseRetryAfter(value, { now = Date.now() } = {}) {
110
+ if (isMissing(value)) return null;
111
+ const text = String(value).trim();
112
+ if (text === '') return null;
113
+ if (SECONDS_PATTERN.test(text))
114
+ return hintFromWait(Number(text) * 1000, now);
115
+ const date = parseDate(text);
116
+ return date ? hintFromRetryAt(date, now) : null;
117
+ }
118
+
119
+ function numberOrUndefined(text) {
120
+ if (text === undefined) return undefined;
121
+ const number = Number(text);
122
+ return Number.isFinite(number) ? number : undefined;
123
+ }
124
+
125
+ function firstHeader(headers, names) {
126
+ for (const name of names) {
127
+ const value = headerValue(headers, name);
128
+ if (value !== undefined) return value;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ function parseResetValue(text, now, extra) {
134
+ if (SECONDS_PATTERN.test(text)) {
135
+ const number = Number(text);
136
+ if (number >= EPOCH_MILLISECONDS_MIN) {
137
+ return hintFromRetryAt(new Date(number), now, extra);
138
+ }
139
+ if (number >= EPOCH_SECONDS_MIN) {
140
+ return hintFromRetryAt(new Date(number * 1000), now, extra);
141
+ }
142
+ return hintFromWait(number * 1000, now, extra);
143
+ }
144
+ const date = parseDate(text);
145
+ return date ? hintFromRetryAt(date, now, extra) : null;
146
+ }
147
+
148
+ /**
149
+ * X-RateLimit-Reset and the other reset headers. The magnitude tells the
150
+ * unit: epoch milliseconds, epoch seconds, or delta seconds.
151
+ */
152
+ function parseResetHeaders(headers, { now = Date.now() } = {}) {
153
+ const remaining = numberOrUndefined(
154
+ firstHeader(headers, REMAINING_HEADERS)
155
+ );
156
+ const extra = { remaining };
157
+
158
+ const after = headerValue(headers, 'x-ratelimit-reset-after');
159
+ if (after !== undefined && SECONDS_PATTERN.test(after)) {
160
+ return hintFromWait(Number(after) * 1000, now, extra);
161
+ }
162
+
163
+ for (const name of RESET_HEADERS) {
164
+ const value = headerValue(headers, name);
165
+ if (value === undefined) continue;
166
+ const hint = parseResetValue(value, now, extra);
167
+ if (hint) return hint;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function splitList(text) {
173
+ const items = [];
174
+ let current = '';
175
+ let quoted = false;
176
+ for (const char of text) {
177
+ if (char === '"') quoted = !quoted;
178
+ if (char === ',' && !quoted) {
179
+ items.push(current);
180
+ current = '';
181
+ } else {
182
+ current += char;
183
+ }
184
+ }
185
+ items.push(current);
186
+ return items.map((item) => item.trim()).filter(Boolean);
187
+ }
188
+
189
+ function parseStructuredItem(item) {
190
+ const [head, ...params] = item.split(';').map((part) => part.trim());
191
+ const values = {};
192
+ for (const param of params) {
193
+ const [key, raw] = param.split('=').map((part) => part.trim());
194
+ if (key) values[key] = numberOrUndefined(raw);
195
+ }
196
+ const name = head.replace(/^"|"$/g, '');
197
+ return {
198
+ name: name || undefined,
199
+ remaining: values.r,
200
+ waitSeconds: values.t,
201
+ };
202
+ }
203
+
204
+ function pickStructured(items) {
205
+ const usable = items.filter(
206
+ (item) => Number.isFinite(item.waitSeconds) && item.waitSeconds >= 0
207
+ );
208
+ usable.sort((a, b) => {
209
+ const byRemaining =
210
+ (a.remaining ?? Infinity) - (b.remaining ?? Infinity);
211
+ if (byRemaining !== 0 && !Number.isNaN(byRemaining)) return byRemaining;
212
+ return b.waitSeconds - a.waitSeconds;
213
+ });
214
+ return usable[0];
215
+ }
216
+
217
+ function readKeyValue(text, key) {
218
+ const match = new RegExp(
219
+ `(?:^|[,;\\s])${key}\\s*=\\s*([^,;\\s]+)`,
220
+ 'i'
221
+ ).exec(text);
222
+ return match ? match[1] : undefined;
223
+ }
224
+
225
+ /**
226
+ * The IETF RateLimit field: the key=value form (limit=, remaining=, reset=)
227
+ * and the structured-field form ("name";r=0;t=12). The list form picks the
228
+ * policy with the fewest remaining calls, then the longest wait.
229
+ */
230
+ function parseIetfRateLimit(headers, { now = Date.now() } = {}) {
231
+ const raw = headerValue(headers, 'ratelimit');
232
+ if (raw === undefined) return null;
233
+ const policyHeader = headerValue(headers, 'ratelimit-policy');
234
+
235
+ if (/;\s*t\s*=/.test(raw)) {
236
+ const chosen = pickStructured(splitList(raw).map(parseStructuredItem));
237
+ if (!chosen) return null;
238
+ return hintFromWait(chosen.waitSeconds * 1000, now, {
239
+ remaining: chosen.remaining,
240
+ policy: chosen.name ?? policyHeader,
241
+ });
242
+ }
243
+
244
+ const reset = readKeyValue(raw, 'reset');
245
+ if (reset === undefined || !SECONDS_PATTERN.test(reset)) return null;
246
+ return hintFromWait(Number(reset) * 1000, now, {
247
+ remaining: numberOrUndefined(readKeyValue(raw, 'remaining')),
248
+ policy: policyHeader,
249
+ });
250
+ }
251
+
252
+ const BUILT_IN_PARSERS = {
253
+ retryAfter: (headers, { now } = {}) =>
254
+ parseRetryAfter(headerValue(headers, 'retry-after'), { now }),
255
+ resetHeaders: parseResetHeaders,
256
+ ietf: parseIetfRateLimit,
257
+ };
258
+
259
+ module.exports = {
260
+ BUILT_IN_PARSERS,
261
+ MAX_HINT_WAIT_MS,
262
+ headerValue,
263
+ hintFromRetryAt,
264
+ hintFromWait,
265
+ parseIetfRateLimit,
266
+ parseResetHeaders,
267
+ parseRetryAfter,
268
+ };