@friggframework/core 2.0.0--canary.657.ebe5c60.0 → 2.0.0--canary.657.8f335eb.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
@@ -381,7 +381,10 @@ in this order:
381
381
  | 4 | The fixed `backOff` ladder (1, 3, 10, 30, 60, 180 s) |
382
382
 
383
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.
384
+ the same delays, then a plain `FetchError`. Nothing budgets it. A response
385
+ that `classify` names as a limit but that has no time follows the same
386
+ ladder, and the last error is a `FetchError` flagged `isRateLimited` with
387
+ the `reason`, so the queue worker does not halt it.
385
388
  - With a hint, the wait is `max(hint, minRetryAfterMs, 1 s)` plus at most 10 %
386
389
  jitter. The total sleep of one request is capped at `maxInProcessWaitMs`
387
390
  (default 5 minutes) and at the time left in the invocation less one request
@@ -58,7 +58,7 @@ class FetchError extends BaseError {
58
58
  const provided = options.responseBody ?? options.body;
59
59
  let responseBody = provided;
60
60
  if (
61
- !responseBody &&
61
+ responseBody === undefined &&
62
62
  response &&
63
63
  !response.bodyUsed &&
64
64
  typeof response.text === 'function'
@@ -90,7 +90,7 @@ function hintFromWait(waitMs, now, { source = 'header', ...extra } = {}) {
90
90
  * Builds a hint from an absolute time. A time in the past waits 0 ms.
91
91
  */
92
92
  function hintFromRetryAt(retryAt, now, { source = 'header', ...extra } = {}) {
93
- const at = retryAt instanceof Date ? retryAt.getTime() : NaN;
93
+ const at = retryAt instanceof Date ? retryAt.getTime() : Number.NaN;
94
94
  if (Number.isNaN(at)) return null;
95
95
  const waitMs = Math.max(0, at - now);
96
96
  if (waitMs > MAX_HINT_WAIT_MS) return null;
@@ -216,7 +216,7 @@ function pickStructured(items) {
216
216
 
217
217
  function readKeyValue(text, key) {
218
218
  const match = new RegExp(
219
- `(?:^|[,;\\s])${key}\\s*=\\s*([^,;\\s]+)`,
219
+ String.raw`(?:^|[,;\s])${key}\s*=\s*([^,;\s]+)`,
220
220
  'i'
221
221
  ).exec(text);
222
222
  return match ? match[1] : undefined;
@@ -243,8 +243,10 @@ function classifyRateLimit(policy, signal, options = {}) {
243
243
  }
244
244
 
245
245
  /**
246
- * Like classifyRateLimit, but always answers: with no hint it returns the
247
- * step of the fixed backoff ladder, with source "backoff".
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.
248
250
  */
249
251
  function resolveRateLimitHint(policy, signal, options = {}) {
250
252
  const {
@@ -258,6 +260,7 @@ function resolveRateLimitHint(policy, signal, options = {}) {
258
260
  onClassifyError,
259
261
  });
260
262
  if (hint) return hint;
263
+ if (signal.status !== 429 && !classified) return null;
261
264
 
262
265
  const waitMs =
263
266
  attempt < backOff.length ? Number(backOff[attempt]) * 1000 || 0 : 0;
@@ -10,7 +10,6 @@ const { redactUrl } = require('../../logs/redact');
10
10
  const { toSanitizedSurrogate } = require('../../logs/serialize');
11
11
  const { getLoggerScope } = require('../../logs/context');
12
12
  const {
13
- classifyRateLimit,
14
13
  computeScopeKey,
15
14
  computeWaitMs,
16
15
  headerValue,
@@ -20,7 +19,18 @@ const {
20
19
  } = require('./rate-limit');
21
20
 
22
21
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
23
- const JSON_CONTENT_TYPE = /^application\/(json|vnd\.api\+json|hal\+json)/;
22
+
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
+ }
24
34
 
25
35
  // A node-fetch error message holds the raw URL, and util.inspect prints the
26
36
  // cause chain, so the FetchError keeps only a sanitized copy.
@@ -46,6 +56,7 @@ class Requester extends Delegate {
46
56
  super(params);
47
57
  this.backOff = get(params, 'backOff', [1, 3, 10, 30, 60, 180]);
48
58
  this._rateLimitPolicy = readRateLimitPolicy(this.constructor);
59
+ this._random = params?.random ?? Math.random;
49
60
  this.isRefreshable = false;
50
61
  this.refreshCount = 0;
51
62
  this.authGraceRetryCount = 0;
@@ -294,7 +305,12 @@ class Requester extends Delegate {
294
305
  clearRequestTimer();
295
306
  const delay = this.backOff[attempt] * 1000;
296
307
  await new Promise((resolve) => setTimeout(resolve, delay));
297
- return this._rawRequest(url, options, attempt + 1);
308
+ return this._rawRequest(
309
+ url,
310
+ options,
311
+ attempt + 1,
312
+ waitedMs
313
+ );
298
314
  }
299
315
  const fetchError = await FetchError.create({
300
316
  resource: encodedUrl,
@@ -318,70 +334,27 @@ class Requester extends Delegate {
318
334
  status,
319
335
  attempt
320
336
  );
321
- if (throttle?.throttled) {
322
- const { hint } = throttle;
323
- if (hint.source === 'backoff') {
324
- if (attempt < this.backOff.length) {
325
- clearRequestTimer();
326
- const delay = this.backOff[attempt] * 1000;
327
- await new Promise((resolve) =>
328
- setTimeout(resolve, delay)
329
- );
330
- return this._rawRequest(
331
- url,
332
- options,
333
- attempt + 1,
334
- waitedMs
335
- );
336
- }
337
- } else {
338
- const budgetMs = inProcessBudgetMs({
339
- policy: this._rateLimitPolicy,
340
- requestTimeoutMs: this.requestTimeoutMs,
341
- remainingMs: remainingInvocationMs(),
342
- waitedMs,
343
- });
344
- const { waitMs, fits } = computeWaitMs({
345
- hint,
346
- policy: this._rateLimitPolicy,
347
- budgetMs,
348
- });
349
- this._logRateLimited({
350
- status,
351
- hint,
352
- waitMs,
353
- attempt,
354
- waitedMs,
355
- action: fits ? 'wait' : 'throw',
356
- });
357
- if (fits) {
358
- clearRequestTimer();
359
- await new Promise((resolve) =>
360
- setTimeout(resolve, waitMs)
361
- );
362
- return this._rawRequest(
363
- url,
364
- options,
365
- attempt + 1,
366
- waitedMs + waitMs
367
- );
368
- }
369
- this._logRequestFailed(encodedUrl, options, status);
370
- const rateLimitError = await RateLimitError.create({
371
- resource: encodedUrl,
372
- init: options,
373
- response,
374
- responseBody: throttle.responseBody,
375
- hint,
376
- waitMs,
377
- module: this._telemetryModuleLabel(),
378
- scopeKey: computeScopeKey(this._rateLimitPolicy, this),
379
- });
380
- throw this._maybeFlagTimeoutDuringBodyRead(
381
- rateLimitError,
382
- timeoutMs
383
- );
384
- }
337
+ const throttleRetry = await this._throttleRetry({
338
+ throttle,
339
+ status,
340
+ attempt,
341
+ waitedMs,
342
+ encodedUrl,
343
+ options,
344
+ response,
345
+ timeoutMs,
346
+ });
347
+ if (throttleRetry) {
348
+ clearRequestTimer();
349
+ await new Promise((resolve) =>
350
+ setTimeout(resolve, throttleRetry.delayMs)
351
+ );
352
+ return this._rawRequest(
353
+ url,
354
+ options,
355
+ attempt + 1,
356
+ waitedMs + throttleRetry.hintedMs
357
+ );
385
358
  }
386
359
 
387
360
  // If the status is retriable and there are back off requests left, retry the request
@@ -389,7 +362,7 @@ class Requester extends Delegate {
389
362
  clearRequestTimer();
390
363
  const delay = this.backOff[attempt] * 1000;
391
364
  await new Promise((resolve) => setTimeout(resolve, delay));
392
- return this._rawRequest(url, options, attempt + 1);
365
+ return this._rawRequest(url, options, attempt + 1, waitedMs);
393
366
  }
394
367
 
395
368
  if (status === 401) {
@@ -410,7 +383,12 @@ class Requester extends Delegate {
410
383
  // This request did not try the current token. Retry with
411
384
  // it. Do not spend one more provider-side rotation.
412
385
  clearRequestTimer();
413
- return this._rawRequest(url, options, attempt + 1);
386
+ return this._rawRequest(
387
+ url,
388
+ options,
389
+ attempt + 1,
390
+ waitedMs
391
+ );
414
392
  }
415
393
 
416
394
  if (!this.isRefreshable) {
@@ -427,7 +405,12 @@ class Requester extends Delegate {
427
405
  await new Promise((resolve) =>
428
406
  setTimeout(resolve, delay)
429
407
  );
430
- return this._rawRequest(url, options, attempt + 1);
408
+ return this._rawRequest(
409
+ url,
410
+ options,
411
+ attempt + 1,
412
+ waitedMs
413
+ );
431
414
  }
432
415
 
433
416
  throw await this._invalidateAuth(
@@ -460,7 +443,12 @@ class Requester extends Delegate {
460
443
  const refreshSucceeded = await this._refreshAuthOnce();
461
444
  if (refreshSucceeded) {
462
445
  clearRequestTimer();
463
- return this._rawRequest(url, options, attempt + 1);
446
+ return this._rawRequest(
447
+ url,
448
+ options,
449
+ attempt + 1,
450
+ waitedMs
451
+ );
464
452
  }
465
453
 
466
454
  throw await this._invalidateAuth(encodedUrl, options, response);
@@ -477,6 +465,10 @@ class Requester extends Delegate {
477
465
  response,
478
466
  responseBody: throttle?.responseBody,
479
467
  });
468
+ if (throttle?.throttled && status !== 429) {
469
+ fetchError.isRateLimited = true;
470
+ fetchError.reason = throttle.hint.reason;
471
+ }
480
472
  throw this._maybeFlagTimeoutDuringBodyRead(
481
473
  fetchError,
482
474
  timeoutMs
@@ -505,6 +497,69 @@ class Requester extends Delegate {
505
497
  }
506
498
  }
507
499
 
500
+ /**
501
+ * What to do about a throttled response: the delay before the next
502
+ * attempt, or null when there is no attempt left. A wait that a hint set
503
+ * and that does not fit the budget throws RateLimitError.
504
+ *
505
+ * @returns {Promise<{delayMs: number, hintedMs: number}|null>}
506
+ * `hintedMs` is the part of the delay that a provider or a policy set,
507
+ * which counts against the in-process cap.
508
+ */
509
+ async _throttleRetry({
510
+ throttle,
511
+ status,
512
+ attempt,
513
+ waitedMs,
514
+ encodedUrl,
515
+ options,
516
+ response,
517
+ timeoutMs,
518
+ }) {
519
+ if (!throttle?.throttled) return null;
520
+ const { hint } = throttle;
521
+ if (hint.source === 'backoff') {
522
+ return attempt < this.backOff.length
523
+ ? { delayMs: this.backOff[attempt] * 1000, hintedMs: 0 }
524
+ : null;
525
+ }
526
+
527
+ const budgetMs = inProcessBudgetMs({
528
+ policy: this._rateLimitPolicy,
529
+ requestTimeoutMs: this.requestTimeoutMs,
530
+ remainingMs: remainingInvocationMs(),
531
+ waitedMs,
532
+ });
533
+ const { waitMs, fits } = computeWaitMs({
534
+ hint,
535
+ policy: this._rateLimitPolicy,
536
+ budgetMs,
537
+ random: this._random,
538
+ });
539
+ this._logRateLimited({
540
+ status,
541
+ hint,
542
+ waitMs,
543
+ attempt,
544
+ waitedMs,
545
+ action: fits ? 'wait' : 'throw',
546
+ });
547
+ if (fits) return { delayMs: waitMs, hintedMs: waitMs };
548
+
549
+ this._logRequestFailed(encodedUrl, options, status);
550
+ const rateLimitError = await RateLimitError.create({
551
+ resource: encodedUrl,
552
+ init: options,
553
+ response,
554
+ responseBody: throttle.responseBody,
555
+ hint,
556
+ waitMs,
557
+ module: this._telemetryModuleLabel(),
558
+ scopeKey: computeScopeKey(this._rateLimitPolicy, this),
559
+ });
560
+ throw this._maybeFlagTimeoutDuringBodyRead(rateLimitError, timeoutMs);
561
+ }
562
+
508
563
  /**
509
564
  * Decides whether a response says a limit was hit. A 429 always does. A
510
565
  * 4xx or 5xx other than 401 does only when the module's classify() names
@@ -528,21 +583,17 @@ class Requester extends Delegate {
528
583
  await this._readBodyForClassify(response));
529
584
  }
530
585
 
531
- const signal = { status, headers: response.headers, body };
532
- const options = {
533
- attempt,
534
- backOff: this.backOff,
535
- now: Date.now(),
536
- onClassifyError: (error) => this._logClassifyFailed(status, error),
537
- };
538
- if (status === 429) {
539
- return {
540
- throttled: true,
541
- hint: resolveRateLimitHint(policy, signal, options),
542
- responseBody,
543
- };
544
- }
545
- const hint = classifyRateLimit(policy, signal, options);
586
+ const hint = resolveRateLimitHint(
587
+ policy,
588
+ { status, headers: response.headers, body },
589
+ {
590
+ attempt,
591
+ backOff: this.backOff,
592
+ now: Date.now(),
593
+ onClassifyError: (error) =>
594
+ this._logClassifyFailed(status, error),
595
+ }
596
+ );
546
597
  return { throttled: Boolean(hint), hint, responseBody };
547
598
  }
548
599
 
@@ -551,11 +602,7 @@ class Requester extends Delegate {
551
602
  return {};
552
603
  }
553
604
  const text = await response.text();
554
- if (
555
- !JSON_CONTENT_TYPE.test(
556
- headerValue(response.headers, 'content-type') || ''
557
- )
558
- ) {
605
+ if (!isJsonMediaType(headerValue(response.headers, 'content-type'))) {
559
606
  return { text };
560
607
  }
561
608
  try {
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.657.ebe5c60.0",
4
+ "version": "2.0.0--canary.657.8f335eb.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.657.ebe5c60.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.657.ebe5c60.0",
53
- "@friggframework/test": "2.0.0--canary.657.ebe5c60.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.657.8f335eb.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.657.8f335eb.0",
53
+ "@friggframework/test": "2.0.0--canary.657.8f335eb.0",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "ebe5c602d3aac76850912c731dd91851772856b3"
93
+ "gitHead": "8f335eb16f678038ad9448d9603c734ad663b3dc"
94
94
  }
@@ -16,6 +16,12 @@ 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
+ isRateLimited?: boolean;
24
+ reason?: RateLimitReason;
19
25
 
20
26
  static create(options?: CreateFetchErrorParams): Promise<FetchError>;
21
27
  }