Repository navigation
Conversation
Pure functions that turn a throttled response into a hint: Retry-After (delta seconds, HTTP-date, ISO), the reset headers (epoch s, epoch ms or delta, told apart by magnitude) and the IETF RateLimit field. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
readRateLimitPolicy reads and validates the static `rateLimit` of an API module class. classifyRateLimit runs the module's classify(), then the header parsers, then the static policy. A classify() that names only a reason takes its time from the parsers or the policy. resolveRateLimitHint adds the fixed backoff ladder as the last step. computeWaitMs and inProcessBudgetMs decide how long a request may sleep in process: the hint wins, the module floor and a 1 s minimum apply, jitter only adds, and the wait stays inside a cumulative cap and the time left in the invocation. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A FetchError for a response that said a limit was hit and told us, or the module's policy told us, when to call again. It keeps statusCode and the redacted url and adds isRateLimited, retryAt, waitMs, reason, policy, source, module and scopeKey. A module whose client is not the Requester can build one from a hint with classifyRateLimit. FetchError.create now builds the class it is called on, and uses a responseBody it is given instead of reading the stream a second time. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
runInvocationScope now records when the Lambda invocation ends, in its own AsyncLocalStorage. It is not the logs store: the telemetry bus forwards every non-log key of that store. remainingInvocationMs() reads it and is Infinity outside a Lambda (tests, frigg start, scripts). A nested scope can shorten the deadline and never extend it. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A 429 with a Retry-After header now waits as long as the provider says instead of the next step of the fixed ladder. A module can declare `static rateLimit` with a minimum wait, the parsers to read, and a classify() that recognises a limit signalled with another status, or with a body. A hinted wait that fits stays in process; a longer one throws RateLimitError with retryAt. A hinted wait is bounded by a cumulative cap (5 minutes, or the policy's maxInProcessWaitMs) and by the time left in the invocation. A 429 with no hint keeps today's ladder exactly: same calls, same delays, same FetchError, and it is not budgeted. Retry-After is read on 429 only; a 5xx keeps the 5xx ladder. Replaces the two skipped tests that called a live mock server. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The worker marks every 4xx except 408 and 429 as a halt and discards the message. A provider that signals a limit with 403 lost the message. An error the Requester flagged isRateLimited is now exempt and goes back to SQS. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Declares RateLimitError, RateLimitHint, RateLimitPolicy, RateLimitSignal and the static `rateLimit` and `requestTimeoutMs` of Requester, plus classifyRateLimit, the header parsers and the invocation deadline functions. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Adds the rate-limit section to the API module reference (the keys, what classify returns, how the Requester waits, and the pattern for a client that is not the Requester), points the integration patterns guide at it, and notes the new record names in the logging guide. The core CLAUDE.md files now name RateLimitError and the halt exemption. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
✅ Deploy Preview for friggframework-org canceled.
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 10f1676a00
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🔴
packages/core/modules/requester/requester.js— sweep:_rawRequest(url, options, attempt + 1)\s*; After a rate-limit wait, callers hit a longer or unbounded in-process sleep than the documented cap because retries after that wait drop the accumulated waitedMs back to 0._rawRequest(url, options, attempt + 1)on the ECONNRESET retry (297), the 5xx ladder retry (392), and the three 401 retries (413, 430, 463) omit the 4thwaitedMsargument, defaulting it to 0 instead of forwarding the value carried in from the rate-limit branch. Fix: every retry call site must thread the currentwaitedMsthrough soinProcessBudgetMs'smaxInProcessWaitMs - waitedMscap (policy.js) still bounds the total sleep for one logical request when a 5xx/401/ECONNRESET retry is interleaved with rate-limit waits.Why this was flagged
A provider that mixes a 429 (with Retry-After) and an intermittent 500 or 401 in the same call sequence triggers this: the 429 branch accumulates waitedMs (requester.js:366) then a subsequent 5xx retry at line 392 or a 401 retry at 413/430/463 calls
_rawRequest(url, options, attempt + 1)with no 4th arg, resetting waitedMs to 0 (default at line 231). The next rate-limit wait then computes its budget as if no time had been slept yet (policy.js inProcessBudgetMs), letting the request sleep well past the documented 5-minutemaxInProcessWaitMscap and the PR's own claim that 'One request may sleep 5 minutes in total.' On the base branch there was no such cap to defeat since waitedMs did not exist; this diff introduces the safeguard and these five call sites silently bypass it.Verification: normal. The candidate's factual claim holds.
_rawRequestcarries a cumulative in-process sleep accumulator (waitedMs, requester.js:231, documented at 227-229 as "Milliseconds this call has slept already ... It counts against the in-process cap"). The two rate-limit retry sites forward it: the backoff-throttle branch passeswaitedMs(requester.js:330-335) and the fitting-hinted-wait…
Records the answers to open questions 1 to 4 (Retry-After for every module, a 5 minute in-process cap, a new message for a wait up to 15 minutes and the scheduler beyond, 10 deferrals and 24 hours) and brings the text and the sketches in line with the code in #656 and #657. Changes to the text: - the hint wins; the fixed ladder is used only when there is no hint, and it has no budget; - classify runs for a 429 and any other 4xx or 5xx except 401, not for a 2xx, and may name only a reason; - the invocation deadline lives in its own AsyncLocalStorage; - the handler checks delivery.isLastAttempt first, then rethrows, because the visibility tier and a cap use a receive; - the RATE_LIMITED message has no `scheduled` flag and is deduplicated; - the base role gets sqs:ChangeMessageVisibility in every stack. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- A response that classify() names as a limit but that has no time (a 403 with only a reason, no header, no policy time) is now throttled: it follows the same ladder as a 429 with no hint, and the last error is a FetchError flagged isRateLimited, so the queue worker does not halt and discard the message. Before, it became a plain 4xx. resolveRateLimitHint returns null for a status other than 429 that classify did not recognise. - classify() gets the parsed body of any application/json, text/json or +json response, whatever the case of the media type. The old prefix match missed application/problem+json and mixed case, and matched application/jsonp. - The cumulative in-process wait now travels through every retry, not only the hinted one. A 5xx or a connection reset between two hinted waits no longer resets the 5 minute cap. - FetchError.create reads the response only when no body was given, so an empty body the caller already read is not read a second time. - The throttle handling moves out of _rawRequest into _throttleRetry, which keeps that function from growing further. - The Requester takes an optional `random` parameter for the jitter, like `fetch` and `backOff`. The tests no longer replace Math.random: source-map picks its quicksort pivots with it, and a constant made jest overflow the stack while it formatted a stack trace. - Number.NaN and String.raw in the parsers. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Addressed the automated review in 7302a64:
One thing worth knowing from this round: the Requester now takes an optional Full |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, this run also examined two other spots and ruled them out: a classify() hint that names only a reason with no time (requester.js:525) correctly falls back to the fixed backoff ladder rather than retrying unboundedly; and the shared attempt counter across throttle retries and 5xx retries (requester.js:366) reuses the same shared-budget pattern the ladder already had pre-PR, not a new regression.
Extended reasoning...
This run's inline findings (policy.js scope-key stringification and reset-header honouring, and RateLimitError losing statusCode/method/url for non-Requester clients) are new, not previously raised, so a fresh defer note is warranted rather than staying silent. The two items above were checked directly against requester.js's retry/backoff logic and found consistent with pre-existing behavior, so they add no new concern.
Delete the JSDoc and inline comments that the rate-limit change added to the parsers, the policy, the Requester, the invocation deadline, RateLimitError and the three type files. The API facts they held that the docs lacked now live in the API module reference: what each built-in parser reads, the TypeError for an unknown parser, an undefined scopeKey, the hint shape, classifyRateLimit and the RateLimitError constructor, and the invocation deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The policy had its own copy of the helper that the parsers already define. It now imports the parsers' one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deadlineFromContext repeated the guarded read of getRemainingTimeInMillis() that the invocation scope uses for its flush deadline. The read now lives in invocation-deadline.js as remainingTimeMs, and both use it. Results are the same for every input: a finite time gives now + time; a missing method, a non-number, a non-finite value or a throw gives no deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_throttleRetry returned { delayMs, hintedMs } where hintedMs was 0 on the
ladder and equal to delayMs for a hint, and its ladder half repeated the 5xx
ladder block. The ladder condition is back to the base shape, with
throttle?.throttled in place of status === 429, and the method, now
_hintedRetryDelayMs, returns only the wait a hint sets, null, or throws
RateLimitError. Same calls and delays for every path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-process budget kept back one full request timeout from the time left in the invocation. The default request timeout is 60 s and an HTTP Lambda has 29 s, so the budget was 0 there and every hinted 429 threw at once instead of a short wait and a retry. The reserve is now min(requestTimeoutMs, remainingMs / 2): budget = max(0, min(cap - waitedMs, remainingMs - reserve)). With 29 s left and a 60 s timeout the budget is 14.5 s; with 900 s left it is the 5-minute cap, as before; with no deadline it is the cap alone. The deadline test now uses Retry-After: 5, over the 4 s budget of 8 s left and a 5 s timeout, so it still proves that the budget respects the deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
serializeError keeps only type, message, code, status, stack and cause, so a RateLimitError logged at its boundary did not say when the limit clears. The serializer now also writes retryAt (ISO) and reason when the error has isRateLimited and a valid Date retryAt. An error without them serializes as before. The logging guides list the two fields; the stability rule allows an added field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dary The Requester wrote an INFO rate_limited record for a wait and for the RateLimitError it throws. Backoff is step detail (TRACE in the level table), and the logging standard says to log an error one time, at the boundary that decides its outcome, and not to log and rethrow. The wait record is now TRACE, the throw writes none, and the action field, left with one value, is gone. The DEBUG request_failed record before the throw stays, as on the other 4xx paths. The boundary record now carries retryAt and reason in its error field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan asks for a reason-only classify plus Retry-After: 7 to sleep 7000 ms with the reason from classify. The existing test used Retry-After: 400 and checked only the throw. The new test checks the delay and the reason on the rate_limited record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Requester reads params.random as its source of jitter, so tests can make a wait exact without mocking Math.random. RequesterConstructor now declares it (random?: () => number); ApiKeyRequester and BasicAuthRequester take it through that type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference said every classified response with no time ends as a FetchError flagged isRateLimited. Only one whose status is not 429 does; a 429 with no time ends as a plain FetchError, as before, and the queue worker never halts a 429. It also said Retry-After is read on a 429 only: the parsers read the headers of a 429 and of any response that classify recognises. And the static step takes the larger of minRetryAfterMs and the perMs of the window the reason names, not one or the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Requester runs classify only on a 4xx or 5xx, because a generic retry of
a "throttled 200" could send a POST twice. The reference now shows the
pattern for a provider that answers 200 with a limit error, such as a GraphQL
THROTTLED error: the module wraps its own call, calls the exported
classifyRateLimit(policy, { status, headers, body }) and throws
new RateLimitError({ hint, module, cause, resource }).
Both examples now name the module with this.delegate?.name: an API instance
has no name of its own; its delegate, the Module, has the moduleName.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guide told a queue handler to let a RateLimitError reach its caller. The contract (ADR-049 section 3) is that the handler checks delivery.isLastAttempt first, ends the run on the last attempt, and then rethrows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 45-line block repeated the API module reference, and had drifted from it. It is now a six-line summary that links to the Rate Limits section of docs/reference/api-module-definition-and-functions.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reference calls the JSON schema canonical, but
api-module-definition.schema.json still describes an older
config.rateLimit: { requests, period } that no runtime code reads. The Rate
Limits section now says so; the schema is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, comment @claude review on this pull request, or push a new commit, to trigger a review.
|
HubSpot limits each account that installs a marketplace OAuth app to 110 requests per 10 seconds, and answers a throttled call with a 429 whose body names the limit in `policyName`. The Requester met every such 429 with its fixed 1, 3, 10, 30, 60 and 180 second ladder, whatever the response said. Declare `static rateLimit` so the Requester can use what the response says: - A burst 429 (`TEN_SECONDLY_ROLLING`) returns a reason-only hint. A real `Retry-After` then wins, and the 10 second burst window is the fallback. - A `DAILY` 429 returns a one hour probe (`source: 'static'`). HubSpot resets the daily limit at midnight in the account's time zone, which the module does not know, so no UTC reset time is computed. One hour is over the in-process budget, so the Requester throws a `RateLimitError` with `reason: 'daily'` and `retryAt` and does not sleep. - Any other status is not a limit. - `userHints.daily.links` points at the API usage guidelines. `maxConcurrency` (10) is a pacing figure that HubSpot does not document. It is declared for the pacing phase and nothing reads it yet. The tests use recorded-style 429 responses and a stubbed fetch. They need a `@friggframework/core` that exports `RateLimitError` (friggframework/frigg#656). The dependency range is unchanged until the release that ships it is known. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Salesforce counts the API calls of an org over a rolling 24 hours. An org over its limit gets a 403 whose error code is `REQUEST_LIMIT_EXCEEDED`, and no reset time. The module drives jsforce, not the Requester, and a jsforce error has no `statusCode` (`HttpApiError`: `name` equals `errorCode`), so the Requester never sees it and the queue worker gets an opaque error. `REQUEST_LIMIT_EXCEEDED` is also the error for too many concurrent requests that run 20 seconds or longer, which clears in seconds. Reporting it as a daily limit would put a sync on hold for an hour. - Declare `static rateLimit`: a per-org daily rolling window and a `classify` that recognises `REQUEST_LIMIT_EXCEEDED`. Salesforce documents no field that tells the two limits apart, so `classify` reads the message: one that contains "concurrent" is a concurrency limit with a 30 second wait, and any other message is a daily limit. The message text is not documented, so the default stays daily. With no reset time to read, a daily limit gets a static one hour probe. Both reasons link to the API request limits page. - Add the public `withLimits(call)`. On a failed call it runs `classifyRateLimit` and throws a `RateLimitError`, with the jsforce error kept as `cause`. It rethrows any other error unchanged. The data methods use it. A consumer that calls `api.conn` directly wraps its own calls with `api.withLimits(() => api.conn.query(soql))`, as the README says. - Before it throws, `withLimits` tells the delegate with `_notifyRateLimited`, as the Requester does when it throws a `RateLimitError`, so the integration can record a warning for the user. Core writes that warning only for a limit longer than its in-process cap, so the daily limit gets one and the 30 second concurrency limit does not. A core without that method skips the call. - `module` on the error is `_telemetryModuleLabel()`, the label the Requester puts on its own `RateLimitError`. An API instance has no `name`. `RateLimitError` and `classifyRateLimit` come from `@friggframework/core` (friggframework/frigg#656) and are read at runtime. The dependency range is unchanged. On a core that does not export both yet, `withLimits` rethrows the original jsforce error, so the module can be released before an app upgrades its core. The token and refresh code is untouched, and `conn.limitInfo` is not read yet. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pipedrive limits an OAuth app per token over a rolling 2 second window and sends `x-ratelimit-reset` with the throttled response. A recorded 429 also carries `Retry-After`. The Requester met every such 429 with its fixed 1, 3, 10, 30, 60 and 180 second ladder. Declare `static rateLimit`: - `scope: 'entity'` and a burst window of 80 requests per 2 seconds. That is the lowest OAuth allowance in the docs (Lite 80, Growth 160, Premium 400, Ultimate 480). Pipedrive reports the real figure in `x-ratelimit-limit`. - `parsers: ['retryAfter', 'resetHeaders']`, the two header families Pipedrive sends. `x-ratelimit-reset` is a wait in seconds. - No `classify`. A spent daily token budget answers with a 429 and no documented wait time. Unless the response carries one of those headers, it keeps the fixed ladder and then the redelivery it has today. Add `RateLimitPolicy` and the types it uses to the module's `frigg-core.d.ts` shim, copied from the core types (friggframework/frigg#656). The subclass declares its own static, so the shim's `OAuth2Requester` needs no change. Core reads `x-ratelimit-reset` by default, so the wait tests characterise the behaviour with recorded headers, and the declaration test pins the numbers. The tests need a `@friggframework/core` with rate-limit awareness. The dependency range is unchanged until the release that ships it is known. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
seanspeaks
left a comment
There was a problem hiding this comment.
Adversarial review, run with Claude Code at Sean's request. All 497 of the PR's tests pass; the findings below come from 13 extra adversarial tests (in a scratch worktree, not committed) plus a read of the queue and infrastructure code.
The parsing, RateLimitError, serialisation and redaction work is solid, and most of it can stay as it is. Two things block the merge, and two more should be fixed.
Blocking
1. A non-429 limit that classify recognises without a time now runs the full ~284 s ladder. policy.js:231-255, requester.js:346
- Repro:
classifyreturns{ reason: 'daily' }for a 403. The ADR allows a hint that names only a reason. The module has nowindows/perMsand nominRetryAfterMs. - What happens:
resolveRateLimitHintfalls back to asource: 'backoff'hint, sothrottledis true. The unbudgeted ladder then runs on the 403: 7 calls and delays of[1000, 3000, 10000, 30000, 60000, 180000]ms, inside a 29 s deadline. It ends with a plainFetchErrorthat hasisRateLimited: trueandretryAt: undefined. - Effect: an HTTP Lambda times out, and a queue worker sleeps about 5 min to call a daily-limited endpoint 7 times. On
nextthe same 403 fails after 1 call. - Suggested fix: a status other than 429 never uses the ladder. Without a time, throw
RateLimitErrorimmediately (retryAtderived fromwindows.resetswhen one exists).
2. The scheduling decision lives in the module policy and the Requester.
-
Direction from Sean: the decision to wait, requeue or defer must not sit at the API-module level. It should surface as an event, and the worker or orchestrator decides.
-
Where the PR puts it:
maxInProcessWaitMsis on the module'srateLimitpolicy. The right value depends on the caller (a 29 s HTTP Lambda vs a 900 s worker that hasreservedConcurrency: 20), which a module can't know. -
What a queue worker does today: "requeue" is the halt-rule exemption, then SQS redelivery after the visibility timeout, so
retryAtis ignored. With the generated queue config (VisibilityTimeout: 1800,maxReceiveCount: 3, DLQ =InternalErrorQueue;integration-builder.js:570-576):- ≤ 5 min: sleeps in process, holding 1 of 20 worker slots for every tenant.
- 10 min: retries about 20 min late.
- Daily (
Retry-After: 86400): 3 receives about 30 min apart, then the DLQ, where the processor only logs it. The work is lost after about an hour.
-
Suggested shape (keeps phase 1 small):
- The module policy describes the provider only:
classify,parsers,minRetryAfterMs,windows,scope. DropmaxInProcessWaitMs. - The caller sets the in-process budget through the invocation store:
runWithInvocationDeadline({ deadlineAt, maxInProcessWaitMs }).createHandlergives HTTP about 10 s; queue workers get a small default (0 once deferral exists). - The Requester stays a signal source: it sleeps within the caller's budget, and otherwise throws
RateLimitError.
Phase 2 then turns the error into a
RATE_LIMITEDevent (details on #655). - The module policy describes the provider only:
Should fix
3. Hinted retries ignore backOff.length. requester.js:487-530
backOff: [1, 2]withRetry-After: 1on every response gives 301 calls and about 300 s of sleep beforeRateLimitError; onnextthe most is 3 calls.- It applies to every module (
Retry-Afteris now global).sharepointanddeelset[1, 3], so their authors expect a small number of retries. - Hinted retries also use up
attempt, so a later 5xx in the same request gets no ladder retry. - Suggested fix: bound hinted attempts, e.g.
max(backOff.length, 10)or a separatemaxHintedRetries.
4. Token-endpoint limits lose the signal. oauth-2.js:364 (_transportFailureError)
- A classified 403, or a hinted 429, from the token endpoint during a 401 refresh comes back as a plain
ErrorwithstatusCode, and noisRateLimited/retryAt. - The queue worker then halts the 403 and discards it, which contradicts the PR's "a 403 limit goes back to SQS".
- Suggested fix: copy
isRateLimited,retryAt,waitMsandreasononto the transport error.
Non-blocking
- Entry points with no deadline. admin-scripts
admin-script-router/report-router(30 s timeout),script-executor-handler,report-executor-handler,database-migration-handlerandworkers/db-migration.jsdon't run underrunInvocationScope, so only the 5-min cap applies. A 60 s hint in the 30 s admin router hits the Lambda timeout. WithgetRemainingTimeInMillismissing, the deadline isInfinity. - Reserve can be 0. With
requestTimeoutMs: 0(policy.js:280), it slept 9500 of a 9500 ms budget. - Thundering herd. 50 parallel requests with
Retry-After: 10all retried between 10.0 and 11.0 s (pacing is phase 2, noting it here). waitedMscounts only hinted sleeps. A mix of ladder and hinted retries can sleep about 5 + 4.7 min.- Over-matching
classify. If it matches a 404, that becomes a 5-min loop (151 calls). Worth a doc warning. - HTTP routes. A
RateLimitErrorreaches the client as a generic 500 (app-handler-helpers.js), not a 429/503 withRetry-After. - Reset headers. One given as a millisecond delta (e.g.
60000) is read as 60000 s. - Types.
RateLimitErroris exported at runtime from@friggframework/corebut only declared in@friggframework/errors;tsc --strictgives TS2305. - Verified fine:
Retry-Afterparsing (it rejects-1/NaN/huge values and dates more than 366 days ahead; past dates and0become 1 s);classifythat throws or returns a Promise is ignored and the headers are used; the body is read once and a 2xx body is never touched; no secrets in message/stack/inspect/JSON.
Frigg 2.0.1 review (Group 2) — COMMENT onlyChecklist
Verdict (would): would approve Findings
No blocking issues. Note for |
A 403 that classify() names by reason only gets no wait time, so the Requester ran the full backoff ladder before it gave up. It now throws RateLimitError after one call, with retryAt and waitMs left unknown. Removes the post-ladder isRateLimited flag, which nothing reaches now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A provider that answers every call with a short Retry-After kept the Requester in a loop that only the time cap could end, and each hinted retry spent a ladder attempt, so a later 5xx got no retry. A request now makes at most 10 hinted retries, and they leave the ladder alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maxInProcessWaitMs was a key of the module's static rateLimit, but the
caller knows how long it can wait: an HTTP handler 10 s, a queue worker
60 s, a script 300 s. The invocation store now carries it next to the
deadline. runWithInvocationDeadline takes { deadlineAt, maxInProcessWaitMs },
createHandler takes maxInProcessWaitMs, and the module policy no longer
reads it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
An organization admin can add extra usage credits at claude.ai/admin-settings/usage.
Once credits are available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
|
Addressed in 3 commits:
Each fix has a repro test that fails on the old head. Not in this round (non-blocking): admin/db-migration deadlines, reserve of 0, The RATE_LIMITED event + default handler (your §3/§5 on #655) still needs the ADR update and a rework of #657/#658. #657/#658 already build on this, pushing next. Can you have another look? |
#656 now throws RateLimitError at once for a non-429 limit that classify() names with no time, with retryAt and waitMs undefined. The Worker must not send, schedule or extend visibility for it. The old test used a plain error with isRateLimited; this adds the real error shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#656 moved maxInProcessWaitMs from the module's static rateLimit to the invocation store and removed DEFAULT_MAX_IN_PROCESS_WAIT_MS. After the merge the cap read undefined, so _notifyRateLimited never notified. It now compares the wait with invocationMaxInProcessWaitMs(): 10 s in an HTTP handler, 60 s in the queue worker, 300 s outside an invocation. The tests set the cap through runWithInvocationDeadline({ maxInProcessWaitMs }) and pass the deadline as { deadlineAt }. A non-429 limit with no time now throws RateLimitError at once and still sends no notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 1 alone sent a rate-limited 403 back to SQS. With the generated queue config (3 receives about 30 min apart) a daily limit then reached the DLQ after about an hour, so the work was still lost. The halt exemption moves to the deferral change (#657), which puts the message back at its retry time. Phase 1 keeps the halt rule of next. The reference doc now also says that a 429 reads all default parsers, not only Retry-After. Refs ADR-049 §7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scope function that was async, or that returned an object, put the same "[object Promise]" or "[object Object]" key on every RateLimitError. Unrelated entities then shared one scope key. Such a value now gives no scope key, like a function that returns nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A module that drives another client builds the error from that client's error and has no fetch response. statusCode was then undefined, though the cause had it. It now falls back to cause.statusCode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…it-defer #656 now keeps halting a rate-limited 4xx (ADR-049 §7). This branch puts the message back at its retry time, so it keeps the halt exemption and its test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



The Requester now waits as long as the provider says when it throttles us, and an API module can declare its provider's limits. A wait that is too long to sleep in process throws a typed
RateLimitErrorwithretryAt. This is phase 1 of ADR-049 (#655).What problem this fixes
Retry-Aftersays. A provider that says "call again in 2 s" still gets the 180 s step. A provider that says "call again in 10 minutes" gets six attempts and then a thrownFetchErrorwith no time on it.What this PR does
Retry-Afteron a 429, for every module. Delta seconds, an HTTP-date and an ISO timestamp.static rateLimit. Every key is optional:classify({ status, headers, body })recognises a limit that is not a plain 429 (a 403 limit code, apolicyNamein the body) and may name only areason;parserspicks the header parsers (retryAfter,resetHeaders,ietf; default all three);minRetryAfterMsis a floor for the wait ("wait 60 seconds" in the docs, nothing in the response);maxInProcessWaitMs(default 5 minutes) andscope.RateLimitError.RateLimitError extends FetchError. It keepsstatusCodeand addsisRateLimited,retryAt,waitMs,reason,policy,source,moduleandscopeKey.isRateLimited, so a 403 limit goes back to SQS instead of being discarded.classifyRateLimitfor a module whose client is not the Requester (for example one built on a vendor SDK).How a throttled response is read
classify(). A status other than 429 counts only whenclassifyreturns a hint for it. A hint names a knownreason, awaitMsor aretryAt; any other object is not a hint, and a returned Promise is logged as aclassifyerror and ignored.minRetryAfterMsand theperMsof the window named by thereason.With a hint, the wait is the largest of the hint,
minRetryAfterMsand 1 s, plus at most 10 % jitter. Jitter only adds and never passes the budget.Compatibility
FetchError. The ladder is not budgeted (the provider's default HTTP Lambda timeout is 29 s, so a budget would have made a bare 429 throw on the first attempt). A test replays it inside a 29 s invocation.Retry-Afteris read on a 429 and on a response thatclassifyrecognises. A 503 thatclassifydoes not recognise keeps the 5xx ladder,Retry-Afteror not.backOff: []still means no retries. The Requester then never sleeps for a hint either: a hint throwsRateLimitErrorat once.<logger>.rate_limited). The Requester writes no record for the error it throws; the boundary logs it once, and the logger now addsretryAtandreasonto a rate-limited error.classifygets the body parsed as JSON whatever its media type (text/plainandapplication/x-amz-json-1.1too); a body that is not JSON arrives asundefined.classifyis not called for a 2xx. A provider that answers 200 with a "throttled" body wraps its call withclassifyRateLimitand throwsRateLimitErroritself.AsyncLocalStorage, not in the logs store: the telemetry bus forwards every non-logkey of that store. Outside a Lambda (tests,frigg start, scripts) there is no deadline and the cap alone applies.FetchError.createnow builds the class it is called on and uses aresponseBodyit is given instead of reading the stream a second time.options.bodystays a fallback after the stream, as before.Not in this PR
packages/schemasstill describes an oldrateLimit: { requests, period }shape that the runtime does not read. The reference doc says so; the schema needs its own change.RateLimitErrorstill goes back to SQS as any retryable error does.RATE_LIMITEDmessage, pacing, and the policies of the HubSpot, Salesforce and Pipedrive modules.Test plan
npm testinpackages/core— 2,720 pass; the 63 failures (DocumentDB, Prisma decryption, Mongo memory server) are the same 63 that fail onorigin/nextin this environmentRateLimitError, invocation deadline, public API[1, 3, 10, 30, 60, 180] sand 7 calls, also under a 29 s deadline; a 2 sRetry-Aftersleeps inside a 29 s invocation;Retry-Afterwins over a ladder step; the cumulative cap; the deadline budget;classifyfor a 403, an asyncclassify, JSON under any media type;backOff: []; the body is read onceisRateLimitedgoes back to SQStsc --strictagainst a consumer, including two@ts-expect-errornegativesQueueWorkerfromcreateQueueWorker, and an integration whose API module calls a local fake provider): a 429 withRetry-After: 1sleeps in process and the call then succeeds; a 403 limit body sent astext/plainis classified and throwsRateLimitErrorRelease notes
@friggframework/coreand redeploys. A module that declares nothing keeps its behaviour, except that a 429 withRetry-Afternow waits as long as the header says.@friggframework/corebefore an API module usesstatic rateLimitorRateLimitError.🤖 Generated with Claude Code
📦 Published PR as canary version:
2.0.0--canary.656.602fc50.0✨ Test out this PR locally via:
npm install @friggframework/admin-scripts@2.0.0--canary.656.602fc50.0 npm install @friggframework/core@2.0.0--canary.656.602fc50.0 npm install @friggframework/devtools@2.0.0--canary.656.602fc50.0 npm install @friggframework/eslint-config@2.0.0--canary.656.602fc50.0 npm install @friggframework/prettier-config@2.0.0--canary.656.602fc50.0 npm install @friggframework/schemas@2.0.0--canary.656.602fc50.0 npm install @friggframework/serverless-plugin@2.0.0--canary.656.602fc50.0 npm install @friggframework/test@2.0.0--canary.656.602fc50.0 npm install @friggframework/ui@2.0.0--canary.656.602fc50.0 # or yarn add @friggframework/admin-scripts@2.0.0--canary.656.602fc50.0 yarn add @friggframework/core@2.0.0--canary.656.602fc50.0 yarn add @friggframework/devtools@2.0.0--canary.656.602fc50.0 yarn add @friggframework/eslint-config@2.0.0--canary.656.602fc50.0 yarn add @friggframework/prettier-config@2.0.0--canary.656.602fc50.0 yarn add @friggframework/schemas@2.0.0--canary.656.602fc50.0 yarn add @friggframework/serverless-plugin@2.0.0--canary.656.602fc50.0 yarn add @friggframework/test@2.0.0--canary.656.602fc50.0 yarn add @friggframework/ui@2.0.0--canary.656.602fc50.0