Repository navigation
Conversation
The rate-limit block pins Math.random to 0 in beforeEach so jitter is zero. source-map-support parses a file's source map on the first stack read, and the sorter in source-map 0.6 picks its pivot with Math.random. With a constant 0 the pivot is always the first element, so on sorted input the recursion is as deep as the file has mappings. The first stack read in this file is jest's toThrow in "fails at construction for a parser that does not exist". Any growth of requester.js (21 lines are enough) or of this file (about 160 lines) pushes that parse past the call stack limit and the test fails with "RangeError: Maximum call stack size exceeded", far from its cause. A beforeAll now reads one stack from the same path (test file, Requester constructor, policy reader) while Math.random is still real, so the parse is cached before the mock exists. Padding requester.js by 400 lines keeps the file green with this in place. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Requester throws a RateLimitError when a wait does not fit the in-process budget, and nothing above it hears about it. The end user sees an integration that silently stops syncing. The Requester now declares RATE_LIMITED next to INVALID_AUTH and notifies its delegate with the error, once, right before it throws. The Module and IntegrationBase turn that into a message in later commits. The notification is best effort. A failed one writes a WARN (rate_limit_notify_failed with statusCode and error) and the caller still gets the RateLimitError, because the throw is what makes the queue worker defer the message. It is awaited, so a Lambda that returns right after the throw has written the message already. Waits that fit, bare 429s on the ladder, 5xx and errors that classify does not recognise never notify. The tests live in a sibling file, like requester.logger.test.js, and do not mock Math.random. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Requester now notifies its delegate when a rate-limit wait is too long, and the Module is that delegate. It passes the notification up as RATE_LIMITED with the reason, the reset time, the policy name, the status code and the links the module declares for that reason in `static rateLimit.userHints`. Without hints the links are empty. The payload has no message, url, body or headers of the error: a FetchError message can echo request data and the integration shows this to end users. Best effort, like markCredentialsInvalid. A failing parent delegate writes one ERROR (rate_limited_propagation_failed) and never replaces the RateLimitError that the caller receives. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Postgres and Mongo repositories read the stored messages from `integration.messages`. The Integration model has no such field: the messages live in the errors, warnings, info and logs columns. The read was always empty, so every updateIntegrationMessages call replaced the column with a one-item array. Only the last message of each type survived. The repositories now read `integration[messageType]`, the column they write. Two consecutive calls leave two items, items stored before stay, and the other columns are not touched. The DocumentDB adapter builds the list from its stored copies and appended correctly already; its tests are added so it stays that way. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Messages had a fixed shape: title, message and timestamp. A message that a client acts on needs more, for example a code and a list of actions. The use case and the three repositories now accept one item object as well as the positional form (title, body, timestamp), and they store the keys of the item as they are. The positional form is unchanged, so every existing caller works without a change. The normalisation lives in one shared helper, like config-patch-shared, so the Postgres, Mongo and DocumentDB adapters cannot drift. DocumentDB still writes a null title when there is none. The in-memory test repository uses the same helper. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A caller that must know what an integration already shows to its users, for example to write a message only once, cannot ask for it. The record from findIntegrationById has a `messages` field, but the Postgres and Mongo adapters map it from a column that does not exist, so it is always undefined there, and the in-memory copy on IntegrationBase is per instance. findIntegrationMessages(integrationId, messageType) reads the stored items of one type from the same column updateIntegrationMessages writes. It selects only that column on Postgres and Mongo. The record mapping is not changed: the existing tests pin it, and changing it would change what clients receive. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
When a module reports a rate limit that is too long to wait for, the integration needs one message for its users. RecordRateLimitMessage writes a warning with a fixed title and text, the reset time in UTC rounded up to the minute, and the code, module, reason, retryAt and actions a client acts on: RETRY_WHEN_READY plus one LINK per link the module declares. The text is fixed and only known payload fields are read. A FetchError message can echo the request, and this message reaches end users. Many workers hit one limit together and each reports it, so a warning for the same module whose reset time is within 60 seconds is not written again. The check reads the stored warnings through findIntegrationMessages: the in-memory messages of an instance are empty on Postgres and differ between workers. The check and the write are two steps, so workers that report at the same instant can still write a few duplicates. It never changes the integration status and lets read and write failures reach the caller, which decides what a lost warning is worth. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…-message Takes the review fixes of the Requester (7302a64). The throw of the RateLimitError moved into _throttleRetry, so the RATE_LIMITED notification is sent from there, after the error is built and before it is thrown. A limit that classify names with no time now ends as a FetchError flagged isRateLimited, not as a RateLimitError, and does not notify; a test covers that. The Requester tests no longer replace Math.random, so the source-map warm-up added earlier on this branch has no cause left and is dropped: requester.test.js is the upstream file. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
IntegrationBase now handles RATE_LIMITED, the notification that travels from the Requester through the Module. It writes one WARN (rate_limited with the module, the reason and the reset time) and records the user-facing message with RecordRateLimitMessage. The integration status does not change. A rate limit is not an ERROR: the provider asked us to wait, the credentials are fine, and an ERROR status makes the queue worker drop every webhook until someone re-authorizes. Recording is best effort, like the credential rejection. A failed read or write logs one ERROR (rate_limit_message_record_failed) and returns, so the Requester still throws the RateLimitError that lets the worker defer the message. An end-to-end test drives a real Requester, Module and IntegrationBase over an in-memory repository: one warning per limit, with the links of the module and nothing from the request. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Declares DLGT_RATE_LIMITED on the Requester, and an IntegrationMessage type for the items in the stored messages, with the fields a rate-limit warning adds (code, module, reason, retryAt, actions). The declared type of Integration.messages was a list of empty tuples, which no item could satisfy. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… error A module that drives another client, as jsforce does, throws RateLimitError itself, and the Requester notifies only for the errors it throws. Such a module calls _notifyRateLimited before its own throw. The tests pin that call: it reaches the delegate with the error, and it resolves with one WARN when the notification fails. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Describes the RATE_LIMITED path (Requester, Module, IntegrationBase), the stored warning with its code, retryAt and actions, the one-per-limit rule, and that the status does not change. userHints feeds the LINK actions. The example of a module that throws RateLimitError itself now calls _notifyRateLimited, because the Requester notifies only for its own throws. The logging guide lists the four new records. CLAUDE.md also records that the messages of the record from findIntegrationById are not filled on Postgres and Mongo, so findIntegrationMessages is the read for stored messages. Refs ADR-049. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The commit that added the RATE_LIMITED branch also added a return to the end of the CREDENTIAL_VALIDATED branch. The branches test different strings, so the return changes nothing, and it touched a line that this work does not need to touch. Refs ADR-049. Co-Authored-By: Claude Sonnet 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, reopen this pull request to trigger a review.
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: a5177aa26a
ℹ️ 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".
| /** Notified with the `RateLimitError` right before a wait that is too long throws. */ | ||
| DLGT_RATE_LIMITED: string; |
There was a problem hiding this comment.
Declare the documented rate-limit notifier
TypeScript API modules following the new example in docs/reference/api-module-definition-and-functions.md cannot call this._notifyRateLimited(error): the runtime Requester implements the method, but neither the exported Requester declaration nor IFriggRequester declares it, so consumers receive TS2339. Add a _notifyRateLimited(rateLimitError: RateLimitError): Promise<void> signature to both public types.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 93c8e07. _notifyRateLimited(rateLimitError: RateLimitError): Promise<void> is now declared on Requester and on IFriggRequester. Checked with a tsc --strict consumer that subclasses Requester and calls it: TS2339 before, clean after.
A TypeScript module that follows the documented pattern calls `this._notifyRateLimited(error)` before it throws its own RateLimitError. Neither `Requester` nor `IFriggRequester` declared the method, so the call failed with TS2339. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
makeRequester now passes random: () => 0, as requester.test.js does, so the jitter of a slept wait no longer reads Math.random. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removes the explanatory comment lines this change added to IntegrationBase, Module, the Requester, RecordRateLimitMessage, the message item helper and both type declarations. The pre-existing receiveNotification text is back as it was. The rationale lives in the ADR-049 docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess cap The owner decided that the rate-limit warning is for a limit the Requester cannot sleep through at all. _notifyRateLimited now returns without a notification when the wait of the error (waitMs, else retryAt minus now) is at most maxInProcessWaitMs of the requester's policy (default 5 minutes). Both paths go through it: the Requester's own throw, and a module that throws its own RateLimitError. A 30 s concurrency limit, or a short wait that only misses the invocation deadline, now writes no warning; an hour-long daily limit still does. The docs say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Module.reportRateLimit read static rateLimit.userHints directly, so it used hints the Requester's own policy ignores (a rateLimit or userHints that is not a plain object). It now reads the links from readRateLimitPolicy(apiClass), the validated and memoized policy the Requester uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ropagation_failed The request still throws its RateLimitError when the Module cannot pass the report on, so the failure is handled and the system continues: WARN, not ERROR. The event name now follows <logger>.rate_limit_<step>_failed, like the Requester's rate_limit_notify_failed. LOGGING.md lists the new name and level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
utcMinuteAfter returned a string, so it is formatResetTime now. RATE_LIMITED in record-rate-limit-message.js is the message code, not the delegate event, so it is RATE_LIMITED_MESSAGE_CODE. isItem checked for a non-array object, the check integration-mapping-query.js already calls isPlainObject. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Postgres, MongoDB and DocumentDB each spelled out Array.isArray(record[type]) ? record[type] : [] twice, once to append and once to find. messagesOfType in message-item-shared.js is now that one check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Postgres and MongoDB message tests carried the same copy of a Prisma double that keeps one integration row. withMessageRow in tests/doubles is now that double; each test file only names its repository and id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Repositories are pure data access (packages/core/CLAUDE.md, golden rule 3), yet the three of them and the test double each decided between the positional form and an item. UpdateIntegrationMessages.execute now builds the one item and still accepts both forms from its callers. updateIntegrationMessages on the repository takes (integrationId, messageType, item) only. RecordRateLimitMessage already passes an item. toMessageItem moved from message-item-shared.js into the use case, its only caller; its tests moved to the use case tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Messages now append, so testAuth (one error per failing module on every
GET /test-auth), validateConfig (one warning per missing key on every
onUpdate) and the rate-limit warnings would grow the stored arrays without a
bound on Postgres and MongoDB. DocumentDB already appended without one.
The use case sets the limit: MAX_MESSAGES_PER_TYPE = 50 in
update-integration-messages.js, which RecordRateLimitMessage also uses. The
repository method takes it as updateIntegrationMessages(integrationId,
messageType, item, { keepLast }) and drops the oldest items of that type
after the append: a slice in the read-modify-write of Postgres, MongoDB,
DocumentDB (both stored copies) and the test double.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No production code read the boolean the use case returned. It now resolves to undefined, and its tests assert on the writes the repository received. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reset time A RATE_LIMITED payload without a module name stored "The undefined API rate limit..."; one without a valid retryAt threw a RangeError after the database read. RecordRateLimitMessage now returns before any read or write when moduleName is not a non-empty string or retryAt is not a valid time, and also when there is no payload. A Date, an ISO string and epoch milliseconds still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Module always sets moduleName in the payload (definition.moduleName is required), so the object?.moduleName ?? notifier?.name fallback in IntegrationBase.receiveNotification never ran for a Module. A payload without a module name now reaches RecordRateLimitMessage unchanged, which writes nothing for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use cases in core log: DeleteIntegrationForUser writes WARN records through
a module-level getLogger('frigg.integrations'). RecordRateLimitMessage now
does the same. A failed read or write of the warning, or a missing
integration, writes one WARN (frigg.integrations.rate_limit_message_record_failed,
with integrationId, moduleName and error) and resolves, as the plan asks: "a
read failure logs and returns". The request still throws its RateLimitError.
The failure is handled and the system continues, so the record is a WARN,
not the ERROR IntegrationBase wrote. IntegrationBase.receiveNotification now
calls the use case directly; its _recordRateLimitMessage wrapper is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… guide The item form of updateIntegrationMessages, findIntegrationMessages and the limit of the newest 50 messages per type move from packages/core/CLAUDE.md into a short "Integration Messages" section of INTEGRATION-PATTERNS.md, next to the rate-limit text; CLAUDE.md keeps a one-line pointer. The rate-limit docs now say that the stored warnings keep their newest 50 items. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws its own error The API module reference now says that this._notifyRateLimited(error) never rejects, that it writes the warning only for a wait longer than maxInProcessWaitMs, and that a module which must also run on an older core calls this._notifyRateLimited?.(error). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # docs/reference/api-module-definition-and-functions.md
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.
|



When a provider's rate limit is longer than the in-process cap (5 minutes by default), the integration now records one warning for the user, with the time the limit resets and the provider's links. This is phase 4 of ADR-049 (#655).
Stacked on #656. Merge #656 first. This PR builds on
RateLimitErrorfrom it, and GitHub retargets this PR tonextwhen #656 merges. It does not depend on #657.What problem this fixes
integration.messages, a field the schema does not have, so everyupdateIntegrationMessagescall replaced the array with a one-item array instead of appending. This is fixed here because the warning needs it.What this PR does
A
RATE_LIMITEDdelegate event, on the same path asCREDENTIAL_INVALIDATED: Requester → Module → IntegrationBase. The Requester sends it when it throws aRateLimitErrorwhose wait is longer than the in-process cap (maxInProcessWaitMs, 5 minutes by default). It does not send it for a wait that it slept through, for a shorter wait that throws only because the invocation ends soon, or for a limit with no time.One warning on the integration, in the stored
warnings:Client-safe text. The message is fixed text plus the reset time. The error's message, URL, body and headers never reach it: a
FetchErrormessage can echo request data, and this reaches end users.No status change. A rate limit is not an error, so the integration keeps its status.
One warning per limit, not one per worker. Twenty workers can meet the same limit at once. A warning for the same module with a
retryAtwithin 60 s of the last one is skipped. The check reads the storedwarnings(a newfindIntegrationMessageson the repository), notIntegrationBase.messages, which is per instance.updateIntegrationMessagestakes a message item object as well as the positional form (title, body, timestamp). The use case builds the item; the repositories only store it. Existing callers are unchanged.Each message type keeps its newest 50 items. Messages now append, and
testAuthandvalidateConfigwrite one on every call, so the use case sets the limit and the repository removes the oldest items.A payload without a module name or a valid
retryAtis skipped, and a failed write logs one WARNfrigg.integrations.rate_limit_message_record_failed. A failed propagation in the Module logs WARNrate_limit_propagation_failed.A module that throws its own
RateLimitError(one built on a vendor SDK, not on the Requester) callsawait this._notifyRateLimited(error)first, so its users get the warning too.Known gap: the warning does not show on Postgres and MongoDB yet
The warning is saved, but the read methods of the Postgres and MongoDB repositories map
messagesfrom the same field that does not exist. Themessagesof a hydrated integration and of the integration DTO are therefore empty there. This is true of every message today, the existing authentication error too. It is not new, and this PR does not change it: mapping the four columns intomessageschanges the DTO for every app, and deserves its own small PR. DocumentDB keeps a realmessagesfield and is not affected. Until that change lands, the UI signal for a wait isProcess.context.rateLimitfrom #657.Caveats
updateIntegrationMessagesis itself read-modify-write. Workers that report in the same instant can write a few copies, and a concurrent append can be lost.keepLastoption. Only the two use cases call it; a direct caller without the option gets a TypeError.Not in this PR
Pacing, the module policies (api-module-library), and the fix for the read path above.
Test plan
npm testinpackages/core— 2,844 pass (with feat(core): honour Retry-After and let API modules declare rate limits #656 merged in); the 63 failures (DocumentDB, Prisma decryption, Mongo memory server) are the same 63 that fail onorigin/nextin this environmentRecordRateLimitMessage, theIntegrationBasereceive and record paths,message-item-shared, and the message tests of the three repositories (append, object item,findIntegrationMessages)tsc;Integration.messageswas declared as empty tuples and is nowIntegrationMessage[]QueueWorkerfromcreateQueueWorker, and an integration whose API module calls a local fake provider): a daily 403 writes oneRATE_LIMITEDwarning with the reset time and the provider link, and none of the provider's text; a second limit within 60 s is deduped; a 3 s wait writes no warning; 55 appends throughIntegrationBasekeep the newest 50no-unused-varson the parameters of the new abstractfindIntegrationMessages, like the 22 stubs around itbuild (22.x)fails on feat(core): add queryMappings to IntegrationMappingRepository #651, feat(core): tell queue handlers their last delivery attempt #653 and feat(core): structured logging (ADR-048) #654 too (frigg-cli, devtools infrastructure and management-ui suites, and a missinglibcrypto.so.1.1for the Mongo memory server), so it does not gate this PRRelease notes
@friggframework/core. No redeploy step beyond that.RateLimitErrorshould callawait this._notifyRateLimited(error)before it throws. The Salesforce policy PR in api-module-library does.🤖 Generated with Claude Code
📦 Published PR as canary version:
2.0.0--canary.658.164d92b.0✨ Test out this PR locally via:
npm install @friggframework/admin-scripts@2.0.0--canary.658.164d92b.0 npm install @friggframework/core@2.0.0--canary.658.164d92b.0 npm install @friggframework/devtools@2.0.0--canary.658.164d92b.0 npm install @friggframework/eslint-config@2.0.0--canary.658.164d92b.0 npm install @friggframework/prettier-config@2.0.0--canary.658.164d92b.0 npm install @friggframework/schemas@2.0.0--canary.658.164d92b.0 npm install @friggframework/serverless-plugin@2.0.0--canary.658.164d92b.0 npm install @friggframework/test@2.0.0--canary.658.164d92b.0 npm install @friggframework/ui@2.0.0--canary.658.164d92b.0 # or yarn add @friggframework/admin-scripts@2.0.0--canary.658.164d92b.0 yarn add @friggframework/core@2.0.0--canary.658.164d92b.0 yarn add @friggframework/devtools@2.0.0--canary.658.164d92b.0 yarn add @friggframework/eslint-config@2.0.0--canary.658.164d92b.0 yarn add @friggframework/prettier-config@2.0.0--canary.658.164d92b.0 yarn add @friggframework/schemas@2.0.0--canary.658.164d92b.0 yarn add @friggframework/serverless-plugin@2.0.0--canary.658.164d92b.0 yarn add @friggframework/test@2.0.0--canary.658.164d92b.0 yarn add @friggframework/ui@2.0.0--canary.658.164d92b.0