@friggframework/core 2.0.0--canary.654.6d3a665.0 → 2.0.0--canary.657.ebe5c60.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
@@ -500,6 +543,58 @@ The single source of the max receive count is
500
543
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
501
544
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
502
545
 
546
+ **Rate-limit deferral** (ADR-049): when a queue handler throws an error with
547
+ `isRateLimited` and a `retryAt` (a `RateLimitError` from the Requester, or one a
548
+ module throws), `Worker.run` does not give the message back to SQS for a 30
549
+ minute redelivery that spends one of its three receives. It puts the message
550
+ back so it runs at `retryAt`:
551
+
552
+ | Wait | What the worker does | Record |
553
+ |---|---|---|
554
+ | Up to 900 s | Sends the same body again with `DelaySeconds`, then acknowledges the old message | handled |
555
+ | Over 900 s, and `SCHEDULER_ROLE_ARN` is set | Creates a one-time EventBridge schedule at `retryAt` that sends the body to the queue, then acknowledges | handled |
556
+ | Over 900 s, no scheduler | Extends the message's visibility timeout to `retryAt` (12 h at most). This uses one receive | reported failed |
557
+
558
+ - The new message starts at receive count 1. A deferred body carries
559
+ `_frigg: { deferrals, firstDeferredAt }`; `_frigg` is reserved and the handler
560
+ never sees it.
561
+ - Two caps bound it: `FRIGG_QUEUE_MAX_DEFERRALS` (default 10) and
562
+ `FRIGG_QUEUE_MAX_DEFERRED_MS` (default 24 h, from the first deferral to the
563
+ retry time). Past a cap, with no `retryAt`, with no event source ARN or on a
564
+ FIFO queue, the record fails like any retryable error and `delivery`
565
+ applies.
566
+ - The mock scheduler is never used for a deferral: a schedule kept in memory
567
+ would be lost with the acknowledged message.
568
+ - With a `processId` in the message, the worker writes
569
+ `Process.context.rateLimit = { status: 'WAITING' | 'EXHAUSTED', mechanism,
570
+ retryAt, reason, module, deferrals, updatedAt }`, and `null` after the message
571
+ ran. `EXHAUSTED` means a cap ended the deferrals, or a visibility change came
572
+ on the last delivery.
573
+ - Delivery stays at least once. The new message is sent before the old one is
574
+ acknowledged, so a crash between the two can run the work twice.
575
+ - Handlers check `delivery.isLastAttempt` first, then rethrow. The delay and
576
+ schedule tiers send a new message, so they never reach the last attempt. The
577
+ visibility tier and a cap use a receive: on the last one the worker writes an
578
+ ERROR (`frigg.worker.record_lost_rate_limited`), and the handler has this one
579
+ chance to end the run.
580
+
581
+ ```javascript
582
+ async processBatch({ data, delivery }) {
583
+ try {
584
+ await this.syncPage(data);
585
+ } catch (error) {
586
+ if (delivery?.isLastAttempt) return this.failRun(data.processId, error);
587
+ throw error; // a RateLimitError too: core puts the message back at retryAt
588
+ }
589
+ }
590
+ ```
591
+
592
+ Records: `frigg.worker.record_deferred` (`mechanism` `delay` or `schedule`),
593
+ `record_visibility_extended`, `record_deferral_capped`,
594
+ `record_lost_rate_limited` and `rate_limit_state_failed`. A queue in a stack
595
+ that does not own it (`ownership.queue: 'external'`) needs its own
596
+ `sqs:ChangeMessageVisibility` and scheduler access.
597
+
503
598
  ### 8. Error Handling (`/errors`)
504
599
 
505
600
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -507,6 +602,7 @@ it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
507
602
  **Error Types**:
508
603
  - `BaseError` - Base error class
509
604
  - `FetchError` - HTTP request failures
605
+ - `RateLimitError` - A `FetchError` for a limit the provider or the module's policy says when to retry (`retryAt`)
510
606
  - `HaltError` - Stop processing without retry
511
607
  - `RequiredPropertyError` - Missing required parameters
512
608
  - `ParameterTypeError` - Invalid parameter type
package/core/CLAUDE.md CHANGED
@@ -79,6 +79,25 @@ await worker.send({
79
79
  }, delaySeconds);
80
80
  ```
81
81
 
82
+ **Rate-limit deferral** (ADR-049): `run()` handles an error with `isRateLimited`
83
+ and a `retryAt` through `defer(record, body, error, delivery)`, which returns
84
+ `{ outcome }`:
85
+ - `acked`: the message was sent again with `DelaySeconds` (wait up to 900 s) or
86
+ scheduled with the one-time scheduler (longer wait). The record is handled.
87
+ - `failed`: the visibility timeout was extended to `retryAt`, or a cap
88
+ (`FRIGG_QUEUE_MAX_DEFERRALS`, `FRIGG_QUEUE_MAX_DEFERRED_MS`) ended the
89
+ deferrals. The record is reported failed.
90
+ - `skipped`: no `retryAt`, no `eventSourceARN`, a FIFO queue, or a failed send.
91
+ The record fails as any error does, and `record_failed` carries
92
+ `deferral: { skipped }`.
93
+
94
+ Subclasses override two no-op hooks to keep run state: `recordRateLimitWait(body,
95
+ error, state)` runs after the message was put back, and `clearRateLimitWait(body)`
96
+ runs after a deferred or redelivered message succeeded. A failing hook logs
97
+ `frigg.worker.rate_limit_state_failed` and never changes the outcome. The queue
98
+ worker of an integration (`createQueueWorker`) writes `Process.context.rateLimit`
99
+ in them. See `packages/core/CLAUDE.md` section 7.
100
+
82
101
  ### Delegate Pattern System (`Delegate.js:3-27`)
83
102
 
84
103
  **Purpose**: Observer/delegation pattern for decoupled component communication
@@ -130,7 +149,7 @@ class MyIntegration extends Delegate {
130
149
  ### Lambda Handler Lifecycle
131
150
  1. **Pre-Execution Setup**:
132
151
  ```javascript
133
- runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope for the invocation
152
+ runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope + invocation deadline (remainingInvocationMs())
134
153
  log.info('Handler invoked', { eventName: 'frigg.handler.invoked' });
135
154
  await secretsToEnv(); // Secrets Manager injection
136
155
  await parametersToEnv(); // SSM Parameter Store fetch (only when SSM_PARAMETER_PREFIX + FRIGG_SSM_OFFLOADED_KEYS are set)
@@ -226,6 +245,8 @@ const handler = createHandler({
226
245
  - Logs error but returns success
227
246
  - Prevents infinite retries for known issues
228
247
  - Used for graceful degradation scenarios
248
+ - The queue worker sets it on a 4xx except 408, 429 and errors with
249
+ `isRateLimited` (a provider limit signalled with 403 clears with time)
229
250
 
230
251
  ### Logging Strategy
231
252
  ```javascript
package/core/Worker.js CHANGED
@@ -1,14 +1,39 @@
1
- const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/client-sqs');
1
+ const { randomUUID } = require('node:crypto');
2
+ const {
3
+ SQSClient,
4
+ GetQueueUrlCommand,
5
+ SendMessageCommand,
6
+ ChangeMessageVisibilityCommand,
7
+ } = require('@aws-sdk/client-sqs');
2
8
  const _ = require('lodash');
3
9
  const { RequiredPropertyError } = require('../errors');
4
10
  const { get } = require('../assertions');
5
11
  const { readQueueDelivery } = require('../queues/queue-delivery');
12
+ const { awsConfigOptions } = require('../queues/queuer-util');
13
+ const {
14
+ MAX_DELAY_SECONDS,
15
+ MAX_VISIBILITY_TIMEOUT_SECONDS,
16
+ isDeferralCapped,
17
+ nextDeferral,
18
+ readDeferral,
19
+ readDeferralLimits,
20
+ withDeferral,
21
+ } = require('../queues/queue-deferral');
6
22
  const { runMessageScope } = require('./invocation-scope');
7
- const { getLogger } = require('../logs');
23
+ const { getLogger, serializeError } = require('../logs');
8
24
 
9
- const sqs = new SQSClient({ region: process.env.AWS_REGION });
25
+ const sqs = new SQSClient({
26
+ region: process.env.AWS_REGION,
27
+ ...awsConfigOptions(),
28
+ });
10
29
  const log = getLogger('frigg.worker');
11
30
 
31
+ const validDate = (value) =>
32
+ value instanceof Date && !Number.isNaN(value.getTime()) ? value : null;
33
+ const queueNameOf = (arn) =>
34
+ typeof arn === 'string' && arn ? arn.split(':').pop() : undefined;
35
+ const causeOf = (error) => serializeError(error).message;
36
+
12
37
  class Worker {
13
38
  async getQueueURL(params) {
14
39
  // Passing params in because there will be multiple QueueNames
@@ -33,12 +58,31 @@ class Worker {
33
58
  // messageId, receiveCount and the event come from the scope.
34
59
  log.debug('Record started', { eventName: 'frigg.worker.record_started' });
35
60
 
61
+ const delivery = readQueueDelivery(record);
62
+ let runParams;
36
63
  try {
37
- const runParams = JSON.parse(record.body);
64
+ runParams = JSON.parse(record.body);
38
65
  this._validateParams(runParams);
39
- await this._run(runParams, context, readQueueDelivery(record));
66
+ await this._run(runParams, context, delivery);
67
+ await this._clearDeferredState(runParams, delivery);
40
68
  log.debug('Record succeeded', { eventName: 'frigg.worker.record_succeeded' });
41
69
  } catch (error) {
70
+ let deferral;
71
+ if (error.isRateLimited && runParams) {
72
+ deferral = await this.defer(
73
+ record,
74
+ runParams,
75
+ error,
76
+ delivery
77
+ );
78
+ if (deferral.outcome === 'acked') return;
79
+ if (deferral.outcome === 'failed') {
80
+ batchItemFailures.push({
81
+ itemIdentifier: record.messageId,
82
+ });
83
+ return;
84
+ }
85
+ }
42
86
  if (error.isHaltError) {
43
87
  // HaltError means "discard this message, don't retry".
44
88
  // Treat as success so SQS deletes it from the queue.
@@ -55,6 +99,14 @@ class Worker {
55
99
  log.warn('Record failed, returned for retry', {
56
100
  eventName: 'frigg.worker.record_failed',
57
101
  error,
102
+ ...(deferral && {
103
+ deferral: {
104
+ skipped: deferral.skipped,
105
+ ...(deferral.cause && {
106
+ cause: causeOf(deferral.cause),
107
+ }),
108
+ },
109
+ }),
58
110
  });
59
111
  batchItemFailures.push({ itemIdentifier: record.messageId });
60
112
  }
@@ -75,6 +127,240 @@ class Worker {
75
127
  // parameters
76
128
  }
77
129
 
130
+ /**
131
+ * Puts a rate-limited message back so it runs at `error.retryAt`, without
132
+ * spending a receive. The message is sent again with a delay of up to 900 s,
133
+ * or scheduled with the one-time scheduler, or has its visibility timeout
134
+ * extended. The new message goes out before the old one is acknowledged, so
135
+ * delivery stays at least once.
136
+ *
137
+ * @returns {Promise<{outcome: 'acked'|'failed'|'skipped', skipped?: string, cause?: Error}>}
138
+ * `acked`: the message is handled. `failed`: report the record failed and
139
+ * let SQS redeliver it. `skipped`: nothing was done; fail the record as
140
+ * any other error.
141
+ */
142
+ async defer(record, body, error, delivery) {
143
+ const retryAt = validDate(error.retryAt);
144
+ if (!retryAt) return { outcome: 'skipped', skipped: 'no_retry_at' };
145
+ const queueName = queueNameOf(record.eventSourceARN);
146
+ if (!queueName || queueName.endsWith('.fifo')) {
147
+ return { outcome: 'skipped', skipped: 'no_event_source' };
148
+ }
149
+
150
+ const now = Date.now();
151
+ const waitMs = Math.max(0, retryAt.getTime() - now);
152
+ const deferral = nextDeferral(body, now);
153
+ const details = {
154
+ waitMs,
155
+ retryAt: retryAt.toISOString(),
156
+ deferrals: deferral.deferrals,
157
+ firstDeferredAt: deferral.firstDeferredAt,
158
+ reason: error.reason,
159
+ module: error.module,
160
+ statusCode: error.statusCode,
161
+ };
162
+ const state = (status, mechanism) => ({
163
+ status,
164
+ mechanism,
165
+ deferrals: deferral.deferrals,
166
+ retryAt,
167
+ });
168
+
169
+ const limits = readDeferralLimits();
170
+ if (isDeferralCapped({ ...deferral, retryAt }, limits)) {
171
+ log.warn('Rate-limit deferral capped', {
172
+ eventName: 'frigg.worker.record_deferral_capped',
173
+ ...details,
174
+ maxDeferrals: limits.maxDeferrals,
175
+ maxDeferredMs: limits.maxDeferredMs,
176
+ deferredMs:
177
+ retryAt.getTime() - Date.parse(deferral.firstDeferredAt),
178
+ error,
179
+ });
180
+ await this._recordWait(body, error, state('EXHAUSTED', 'none'));
181
+ this._logIfLost(delivery, error, details);
182
+ return { outcome: 'failed' };
183
+ }
184
+
185
+ const next = withDeferral(body, deferral);
186
+
187
+ if (waitMs <= MAX_DELAY_SECONDS * 1000) {
188
+ const delaySeconds = Math.ceil(waitMs / 1000);
189
+ try {
190
+ const queueUrl = await this._queueUrl(queueName);
191
+ await this.send({ ...next, QueueUrl: queueUrl }, delaySeconds);
192
+ } catch (cause) {
193
+ return { outcome: 'skipped', skipped: 'send_failed', cause };
194
+ }
195
+ await this._recordWait(body, error, state('WAITING', 'delay'));
196
+ log.warn('Record deferred', {
197
+ eventName: 'frigg.worker.record_deferred',
198
+ mechanism: 'delay',
199
+ delaySeconds,
200
+ ...details,
201
+ error,
202
+ });
203
+ return { outcome: 'acked' };
204
+ }
205
+
206
+ let scheduleError;
207
+ const scheduler = this.getSchedulerService();
208
+ if (scheduler) {
209
+ const scheduleName = `frigg-defer-${
210
+ record.messageId || randomUUID()
211
+ }`;
212
+ try {
213
+ await scheduler.scheduleOneTime({
214
+ scheduleName,
215
+ scheduleAt: retryAt,
216
+ queueResourceId: record.eventSourceARN,
217
+ payload: next,
218
+ });
219
+ } catch (cause) {
220
+ if (cause?.name !== 'ConflictException') scheduleError = cause;
221
+ }
222
+ if (!scheduleError) {
223
+ await this._recordWait(
224
+ body,
225
+ error,
226
+ state('WAITING', 'schedule')
227
+ );
228
+ log.warn('Record deferred', {
229
+ eventName: 'frigg.worker.record_deferred',
230
+ mechanism: 'schedule',
231
+ scheduleName,
232
+ ...details,
233
+ error,
234
+ });
235
+ return { outcome: 'acked' };
236
+ }
237
+ }
238
+
239
+ const visibilityTimeout = Math.min(
240
+ Math.ceil(waitMs / 1000),
241
+ MAX_VISIBILITY_TIMEOUT_SECONDS
242
+ );
243
+ try {
244
+ const queueUrl = await this._queueUrl(queueName);
245
+ await sqs.send(
246
+ new ChangeMessageVisibilityCommand({
247
+ QueueUrl: queueUrl,
248
+ ReceiptHandle: record.receiptHandle,
249
+ VisibilityTimeout: visibilityTimeout,
250
+ })
251
+ );
252
+ } catch (cause) {
253
+ return { outcome: 'skipped', skipped: 'visibility_failed', cause };
254
+ }
255
+ await this._recordWait(
256
+ body,
257
+ error,
258
+ state(
259
+ delivery.isLastAttempt ? 'EXHAUSTED' : 'WAITING',
260
+ 'visibility'
261
+ )
262
+ );
263
+ log.warn('Record visibility extended', {
264
+ eventName: 'frigg.worker.record_visibility_extended',
265
+ mechanism: 'visibility',
266
+ visibilityTimeout,
267
+ ...details,
268
+ ...(scheduleError && { scheduleError: causeOf(scheduleError) }),
269
+ error,
270
+ });
271
+ this._logIfLost(delivery, error, details);
272
+ return { outcome: 'failed' };
273
+ }
274
+
275
+ /**
276
+ * The one-time scheduler, or null when the stack has none. The mock
277
+ * scheduler is never used: a schedule kept in memory would be lost with
278
+ * the message.
279
+ */
280
+ getSchedulerService() {
281
+ if (this._schedulerService !== undefined) return this._schedulerService;
282
+ this._schedulerService = null;
283
+ if (
284
+ process.env.SCHEDULER_ROLE_ARN &&
285
+ process.env.SCHEDULER_PROVIDER !== 'mock'
286
+ ) {
287
+ const {
288
+ createSchedulerService,
289
+ SCHEDULER_PROVIDERS,
290
+ } = require('../infrastructure/scheduler/scheduler-service-factory');
291
+ this._schedulerService = createSchedulerService({
292
+ provider: SCHEDULER_PROVIDERS.EVENTBRIDGE,
293
+ });
294
+ }
295
+ return this._schedulerService;
296
+ }
297
+
298
+ /**
299
+ * Hook: the message was put back for a rate limit. Runs after it was sent.
300
+ * @param {Object} body The parsed message body.
301
+ * @param {Error} error The RateLimitError.
302
+ * @param {{status: 'WAITING'|'EXHAUSTED', mechanism: string, deferrals: number, retryAt: Date}} state
303
+ */
304
+ async recordRateLimitWait() {}
305
+
306
+ /**
307
+ * Hook: a message that was put back for a rate limit ran without error.
308
+ * @param {Object} body The parsed message body.
309
+ */
310
+ async clearRateLimitWait() {}
311
+
312
+ _queueUrl(queueName) {
313
+ this._queueUrls ??= new Map();
314
+ if (!this._queueUrls.has(queueName)) {
315
+ const lookup = this.getQueueURL({ QueueName: queueName }).catch(
316
+ (error) => {
317
+ this._queueUrls.delete(queueName);
318
+ throw error;
319
+ }
320
+ );
321
+ this._queueUrls.set(queueName, lookup);
322
+ }
323
+ return this._queueUrls.get(queueName);
324
+ }
325
+
326
+ async _recordWait(body, error, state) {
327
+ await this._runStateHook('record', () =>
328
+ this.recordRateLimitWait(body, error, state)
329
+ );
330
+ }
331
+
332
+ async _clearDeferredState(body, delivery) {
333
+ if (readDeferral(body).deferrals === 0 && !(delivery.receiveCount > 1))
334
+ return;
335
+ await this._runStateHook('clear', () => this.clearRateLimitWait(body));
336
+ }
337
+
338
+ async _runStateHook(operation, hook) {
339
+ try {
340
+ await hook();
341
+ } catch (error) {
342
+ log.warn('Rate-limit run state not written', {
343
+ eventName: 'frigg.worker.rate_limit_state_failed',
344
+ operation,
345
+ error,
346
+ });
347
+ }
348
+ }
349
+
350
+ _logIfLost(delivery, error, details) {
351
+ if (!delivery.isLastAttempt) return;
352
+ log.error(
353
+ 'Rate-limited record lost: the message goes to the dead-letter queue next',
354
+ {
355
+ eventName: 'frigg.worker.record_lost_rate_limited',
356
+ retryAt: details.retryAt,
357
+ reason: error.reason,
358
+ module: error.module,
359
+ error,
360
+ }
361
+ );
362
+ }
363
+
78
364
  // returns the message id
79
365
  async send(params, delay = 0) {
80
366
  this._validateParams(params);
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,