Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
daf2bac
feat(core): add rate-limit hint parsers
d-klotz Sep 28, 2026
5f97d7c
feat(core): resolve rate-limit hints from policy, parsers and backoff
d-klotz Sep 28, 2026
4fa6931
feat(core): add RateLimitError
d-klotz Sep 28, 2026
a5769ac
feat(core): expose the invocation deadline
d-klotz Sep 28, 2026
1ee2708
feat(core): honour Retry-After and static rateLimit in the Requester
d-klotz Sep 28, 2026
1cb07e6
fix(core): do not halt rate-limited 4xx in the queue worker
d-klotz Sep 28, 2026
14ae933
feat(core): type the rate-limit API
d-klotz Sep 28, 2026
10f1676
docs(core): document static rateLimit and RateLimitError
d-klotz Sep 28, 2026
7302a64
fix(core): address the review of the rate-limit Requester
d-klotz Sep 28, 2026
0d9ce57
refactor(core): move the rate-limit notes from comments to the reference
d-klotz Sep 29, 2026
a603952
refactor(core): keep one definedOnly for the rate-limit helpers
d-klotz Sep 29, 2026
1478395
refactor(core): read the time left in a Lambda context in one place
d-klotz Sep 29, 2026
1c1ba79
refactor(core): send an unhinted throttle down the base retry ladder
d-klotz Sep 29, 2026
bca32d7
fix(core): keep at most half the time left for the next request
d-klotz Sep 29, 2026
8911244
fix(core): write retryAt and reason of a rate-limited error to the log
d-klotz Sep 29, 2026
0889205
fix(core): log a hinted wait at TRACE and leave the throw to the boun…
d-klotz Sep 29, 2026
7bbd1f2
fix(core): count a classify() result only when it names a limit
d-klotz Sep 29, 2026
9ff5866
fix(core): parse the classify body as JSON whatever its media type
d-klotz Sep 29, 2026
4bb5df4
fix(core): never wait in process for a hint with an empty backOff
d-klotz Sep 29, 2026
bf3847f
fix(core): read the response stream before options.body in FetchError…
d-klotz Sep 29, 2026
9e61ffe
test(core): cover a reason-only classify with a short Retry-After
d-klotz Sep 29, 2026
b4e8789
fix(core): type the random option of the Requester constructor
d-klotz Sep 29, 2026
fcaf1fa
docs(core): say which untimed throttles carry isRateLimited
d-klotz Sep 29, 2026
0d299d4
docs(core): show a module that throttles on a 2xx body
d-klotz Sep 29, 2026
ffedaa4
docs(core): check isLastAttempt before rethrowing a RateLimitError
d-klotz Sep 29, 2026
5f25a0f
docs(core): cut the core CLAUDE.md rate-limit block to a summary
d-klotz Sep 29, 2026
4fb07b4
docs(core): note that the schema's rateLimit is not read at runtime
d-klotz Sep 29, 2026
a9fb1cb
fix(core): throw RateLimitError at once for a non-429 limit with no time
d-klotz Nov 9, 2026
468f83a
fix(core): bound hinted retries and keep them off the ladder
d-klotz Nov 9, 2026
6eb85ab
refactor(core): let the caller own the in-process wait budget
d-klotz Nov 9, 2026
3d2cd3e
fix(core): keep halting a rate-limited 4xx until deferral lands
d-klotz Nov 9, 2026
f067d2a
fix(core): drop a custom rate-limit scope that is not a string or number
d-klotz Nov 9, 2026
602fc50
fix(core): keep the status of the cause on a RateLimitError
d-klotz Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/guides/INTEGRATION-PATTERNS.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,17 @@ module.exports = { MyIntegration };

### Rate Limiting

Always respect API rate limits:
Put what you know about a provider's limits in its API module: declare
`static rateLimit` (see the
[API module reference](../reference/api-module-definition-and-functions.md#rate-limits-static-ratelimit)).
The Requester then waits as the provider says. When the wait is too long to
sleep, it throws `RateLimitError` with `retryAt`. A queue handler checks
`delivery.isLastAttempt` first (see [Queue Handler Delivery](#queue-handler-delivery))
and ends the run on the last attempt, then rethrows: the queue worker does not
halt a `RateLimitError`, and SQS redelivers the message. Do not catch it to
write a retry of your own.

For pacing in your own loop, respect API rate limits:

```javascript
async processBatchHandler({ processId, entityIds, entityType }) {
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/LOGGING-IN-INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ try {
error)` also works (the logger moves it to `error`), but `{ error }` is the
standard form.
- The logger writes the error as `{ type, message, code, status, stack,
cause }`, redacted. The `cause` chain is kept to 3 levels. Other own
cause }`, redacted, plus `retryAt` and `reason` for a `RateLimitError`.
The `cause` chain is kept to 3 levels. Other own
properties of the error (for example axios `config`, `request`,
`response`) are not written, because they often hold tokens.
- A `FetchError` from an API module already has a safe message:
Expand Down
10 changes: 9 additions & 1 deletion docs/guides/LOGGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ One record, shown on several lines (stdout has it on one line):
| `entityId`, `credentialId` | string | `Module` bindings | in API module records |
| `eventName` | string | call site: the `logger` name, then `.<action>` | required for `frigg.*` at `WARN` and above, and for framework lifecycle `INFO` |
| `trace_id`, `span_id`, `trace_flags` | string (hex) | the active OTel span | inside a span only |
| `error` | object | `{ type, message, code, status, stack, cause }`, sanitized | when a call site passes `error` |
| `error` | object | `{ type, message, code, status, stack, cause }`, sanitized; plus `retryAt` (ISO) and `reason` for an error with `isRateLimited` and a valid `retryAt` | when a call site passes `error` |
| `droppedKeys` | string[] | logger | when the logger removed a field (see Precedence) |

Call-site fields go next to these, at the top level.
Expand Down Expand Up @@ -162,6 +162,14 @@ the app deploys defines the schema.
layers throw with `cause` and do not log. `createHandler`, the express error
middleware, `Worker.run` and the DLQ processor are the boundaries.

The `Requester` writes one `TRACE` record, `module.<name>.rate_limited`, for
each wait that a provider or a module policy set. It carries `statusCode`,
`waitMs`, `retryAt`, `reason`, `hintSource`, `attempt` and `waitedMs`. The
plain backoff ladder writes none. The `RateLimitError` it throws gets no record
of its own: the boundary logs it, and its `error` field carries `retryAt` and
`reason`. A `classify()` that throws or returns a Promise writes one `WARN`,
`module.<name>.rate_limit_classify_failed`.

`FRIGG_LOG_LEVEL` sets the minimum level. The logger drops a record below it
before it reads any field.

Expand Down
212 changes: 212 additions & 0 deletions docs/reference/api-module-definition-and-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,218 @@ encryption: { credentialFields: ['signing_key', 'webhook_secret'] },
apiPropertiesToPersist: { credential: ['signing_key', 'webhook_secret'] }
```

## Rate Limits (`static rateLimit`)

An API module can tell the Requester how its provider limits calls, and how to
read a throttled response. Declare a static `rateLimit` on the API class. Every
key is optional. A module that declares nothing keeps the fixed backoff ladder
for a 429 (1, 3, 10, 30, 60 and 180 s), and reads the time from a 429 with the
default `parsers`: `Retry-After`, the reset headers and the IETF `RateLimit`
headers. The JSON schema still has an older `config.rateLimit: { requests, period }`
that the runtime does not read: only `static rateLimit` on the API class counts.

| Key | Meaning |
|---|---|
| `scope` | The key a limit counts against: `'entity'` (default), `'credential'`, `'app'`, or a synchronous function `(requester) => key` that returns a string or a number. The Requester puts it on `RateLimitError.scopeKey`, which is `undefined` when the requester has no id for the scope or the function returns another type. |
| `minRetryAfterMs` | A wait never shorter than this. Use it when the docs say "wait 60 seconds" and the response says nothing. |
| `parsers` | The header parsers to read, in order: `retryAfter`, `resetHeaders`, `ietf`. Default: all three. A name that is not built in throws a `TypeError` when the API class is constructed. |
| `classify` | Recognises a limit that is not a plain 429, or names its reason. See below. |
| `windows`, `maxConcurrency` | The provider's limits, as documentation. A window named like the `reason` (`{ name: 'burst', perMs: 10000 }`) sets the wait when no header does, unless `minRetryAfterMs` is longer. Pacing uses them later. |
| `userHints` | Links per `reason` that a UI can show, for example `{ daily: { links: [{ label, url }] } }`. |

### Built-in parsers

| Parser | Reads |
|---|---|
| `retryAfter` | `Retry-After`: delta seconds, an HTTP-date or an ISO timestamp |
| `resetHeaders` | `X-RateLimit-Reset-After` (delta seconds), else `X-RateLimit-Reset`, `RateLimit-Reset` or `X-Rate-Limit-Reset`: epoch milliseconds, epoch seconds or delta seconds (told apart by size), or a date. `remaining` comes from `X-RateLimit-Remaining`, `RateLimit-Remaining` or `X-Rate-Limit-Remaining` |
| `ietf` | The IETF `RateLimit` field, as `limit=, remaining=, reset=` or as structured items (`"name";r=0;t=12`). In a list, the item with the fewest remaining calls wins, then the longest wait. `RateLimit-Policy` names the policy |

A parser reads the headers from a `Headers` object, a `Map`, an entries array or
a plain object, and matches the names in any case. A time in the past waits
0 ms. A wait that is negative, not a number or longer than 366 days is ignored.
The package exports the parsers as `parseRetryAfter(value, { now })`,
`parseResetHeaders(headers, { now })` and `parseIetfRateLimit(headers, { now })`.
Each returns a hint or `null`.

### What `classify` returns

`classify({ status, headers, body })` runs for a 429, and for any other 4xx or
5xx except 401. `body` is the response text parsed as JSON, whatever the media
type, or `undefined` when the text is not JSON (an HTML error page, for
example). It returns `null` when the response is not a limit, or a hint:

| Field | Meaning |
|---|---|
| `reason` | `'burst'`, `'daily'`, `'monthly'`, `'concurrency'` or `'unknown'` |
| `waitMs` or `retryAt` | When to call again. Leave both out to take the time from the parsers, then from the policy. Step 2 of [How the Requester waits](#how-the-requester-waits) says what happens when neither gives one. |
| `policy` | The provider's own name for the limit, for logs and the UI |
| `remaining` | Calls left in the window, when the response says |
| `source` | `'header'`, `'body'` (default) or `'static'` |

A status other than 429 is a limit only when `classify` returns a hint for it.
A result counts only when it has a known `reason`, a finite `waitMs` or a valid
`retryAt`; anything else, `{}` for example, counts as `null`. `classify` must
be synchronous. A `classify` that throws or returns a Promise is logged
(`rate_limit_classify_failed`) and counts as `null`.

```javascript
class Api extends OAuth2Requester {
static rateLimit = {
scope: 'entity',
windows: [{ name: 'burst', limit: 100, perMs: 10_000 }],
parsers: ['retryAfter', 'resetHeaders'],
classify({ status, body }) {
if (status !== 429) return null;
if (body?.policyName === 'DAILY') return { reason: 'daily', waitMs: 3_600_000 };
return { reason: 'burst', policy: body?.policyName };
},
userHints: {
daily: { links: [{ label: 'API usage limits', url: 'https://example.com/limits' }] },
},
};
}
```

```javascript
class Api extends ApiKeyRequester {
static rateLimit = { minRetryAfterMs: 60_000 };
}
```

### How the Requester waits

1. It looks for a hint in this order: `classify()`, the parsers, the policy
(the larger of `minRetryAfterMs` and the `perMs` of the window named by the
reason).
2. With no time, a 429 keeps the fixed ladder: the same calls, the same delays,
then a plain `FetchError`, as before. A response with another status that
`classify` recognises but that gets no time throws `RateLimitError` at once,
after one call, with `retryAt` and `waitMs` `undefined`: the time is unknown,
and a ladder of seconds cannot outwait a daily limit. A 5xx that `classify`
does not recognise keeps the 5xx ladder.
3. With a hint, the wait is the largest of the hint, `minRetryAfterMs` and 1 s,
plus at most 10 % jitter. The Requester sleeps when the wait fits the time
this request may still sleep, in total, and the time left in the Lambda
invocation less one request timeout. That reserve is never more than half
the time left, so a 29 s HTTP invocation with the default 60 s request
timeout can still sleep 14.5 s. The caller owns the total, not the module:
10 s in an HTTP handler, 60 s in an integration queue worker, and 300 s
outside an invocation (tests, scripts, `frigg start`). See
[The invocation deadline](#the-invocation-deadline). A request makes at most
10 hinted retries. They do not use the retries of the ladder, so a 5xx after
a hinted wait still gets the whole 5xx ladder.
4. A wait that does not fit throws `RateLimitError`. It extends `FetchError`, so
`statusCode` stays, and adds `isRateLimited`, `retryAt`, `waitMs`, `reason`,
`policy`, `source`, `module` and `scopeKey`.

A Requester built with `backOff: []` never sleeps: a hinted wait throws
`RateLimitError` at once, and a 429 with no hint throws a plain `FetchError`.

The parsers, `Retry-After` among them, read the headers of a 429 and of a
response that `classify` recognises, and of no other response: a 503 with
`Retry-After` keeps the 5xx ladder unless `classify` recognises it. The queue
worker halts a `RateLimitError` with a 4xx status other than 408 and 429, like
any other 4xx: it discards the message.

### A client that is not the Requester

A module that drives another client (for example jsforce) calls
`classifyRateLimit` around its own calls and throws the error itself:

```javascript
const { classifyRateLimit, RateLimitError } = require('@friggframework/core');

async withLimits(call) {
try {
return await call();
} catch (err) {
const hint = classifyRateLimit(Api.rateLimit, {
status: err.statusCode,
headers: {},
body: { errorCode: err.errorCode },
});
if (!hint) throw err;
throw new RateLimitError({ hint, module: this.delegate?.name, cause: err });
}
}
```

`classifyRateLimit(policy, { status, headers, body }, { now, onClassifyError })`
resolves in the order the Requester uses and returns a hint or `null`. A hint is
`{ retryAt, waitMs, reason, source, policy, remaining }`: `retryAt` is a `Date`,
`waitMs` counts from the call, and `policy` and `remaining` are there only when
known. An error that `classify` throws propagates unless you pass
`onClassifyError`.

`new RateLimitError({ hint, waitMs, module, scopeKey, ...fetchErrorArgs })`
takes the `FetchError` arguments (`resource`, `init`, `response`, `cause`,
`responseBody`). It sets `retryAt` to now plus `waitMs`, or to `hint.retryAt`
when `waitMs` is left out. With no `response`, `statusCode` comes from
`cause.statusCode`.

### A limit in a 2xx body

The Requester calls `classify` only for a 4xx or 5xx: a generic retry of a
"throttled 200" could send a POST twice. A provider that answers 200 with a
limit error (a GraphQL `THROTTLED` error, for example) needs the module to
wrap its own call, classify the body and throw:

```javascript
const { classifyRateLimit, RateLimitError } = require('@friggframework/core');

class Api extends OAuth2Requester {
static rateLimit = {
classify({ body }) {
const throttled = body?.errors?.some(
(error) => error.extensions?.code === 'THROTTLED'
);
return throttled ? { reason: 'burst', waitMs: 2_000 } : null;
},
};

async query(document, variables) {
const url = `${this.baseUrl}/graphql`;
const response = await this._post({
url,
body: { query: document, variables },
returnFullRes: true,
});
const body = await response.json();
if (!body.errors?.length) return body.data;

const cause = new Error(body.errors[0].message);
const hint = classifyRateLimit(Api.rateLimit, {
status: response.status,
headers: response.headers,
body,
});
if (!hint) throw cause;
throw new RateLimitError({ hint, module: this.delegate?.name, cause, resource: url });
}
}
```

### The invocation deadline

`runInvocationScope` stores two limits for every Lambda invocation: the end of
the invocation, from `context.getRemainingTimeInMillis()`, and the most one
Requester call may sleep in process, in total. `createHandler` takes the second
as the option `maxInProcessWaitMs`.

| Entry point | `maxInProcessWaitMs` |
|---|---|
| HTTP handlers (`createHandler` default) | `10000` |
| Integration queue worker | `60000` |
| Outside an invocation (tests, scripts, `frigg start`) | `300000` |

`remainingInvocationMs()` returns the milliseconds left, or `Infinity` outside
an invocation. `runWithInvocationDeadline({ deadlineAt, maxInProcessWaitMs }, fn)`
runs `fn` with either limit or both. `deadlineAt` is in epoch milliseconds. A
nested call can only lower each limit, field by field. A field that is not a
finite number, or a negative `maxInProcessWaitMs`, sets nothing. `0` means never
sleep: every hinted wait throws `RateLimitError`.

## Complete OAuth2 Example

```javascript
Expand Down
13 changes: 12 additions & 1 deletion packages/core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,16 @@ class MyIntegration extends IntegrationBase {
- `ApiKeyRequester` - API key authentication
- `BasicAuthRequester` - Basic authentication

**Rate limits** (ADR-049): an API module declares `static rateLimit` on its
`Requester` subclass. A hinted wait (from `classify()`, the header parsers or
the static policy) sleeps within a budget that the caller owns (10 s in an HTTP
handler, 60 s in a queue worker, 300 s outside an invocation, at most 10
hinted retries), or throws `RateLimitError`
(`isRateLimited`, `retryAt`) when it does not fit. A 429 with no hint keeps the
fixed `backOff` ladder. A non-429 limit that `classify` names without a time
throws `RateLimitError` at once, with `retryAt` unknown. See
[Rate Limits](../../docs/reference/api-module-definition-and-functions.md#rate-limits-static-ratelimit).

**Module Factory**:
- `ModuleFactory` - Creates and configures API module instances
- Handles credential injection
Expand Down Expand Up @@ -477,7 +487,7 @@ handlers (`{ req, res, next }`) and `this.on` events do not.
`isLastAttempt` is `false` when either count is unknown: a local or non-SQS
invocation, or a queue whose redrive policy the stack does not own
(`ownership.queue: 'external'`). The value is information only: core still
rethrows retryable errors and discards halt errors (4xx except 408/429).
rethrows retryable errors and discards halt errors (4xx except 408 and 429).

Use it to end a run or count lost work on the final try: when a retryable
error (429, 5xx, network) is about to be rethrown and `isLastAttempt` is
Expand Down Expand Up @@ -507,6 +517,7 @@ it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
**Error Types**:
- `BaseError` - Base error class
- `FetchError` - HTTP request failures
- `RateLimitError` - A `FetchError` for a limit the provider or the module's policy says when to retry (`retryAt`)
- `HaltError` - Stop processing without retry
- `RequiredPropertyError` - Missing required parameters
- `ParameterTypeError` - Invalid parameter type
Expand Down
4 changes: 3 additions & 1 deletion packages/core/core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class MyIntegration extends Delegate {
### Lambda Handler Lifecycle
1. **Pre-Execution Setup**:
```javascript
runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope for the invocation
runInvocationScope({ requestId, handlerName, method, route, invocation }, …); // Logger scope + invocation deadline (remainingInvocationMs()) + in-process wait budget (createHandler `maxInProcessWaitMs`: 10 s HTTP, 60 s queue worker)
log.info('Handler invoked', { eventName: 'frigg.handler.invoked' });
await secretsToEnv(); // Secrets Manager injection
await parametersToEnv(); // SSM Parameter Store fetch (only when SSM_PARAMETER_PREFIX + FRIGG_SSM_OFFLOADED_KEYS are set)
Expand Down Expand Up @@ -226,6 +226,8 @@ const handler = createHandler({
- Logs error but returns success
- Prevents infinite retries for known issues
- Used for graceful degradation scenarios
- The queue worker sets it on a 4xx except 408, 429 and errors with
`isRateLimited` (a provider limit signalled with 403 clears with time)

### Logging Strategy
```javascript
Expand Down
3 changes: 3 additions & 0 deletions packages/core/core/create-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
runInvocationScope,
DEFAULT_FLUSH_TIMEOUT_MS,
} = require('./invocation-scope');
const { MAX_IN_PROCESS_WAIT_MS } = require('./invocation-deadline');
const { secretsToEnv } = require('./secrets-to-env');
const { parametersToEnv } = require('./parameters-to-env');
const {
Expand All @@ -31,6 +32,7 @@ const createHandler = (optionByName = {}) => {
telemetry,
flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
usageRollup,
maxInProcessWaitMs = MAX_IN_PROCESS_WAIT_MS.http,
} = optionByName;

if (!method) {
Expand Down Expand Up @@ -144,6 +146,7 @@ const createHandler = (optionByName = {}) => {
shouldUseDatabase,
flushTimeoutMs,
context,
maxInProcessWaitMs,
}
);
};
Expand Down
24 changes: 24 additions & 0 deletions packages/core/core/create-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ jest.mock('./secrets-to-env', () => ({

const { connectPrisma } = require('../database/prisma');
const { createHandler } = require('./create-handler');
const { invocationMaxInProcessWaitMs } = require('./invocation-deadline');
const { getLogger, createMemorySink } = require('../logs');
const { HaltError } = require('../errors/halt-error');
const { httpApiV2Event, sqsEvent } = require('../logs/__fixtures__/events');
Expand Down Expand Up @@ -476,3 +477,26 @@ describe('createHandler — logger scope and records (ADR-048)', () => {
expect(inside.droppedKeys).toEqual(['requestId']);
});
});

describe('createHandler: in-process wait budget', () => {
const ctx = { awsRequestId: 'r1' };

it('runs the method with a 10 s budget by default', async () => {
const handler = createHandler({
eventName: 'X',
shouldUseDatabase: false,
method: async () => invocationMaxInProcessWaitMs(),
});
await expect(handler({}, { ...ctx })).resolves.toBe(10_000);
});

it('runs the method with the budget the caller gives', async () => {
const handler = createHandler({
eventName: 'X',
shouldUseDatabase: false,
maxInProcessWaitMs: 60_000,
method: async () => invocationMaxInProcessWaitMs(),
});
await expect(handler({}, { ...ctx })).resolves.toBe(60_000);
});
});
Loading
Loading