@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 +45 -1
- package/core/CLAUDE.md +3 -1
- package/core/index.js +6 -0
- package/core/invocation-deadline.js +56 -0
- package/core/invocation-scope.js +24 -15
- package/errors/fetch-error.js +12 -6
- package/errors/index.js +2 -0
- package/errors/rate-limit-error.js +44 -0
- package/handlers/backend-utils.js +2 -1
- package/index.js +18 -0
- package/modules/requester/rate-limit/index.js +4 -0
- package/modules/requester/rate-limit/parsers.js +268 -0
- package/modules/requester/rate-limit/policy.js +323 -0
- package/modules/requester/requester.js +174 -6
- package/package.json +5 -5
- package/types/core/index.d.ts +42 -0
- package/types/errors/index.d.ts +56 -0
- package/types/module-plugin/index.d.ts +52 -0
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
|
|
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
|
|
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
|
+
};
|
package/core/invocation-scope.js
CHANGED
|
@@ -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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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) {
|
package/errors/fetch-error.js
CHANGED
|
@@ -55,12 +55,18 @@ class FetchError extends BaseError {
|
|
|
55
55
|
|
|
56
56
|
static async create(options = {}) {
|
|
57
57
|
const { response } = options;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 };
|
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,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
|
+
};
|