@friggframework/core 2.0.0--canary.658.93c8e07.0 → 2.0.0--canary.656.4fb07b4.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 +5 -90
- package/core/invocation-deadline.js +10 -19
- package/core/invocation-scope.js +1 -9
- package/errors/fetch-error.js +7 -9
- package/errors/rate-limit-error.js +0 -4
- package/integrations/integration-base.js +1 -37
- package/integrations/repositories/integration-repository-documentdb.js +6 -25
- package/integrations/repositories/integration-repository-interface.js +5 -22
- package/integrations/repositories/integration-repository-mongo.js +12 -32
- package/integrations/repositories/integration-repository-postgres.js +12 -33
- package/integrations/tests/doubles/test-integration-repository.js +2 -15
- package/integrations/use-cases/update-integration-messages.js +6 -8
- package/logs/serialize.js +15 -0
- package/modules/module.js +0 -30
- package/modules/requester/rate-limit/parsers.js +1 -24
- package/modules/requester/rate-limit/policy.js +21 -52
- package/modules/requester/requester.js +26 -88
- package/package.json +5 -5
- package/types/core/index.d.ts +0 -9
- package/types/errors/index.d.ts +0 -15
- package/types/integrations/index.d.ts +4 -25
- package/types/module-plugin/index.d.ts +2 -20
- package/integrations/repositories/message-item-shared.js +0 -24
- package/integrations/use-cases/record-rate-limit-message.js +0 -85
package/modules/module.js
CHANGED
|
@@ -51,8 +51,6 @@ class Module extends Delegate {
|
|
|
51
51
|
this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
|
|
52
52
|
this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
|
|
53
53
|
this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
|
|
54
|
-
this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
|
|
55
|
-
this.delegateTypes.push(this.DLGT_RATE_LIMITED);
|
|
56
54
|
|
|
57
55
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
58
56
|
|
|
@@ -162,8 +160,6 @@ class Module extends Delegate {
|
|
|
162
160
|
await this.deauthorize();
|
|
163
161
|
} else if (delegateString === this.api.DLGT_INVALID_AUTH) {
|
|
164
162
|
await this.markCredentialsInvalid(object);
|
|
165
|
-
} else if (delegateString === this.api.DLGT_RATE_LIMITED) {
|
|
166
|
-
await this.reportRateLimit(object);
|
|
167
163
|
} else if (delegateString === this.api.DLGT_CREDENTIAL_RELOAD) {
|
|
168
164
|
return this.reloadCredential();
|
|
169
165
|
}
|
|
@@ -238,32 +234,6 @@ class Module extends Delegate {
|
|
|
238
234
|
}
|
|
239
235
|
}
|
|
240
236
|
|
|
241
|
-
/**
|
|
242
|
-
* Passes a rate limit the api reported to the parent delegate, best
|
|
243
|
-
* effort. The payload never holds the error's message, url, body or
|
|
244
|
-
* headers, because the parent shows it to end users.
|
|
245
|
-
* @param {import('../errors').RateLimitError} rateLimitError
|
|
246
|
-
*/
|
|
247
|
-
async reportRateLimit(rateLimitError) {
|
|
248
|
-
const { reason, retryAt, policy, statusCode } = rateLimitError;
|
|
249
|
-
const links = this.apiClass.rateLimit?.userHints?.[reason]?.links ?? [];
|
|
250
|
-
try {
|
|
251
|
-
await this.notify(this.DLGT_RATE_LIMITED, {
|
|
252
|
-
moduleName: this.name,
|
|
253
|
-
reason,
|
|
254
|
-
retryAt,
|
|
255
|
-
policy,
|
|
256
|
-
statusCode,
|
|
257
|
-
links,
|
|
258
|
-
});
|
|
259
|
-
} catch (err) {
|
|
260
|
-
this.logger.error('Failed to propagate RATE_LIMITED', {
|
|
261
|
-
eventName: `${this.logger.name}.rate_limited_propagation_failed`,
|
|
262
|
-
error: err,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
237
|
async deauthorize() {
|
|
268
238
|
//todo: Check if this is correct, we're instantiating a new api without params (credentials, tokens, etc...)
|
|
269
239
|
this.api = new this.apiClass();
|
|
@@ -34,11 +34,6 @@ function readKeys(headers, wanted) {
|
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
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
37
|
function headerValue(headers, name) {
|
|
43
38
|
if (!headers || typeof headers !== 'object') return undefined;
|
|
44
39
|
const wanted = name.toLowerCase();
|
|
@@ -70,10 +65,6 @@ function definedOnly(fields) {
|
|
|
70
65
|
return result;
|
|
71
66
|
}
|
|
72
67
|
|
|
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
68
|
function hintFromWait(waitMs, now, { source = 'header', ...extra } = {}) {
|
|
78
69
|
if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > MAX_HINT_WAIT_MS) {
|
|
79
70
|
return null;
|
|
@@ -86,9 +77,6 @@ function hintFromWait(waitMs, now, { source = 'header', ...extra } = {}) {
|
|
|
86
77
|
};
|
|
87
78
|
}
|
|
88
79
|
|
|
89
|
-
/**
|
|
90
|
-
* Builds a hint from an absolute time. A time in the past waits 0 ms.
|
|
91
|
-
*/
|
|
92
80
|
function hintFromRetryAt(retryAt, now, { source = 'header', ...extra } = {}) {
|
|
93
81
|
const at = retryAt instanceof Date ? retryAt.getTime() : Number.NaN;
|
|
94
82
|
if (Number.isNaN(at)) return null;
|
|
@@ -103,9 +91,6 @@ function parseDate(text) {
|
|
|
103
91
|
return Number.isNaN(at) ? null : new Date(at);
|
|
104
92
|
}
|
|
105
93
|
|
|
106
|
-
/**
|
|
107
|
-
* Retry-After: delta seconds, an HTTP-date or an ISO timestamp.
|
|
108
|
-
*/
|
|
109
94
|
function parseRetryAfter(value, { now = Date.now() } = {}) {
|
|
110
95
|
if (isMissing(value)) return null;
|
|
111
96
|
const text = String(value).trim();
|
|
@@ -145,10 +130,6 @@ function parseResetValue(text, now, extra) {
|
|
|
145
130
|
return date ? hintFromRetryAt(date, now, extra) : null;
|
|
146
131
|
}
|
|
147
132
|
|
|
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
133
|
function parseResetHeaders(headers, { now = Date.now() } = {}) {
|
|
153
134
|
const remaining = numberOrUndefined(
|
|
154
135
|
firstHeader(headers, REMAINING_HEADERS)
|
|
@@ -222,11 +203,6 @@ function readKeyValue(text, key) {
|
|
|
222
203
|
return match ? match[1] : undefined;
|
|
223
204
|
}
|
|
224
205
|
|
|
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
206
|
function parseIetfRateLimit(headers, { now = Date.now() } = {}) {
|
|
231
207
|
const raw = headerValue(headers, 'ratelimit');
|
|
232
208
|
if (raw === undefined) return null;
|
|
@@ -259,6 +235,7 @@ const BUILT_IN_PARSERS = {
|
|
|
259
235
|
module.exports = {
|
|
260
236
|
BUILT_IN_PARSERS,
|
|
261
237
|
MAX_HINT_WAIT_MS,
|
|
238
|
+
definedOnly,
|
|
262
239
|
headerValue,
|
|
263
240
|
hintFromRetryAt,
|
|
264
241
|
hintFromWait,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const {
|
|
2
2
|
BUILT_IN_PARSERS,
|
|
3
|
+
definedOnly,
|
|
3
4
|
hintFromRetryAt,
|
|
4
5
|
hintFromWait,
|
|
5
6
|
} = require('./parsers');
|
|
@@ -24,6 +25,7 @@ const normalizedPolicies = new WeakSet();
|
|
|
24
25
|
const policiesByClass = new WeakMap();
|
|
25
26
|
|
|
26
27
|
const isObject = (value) => value !== null && typeof value === 'object';
|
|
28
|
+
const isThenable = (value) => typeof value?.then === 'function';
|
|
27
29
|
|
|
28
30
|
function isPlainObject(value) {
|
|
29
31
|
if (!isObject(value) || Array.isArray(value)) return false;
|
|
@@ -31,14 +33,6 @@ function isPlainObject(value) {
|
|
|
31
33
|
return prototype === Object.prototype || prototype === null;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
function definedOnly(fields) {
|
|
35
|
-
const result = {};
|
|
36
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
37
|
-
if (value !== undefined) result[key] = value;
|
|
38
|
-
}
|
|
39
|
-
return result;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
36
|
function readParsers(raw, owner) {
|
|
43
37
|
if (!Array.isArray(raw.parsers)) return [...DEFAULT_PARSERS];
|
|
44
38
|
for (const name of raw.parsers) {
|
|
@@ -79,11 +73,6 @@ function normalizePolicy(raw, owner) {
|
|
|
79
73
|
return policy;
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
/**
|
|
83
|
-
* Reads the static `rateLimit` policy of an API module class. Returns
|
|
84
|
-
* undefined when the class declares none. The result is memoized per class.
|
|
85
|
-
* Throws a TypeError for a parser name that is not built in.
|
|
86
|
-
*/
|
|
87
76
|
function readRateLimitPolicy(ctor) {
|
|
88
77
|
if (typeof ctor !== 'function') return undefined;
|
|
89
78
|
if (policiesByClass.has(ctor)) return policiesByClass.get(ctor);
|
|
@@ -98,10 +87,6 @@ function asPolicy(value) {
|
|
|
98
87
|
return normalizePolicy(value, 'policy');
|
|
99
88
|
}
|
|
100
89
|
|
|
101
|
-
/**
|
|
102
|
-
* The key a limit counts against, for one requester. Undefined when the
|
|
103
|
-
* scope needs an id that the requester does not have.
|
|
104
|
-
*/
|
|
105
90
|
function computeScopeKey(policy, requester) {
|
|
106
91
|
const label = requester?._telemetryModuleLabel?.() ?? 'unknown';
|
|
107
92
|
const scope = policy?.scope ?? 'entity';
|
|
@@ -136,8 +121,18 @@ function computeScopeKey(policy, requester) {
|
|
|
136
121
|
return undefined;
|
|
137
122
|
}
|
|
138
123
|
|
|
124
|
+
function toDate(value) {
|
|
125
|
+
if (value === undefined || value === null) return null;
|
|
126
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
127
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
128
|
+
}
|
|
129
|
+
|
|
139
130
|
function normalizeClassified(raw, now) {
|
|
140
131
|
if (!isObject(raw)) return null;
|
|
132
|
+
const retryAt = toDate(raw.retryAt);
|
|
133
|
+
if (!REASONS.has(raw.reason) && !Number.isFinite(raw.waitMs) && !retryAt) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
141
136
|
|
|
142
137
|
const extra = {
|
|
143
138
|
reason: REASONS.has(raw.reason) ? raw.reason : 'unknown',
|
|
@@ -146,12 +141,7 @@ function normalizeClassified(raw, now) {
|
|
|
146
141
|
source: CLASSIFY_SOURCES.has(raw.source) ? raw.source : 'body',
|
|
147
142
|
};
|
|
148
143
|
|
|
149
|
-
let hint = null;
|
|
150
|
-
if (raw.retryAt !== undefined && raw.retryAt !== null) {
|
|
151
|
-
const date =
|
|
152
|
-
raw.retryAt instanceof Date ? raw.retryAt : new Date(raw.retryAt);
|
|
153
|
-
hint = hintFromRetryAt(date, now, extra);
|
|
154
|
-
}
|
|
144
|
+
let hint = retryAt ? hintFromRetryAt(retryAt, now, extra) : null;
|
|
155
145
|
if (!hint && Number.isFinite(raw.waitMs)) {
|
|
156
146
|
hint = hintFromWait(raw.waitMs, now, extra);
|
|
157
147
|
}
|
|
@@ -172,6 +162,12 @@ function runClassify(policy, signal, now, onClassifyError) {
|
|
|
172
162
|
headers: signal.headers,
|
|
173
163
|
body: signal.body,
|
|
174
164
|
});
|
|
165
|
+
if (isThenable(raw)) {
|
|
166
|
+
Promise.resolve(raw).catch(() => {});
|
|
167
|
+
throw new TypeError(
|
|
168
|
+
'classify() must return a hint or null, not a Promise'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
175
171
|
} catch (error) {
|
|
176
172
|
if (!onClassifyError) throw error;
|
|
177
173
|
onClassifyError(error);
|
|
@@ -226,28 +222,12 @@ function resolveCandidates(policy, signal, { now, onClassifyError }) {
|
|
|
226
222
|
return { hint: null, classified };
|
|
227
223
|
}
|
|
228
224
|
|
|
229
|
-
/**
|
|
230
|
-
* Finds the hint of a throttled response: the module's classify(), then the
|
|
231
|
-
* header parsers, then the static policy. Returns null when there is none.
|
|
232
|
-
* A status other than 429 counts only when classify recognised it.
|
|
233
|
-
*
|
|
234
|
-
* @param {object} [policy] The static `rateLimit` object of an API module.
|
|
235
|
-
* @param {{status?: number, headers?: object, body?: unknown}} signal
|
|
236
|
-
* @param {{now?: number, onClassifyError?: (error: Error) => void}} [options]
|
|
237
|
-
* Without `onClassifyError` an error thrown by classify propagates.
|
|
238
|
-
*/
|
|
239
225
|
function classifyRateLimit(policy, signal, options = {}) {
|
|
240
226
|
const { now = Date.now(), onClassifyError } = options;
|
|
241
227
|
return resolveCandidates(asPolicy(policy), signal, { now, onClassifyError })
|
|
242
228
|
.hint;
|
|
243
229
|
}
|
|
244
230
|
|
|
245
|
-
/**
|
|
246
|
-
* Like classifyRateLimit, but a throttled response always gets a hint: with
|
|
247
|
-
* none found it gets the step of the fixed backoff ladder, with source
|
|
248
|
-
* "backoff". Null when the response is not throttled: a status other than 429
|
|
249
|
-
* that classify did not recognise.
|
|
250
|
-
*/
|
|
251
231
|
function resolveRateLimitHint(policy, signal, options = {}) {
|
|
252
232
|
const {
|
|
253
233
|
attempt = 0,
|
|
@@ -276,11 +256,6 @@ function resolveRateLimitHint(policy, signal, options = {}) {
|
|
|
276
256
|
};
|
|
277
257
|
}
|
|
278
258
|
|
|
279
|
-
/**
|
|
280
|
-
* The wait before the next attempt, for a hint that came from the provider
|
|
281
|
-
* or the policy. Jitter only adds to the wait and never passes the budget.
|
|
282
|
-
* `fits` is false when the wait alone is over the budget.
|
|
283
|
-
*/
|
|
284
259
|
function computeWaitMs({ hint, policy, budgetMs, random = Math.random }) {
|
|
285
260
|
const base = Math.max(
|
|
286
261
|
hint.waitMs,
|
|
@@ -295,10 +270,6 @@ function computeWaitMs({ hint, policy, budgetMs, random = Math.random }) {
|
|
|
295
270
|
return { waitMs: Math.min(base + jitter, budgetMs), fits: true };
|
|
296
271
|
}
|
|
297
272
|
|
|
298
|
-
/**
|
|
299
|
-
* How long this request may still sleep in process: the cap, less what it
|
|
300
|
-
* slept already, and the time left in the invocation, less one request.
|
|
301
|
-
*/
|
|
302
273
|
function inProcessBudgetMs({
|
|
303
274
|
policy,
|
|
304
275
|
requestTimeoutMs = 0,
|
|
@@ -306,10 +277,8 @@ function inProcessBudgetMs({
|
|
|
306
277
|
waitedMs = 0,
|
|
307
278
|
} = {}) {
|
|
308
279
|
const cap = policy?.maxInProcessWaitMs ?? DEFAULT_MAX_IN_PROCESS_WAIT_MS;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
Math.min(cap - waitedMs, remainingMs - (requestTimeoutMs || 0))
|
|
312
|
-
);
|
|
280
|
+
const reserveMs = Math.min(requestTimeoutMs || 0, remainingMs / 2);
|
|
281
|
+
return Math.max(0, Math.min(cap - waitedMs, remainingMs - reserveMs));
|
|
313
282
|
}
|
|
314
283
|
|
|
315
284
|
module.exports = {
|
|
@@ -12,7 +12,6 @@ const { getLoggerScope } = require('../../logs/context');
|
|
|
12
12
|
const {
|
|
13
13
|
computeScopeKey,
|
|
14
14
|
computeWaitMs,
|
|
15
|
-
headerValue,
|
|
16
15
|
inProcessBudgetMs,
|
|
17
16
|
readRateLimitPolicy,
|
|
18
17
|
resolveRateLimitHint,
|
|
@@ -20,18 +19,6 @@ const {
|
|
|
20
19
|
|
|
21
20
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
22
21
|
|
|
23
|
-
function isJsonMediaType(contentType) {
|
|
24
|
-
const mediaType = String(contentType ?? '')
|
|
25
|
-
.split(';')[0]
|
|
26
|
-
.trim()
|
|
27
|
-
.toLowerCase();
|
|
28
|
-
return (
|
|
29
|
-
mediaType === 'application/json' ||
|
|
30
|
-
mediaType === 'text/json' ||
|
|
31
|
-
mediaType.endsWith('+json')
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
22
|
// A node-fetch error message holds the raw URL, and util.inspect prints the
|
|
36
23
|
// cause chain, so the FetchError keeps only a sanitized copy.
|
|
37
24
|
function sanitizedCause(err) {
|
|
@@ -67,8 +54,6 @@ class Requester extends Delegate {
|
|
|
67
54
|
this._authGeneration = 0;
|
|
68
55
|
this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
|
|
69
56
|
this.delegateTypes.push(this.DLGT_INVALID_AUTH);
|
|
70
|
-
this.DLGT_RATE_LIMITED = 'RATE_LIMITED';
|
|
71
|
-
this.delegateTypes.push(this.DLGT_RATE_LIMITED);
|
|
72
57
|
this.agent = get(params, 'agent', null);
|
|
73
58
|
|
|
74
59
|
// Per-attempt HTTP timeout. Without this the framework called fetch()
|
|
@@ -237,9 +222,6 @@ class Requester extends Delegate {
|
|
|
237
222
|
* @param {number} attempt - 0-based count of retries already made for
|
|
238
223
|
* this call. Indexes `this.backOff` for the next delay and is passed
|
|
239
224
|
* back in on each recursive retry.
|
|
240
|
-
* @param {number} waitedMs - Milliseconds this call has slept already
|
|
241
|
-
* for a wait that a provider or a policy set. It counts against the
|
|
242
|
-
* in-process cap.
|
|
243
225
|
*/
|
|
244
226
|
async _rawRequest(url, options, attempt = 0, waitedMs = 0) {
|
|
245
227
|
let encodedUrl = encodeURI(url);
|
|
@@ -336,7 +318,7 @@ class Requester extends Delegate {
|
|
|
336
318
|
status,
|
|
337
319
|
attempt
|
|
338
320
|
);
|
|
339
|
-
const
|
|
321
|
+
const hintedDelayMs = await this._hintedRetryDelayMs({
|
|
340
322
|
throttle,
|
|
341
323
|
status,
|
|
342
324
|
attempt,
|
|
@@ -346,21 +328,24 @@ class Requester extends Delegate {
|
|
|
346
328
|
response,
|
|
347
329
|
timeoutMs,
|
|
348
330
|
});
|
|
349
|
-
if (
|
|
331
|
+
if (hintedDelayMs !== null) {
|
|
350
332
|
clearRequestTimer();
|
|
351
333
|
await new Promise((resolve) =>
|
|
352
|
-
setTimeout(resolve,
|
|
334
|
+
setTimeout(resolve, hintedDelayMs)
|
|
353
335
|
);
|
|
354
336
|
return this._rawRequest(
|
|
355
337
|
url,
|
|
356
338
|
options,
|
|
357
339
|
attempt + 1,
|
|
358
|
-
waitedMs +
|
|
340
|
+
waitedMs + hintedDelayMs
|
|
359
341
|
);
|
|
360
342
|
}
|
|
361
343
|
|
|
362
344
|
// If the status is retriable and there are back off requests left, retry the request
|
|
363
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
(throttle?.throttled || status >= 500) &&
|
|
347
|
+
attempt < this.backOff.length
|
|
348
|
+
) {
|
|
364
349
|
clearRequestTimer();
|
|
365
350
|
const delay = this.backOff[attempt] * 1000;
|
|
366
351
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
@@ -499,16 +484,7 @@ class Requester extends Delegate {
|
|
|
499
484
|
}
|
|
500
485
|
}
|
|
501
486
|
|
|
502
|
-
|
|
503
|
-
* What to do about a throttled response: the delay before the next
|
|
504
|
-
* attempt, or null when there is no attempt left. A wait that a hint set
|
|
505
|
-
* and that does not fit the budget throws RateLimitError.
|
|
506
|
-
*
|
|
507
|
-
* @returns {Promise<{delayMs: number, hintedMs: number}|null>}
|
|
508
|
-
* `hintedMs` is the part of the delay that a provider or a policy set,
|
|
509
|
-
* which counts against the in-process cap.
|
|
510
|
-
*/
|
|
511
|
-
async _throttleRetry({
|
|
487
|
+
async _hintedRetryDelayMs({
|
|
512
488
|
throttle,
|
|
513
489
|
status,
|
|
514
490
|
attempt,
|
|
@@ -518,35 +494,28 @@ class Requester extends Delegate {
|
|
|
518
494
|
response,
|
|
519
495
|
timeoutMs,
|
|
520
496
|
}) {
|
|
521
|
-
if (!throttle?.throttled)
|
|
522
|
-
|
|
523
|
-
if (hint.source === 'backoff') {
|
|
524
|
-
return attempt < this.backOff.length
|
|
525
|
-
? { delayMs: this.backOff[attempt] * 1000, hintedMs: 0 }
|
|
526
|
-
: null;
|
|
497
|
+
if (!throttle?.throttled || throttle.hint.source === 'backoff') {
|
|
498
|
+
return null;
|
|
527
499
|
}
|
|
528
|
-
|
|
529
|
-
const budgetMs =
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
500
|
+
const { hint } = throttle;
|
|
501
|
+
const budgetMs = this.backOff.length
|
|
502
|
+
? inProcessBudgetMs({
|
|
503
|
+
policy: this._rateLimitPolicy,
|
|
504
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
505
|
+
remainingMs: remainingInvocationMs(),
|
|
506
|
+
waitedMs,
|
|
507
|
+
})
|
|
508
|
+
: 0;
|
|
535
509
|
const { waitMs, fits } = computeWaitMs({
|
|
536
510
|
hint,
|
|
537
511
|
policy: this._rateLimitPolicy,
|
|
538
512
|
budgetMs,
|
|
539
513
|
random: this._random,
|
|
540
514
|
});
|
|
541
|
-
|
|
542
|
-
status,
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
attempt,
|
|
546
|
-
waitedMs,
|
|
547
|
-
action: fits ? 'wait' : 'throw',
|
|
548
|
-
});
|
|
549
|
-
if (fits) return { delayMs: waitMs, hintedMs: waitMs };
|
|
515
|
+
if (fits) {
|
|
516
|
+
this._logRateLimited({ status, hint, waitMs, attempt, waitedMs });
|
|
517
|
+
return waitMs;
|
|
518
|
+
}
|
|
550
519
|
|
|
551
520
|
this._logRequestFailed(encodedUrl, options, status);
|
|
552
521
|
const rateLimitError = await RateLimitError.create({
|
|
@@ -559,18 +528,9 @@ class Requester extends Delegate {
|
|
|
559
528
|
module: this._telemetryModuleLabel(),
|
|
560
529
|
scopeKey: computeScopeKey(this._rateLimitPolicy, this),
|
|
561
530
|
});
|
|
562
|
-
await this._notifyRateLimited(rateLimitError);
|
|
563
531
|
throw this._maybeFlagTimeoutDuringBodyRead(rateLimitError, timeoutMs);
|
|
564
532
|
}
|
|
565
533
|
|
|
566
|
-
/**
|
|
567
|
-
* Decides whether a response says a limit was hit. A 429 always does. A
|
|
568
|
-
* 4xx or 5xx other than 401 does only when the module's classify() names
|
|
569
|
-
* a limit. The body is read here, once, only for classify(); the text is
|
|
570
|
-
* handed on so the error need not read the stream again.
|
|
571
|
-
*
|
|
572
|
-
* @returns {Promise<{throttled: boolean, hint?: object, responseBody?: string}|null>}
|
|
573
|
-
*/
|
|
574
534
|
async _detectThrottle(response, status, attempt) {
|
|
575
535
|
const policy = this._rateLimitPolicy;
|
|
576
536
|
const canClassify =
|
|
@@ -605,9 +565,6 @@ class Requester extends Delegate {
|
|
|
605
565
|
return {};
|
|
606
566
|
}
|
|
607
567
|
const text = await response.text();
|
|
608
|
-
if (!isJsonMediaType(headerValue(response.headers, 'content-type'))) {
|
|
609
|
-
return { text };
|
|
610
|
-
}
|
|
611
568
|
try {
|
|
612
569
|
return { text, json: JSON.parse(text) };
|
|
613
570
|
} catch {
|
|
@@ -615,9 +572,9 @@ class Requester extends Delegate {
|
|
|
615
572
|
}
|
|
616
573
|
}
|
|
617
574
|
|
|
618
|
-
_logRateLimited({ status, hint, waitMs, attempt, waitedMs
|
|
575
|
+
_logRateLimited({ status, hint, waitMs, attempt, waitedMs }) {
|
|
619
576
|
const logger = this.logger;
|
|
620
|
-
logger.
|
|
577
|
+
logger.trace('Rate limited', {
|
|
621
578
|
eventName: `${logger.name}.rate_limited`,
|
|
622
579
|
statusCode: status,
|
|
623
580
|
waitMs,
|
|
@@ -626,7 +583,6 @@ class Requester extends Delegate {
|
|
|
626
583
|
hintSource: hint.source,
|
|
627
584
|
attempt,
|
|
628
585
|
waitedMs,
|
|
629
|
-
action,
|
|
630
586
|
});
|
|
631
587
|
}
|
|
632
588
|
|
|
@@ -639,24 +595,6 @@ class Requester extends Delegate {
|
|
|
639
595
|
});
|
|
640
596
|
}
|
|
641
597
|
|
|
642
|
-
/**
|
|
643
|
-
* Tells the delegate a wait was too long to sleep. Best effort: a failed
|
|
644
|
-
* notification is logged and never replaces the RateLimitError the caller
|
|
645
|
-
* is about to receive.
|
|
646
|
-
*/
|
|
647
|
-
async _notifyRateLimited(rateLimitError) {
|
|
648
|
-
try {
|
|
649
|
-
await this.notify(this.DLGT_RATE_LIMITED, rateLimitError);
|
|
650
|
-
} catch (error) {
|
|
651
|
-
const logger = this.logger;
|
|
652
|
-
logger.warn('Rate limit notification failed', {
|
|
653
|
-
eventName: `${logger.name}.rate_limit_notify_failed`,
|
|
654
|
-
statusCode: rateLimitError.statusCode,
|
|
655
|
-
error,
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
598
|
_logRequestFailed(encodedUrl, options, status) {
|
|
661
599
|
const logger = this.logger;
|
|
662
600
|
if (!logger.isLevelEnabled('DEBUG')) return;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.656.4fb07b4.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.656.4fb07b4.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.656.4fb07b4.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.656.4fb07b4.0",
|
|
53
53
|
"@prisma/client": "^6.19.3",
|
|
54
54
|
"@types/lodash": "4.17.15",
|
|
55
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "4fb07b4e92324bbbf0d19153e2168642facc1182"
|
|
93
93
|
}
|
package/types/core/index.d.ts
CHANGED
|
@@ -58,22 +58,13 @@ declare module "@friggframework/core" {
|
|
|
58
58
|
|
|
59
59
|
export function loadInstalledModules(): any[];
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* Runs `fn` with the time the invocation ends, as epoch milliseconds. A
|
|
63
|
-
* nested scope can shorten the deadline and never extend it.
|
|
64
|
-
*/
|
|
65
61
|
export function runWithInvocationDeadline<T>(
|
|
66
62
|
deadlineAt: number | undefined,
|
|
67
63
|
fn: () => T
|
|
68
64
|
): T;
|
|
69
65
|
|
|
70
|
-
/** Milliseconds left in the invocation. `Infinity` outside a Lambda. */
|
|
71
66
|
export function remainingInvocationMs(now?: number): number;
|
|
72
67
|
|
|
73
|
-
/**
|
|
74
|
-
* Finds the hint of a throttled response: the module's classify(), then the
|
|
75
|
-
* header parsers, then the static policy. Null when there is none.
|
|
76
|
-
*/
|
|
77
68
|
export function classifyRateLimit(
|
|
78
69
|
policy: RateLimitPolicy | undefined,
|
|
79
70
|
signal: RateLimitSignal,
|
package/types/errors/index.d.ts
CHANGED
|
@@ -16,10 +16,6 @@ declare module "@friggframework/errors" {
|
|
|
16
16
|
readonly body: any;
|
|
17
17
|
isTimeout?: boolean;
|
|
18
18
|
timeoutMs?: number;
|
|
19
|
-
/**
|
|
20
|
-
* True when `classify` named the response as a limit but gave no time. A
|
|
21
|
-
* `RateLimitError` always has it.
|
|
22
|
-
*/
|
|
23
19
|
isRateLimited?: boolean;
|
|
24
20
|
reason?: RateLimitReason;
|
|
25
21
|
|
|
@@ -35,29 +31,19 @@ declare module "@friggframework/errors" {
|
|
|
35
31
|
|
|
36
32
|
export type RateLimitSource = "header" | "body" | "static" | "backoff";
|
|
37
33
|
|
|
38
|
-
/** What Frigg knows about when calls are accepted again. */
|
|
39
34
|
export type RateLimitHint = {
|
|
40
|
-
/** When calls are accepted again. */
|
|
41
35
|
retryAt: Date;
|
|
42
|
-
/** Milliseconds from the time the hint was read until `retryAt`. */
|
|
43
36
|
waitMs: number;
|
|
44
37
|
reason: RateLimitReason;
|
|
45
|
-
/** A provider policy name, when the response names one. */
|
|
46
38
|
policy?: string;
|
|
47
|
-
/** Calls left in the window, when the response says. */
|
|
48
39
|
remaining?: number;
|
|
49
40
|
source: RateLimitSource;
|
|
50
41
|
};
|
|
51
42
|
|
|
52
|
-
/**
|
|
53
|
-
* A FetchError for a response that said a limit was hit, and for which the
|
|
54
|
-
* response or the module's policy says when to call again.
|
|
55
|
-
*/
|
|
56
43
|
export class RateLimitError extends FetchError {
|
|
57
44
|
constructor(options?: RateLimitErrorConstructor);
|
|
58
45
|
|
|
59
46
|
isRateLimited: true;
|
|
60
|
-
/** When calls are accepted again. */
|
|
61
47
|
retryAt: Date;
|
|
62
48
|
waitMs: number;
|
|
63
49
|
reason: RateLimitReason;
|
|
@@ -73,7 +59,6 @@ declare module "@friggframework/errors" {
|
|
|
73
59
|
|
|
74
60
|
type RateLimitErrorConstructor = FetchErrorConstructor & {
|
|
75
61
|
hint?: Partial<RateLimitHint>;
|
|
76
|
-
/** Wait from `now`. When absent, `hint.retryAt` sets the time. */
|
|
77
62
|
waitMs?: number;
|
|
78
63
|
module?: string;
|
|
79
64
|
scopeKey?: string;
|
|
@@ -1,27 +1,6 @@
|
|
|
1
1
|
declare module "@friggframework/integrations" {
|
|
2
2
|
import { Delegate, IFriggDelegate } from "@friggframework/core";
|
|
3
3
|
|
|
4
|
-
export type IntegrationMessageAction =
|
|
5
|
-
| { type: "RETRY_WHEN_READY" }
|
|
6
|
-
| { type: "LINK"; label: string; url: string };
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* A stored message. The framework stores the keys of an item as given, so a
|
|
10
|
-
* caller can add its own. A rate limit adds `code: "RATE_LIMITED"`,
|
|
11
|
-
* `module`, `reason`, `retryAt` (ISO 8601) and `actions`.
|
|
12
|
-
*/
|
|
13
|
-
export interface IntegrationMessage {
|
|
14
|
-
title: string;
|
|
15
|
-
message: string;
|
|
16
|
-
timestamp: number;
|
|
17
|
-
code?: string;
|
|
18
|
-
module?: string;
|
|
19
|
-
reason?: string;
|
|
20
|
-
retryAt?: string;
|
|
21
|
-
actions?: IntegrationMessageAction[];
|
|
22
|
-
[key: string]: unknown;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
4
|
export interface Integration {
|
|
26
5
|
entities: any[];
|
|
27
6
|
userId: string;
|
|
@@ -29,10 +8,10 @@ declare module "@friggframework/integrations" {
|
|
|
29
8
|
config: any;
|
|
30
9
|
version: string;
|
|
31
10
|
messages: {
|
|
32
|
-
errors:
|
|
33
|
-
warnings:
|
|
34
|
-
info:
|
|
35
|
-
logs:
|
|
11
|
+
errors: [];
|
|
12
|
+
warnings: [];
|
|
13
|
+
info: [];
|
|
14
|
+
logs: [];
|
|
36
15
|
};
|
|
37
16
|
}
|
|
38
17
|
|