feat(typesafe): add jittered exponential backoff retries for transient transport errors - #4591
AseemPrasad wants to merge 1 commit into
Conversation
…t transport errors
|
AseemPrasad is attempting to deploy a commit to the Composio Team on Vercel. A member of the Team first needs to authorize it. |
|
| const jitter = 0.8 + 0.4 * Math.random(); | ||
| const delayMs = Math.round(baseDelay * jitter); | ||
|
|
||
| await new Promise(resolve => setTimeout(resolve, delayMs)); |
There was a problem hiding this comment.
If the signal is aborted while this backoff timer is pending, the timer continues for the entire jittered delay before the loop observes the abort. With a configured backoff at the cap, cancellation can therefore be delayed by as much as six seconds, contradicting the request's immediate cancellation behavior. Make the delay abort-aware and clear its timer when the signal fires.
| const isRetryable = | ||
| providerError.reason === 'rate_limit' || | ||
| providerError.reason === 'server_error' || | ||
| providerError.reason === 'connection' || | ||
| providerError.reason === 'timeout' || | ||
| (providerError.status !== undefined && retryStatusCodes.has(providerError.status)); |
There was a problem hiding this comment.
retryStatusCodes cannot disable any default status: 429 is always retried through rate_limit, while every 5xx response is always retried through server_error, independently of membership in the configured set. Consequently, callers passing [] or removing a default code still receive retries despite the public option describing the configured codes as those that trigger retries.
| return { ...validateAnswers(data, questions, requestId), requestId }; | ||
| } catch (error) { | ||
| onRequest?.(undefined); | ||
| const providerError = toProviderError(error); |
There was a problem hiding this comment.
Malformed responses count twice
A malformed successful response now invokes onRequest twice: once with its request ID before validation and again with undefined when validateAnswers throws inside this catch. In fan-out paths where another request succeeds, the returned metadata consequently counts one malformed request twice, making meta.requestCount inaccurate.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 97c9a17. Configure here.
| const jitter = 0.8 + 0.4 * Math.random(); | ||
| const delayMs = Math.round(baseDelay * jitter); | ||
|
|
||
| await new Promise(resolve => setTimeout(resolve, delayMs)); |
There was a problem hiding this comment.
Retries ignore server retry delay
Medium Severity
toProviderError already preserves retryAfterMs on rate_limit errors, but the new retry loop always waits the local exponential backoff instead. When the API asks for a longer delay, the extra attempts land inside the same window, exhaust maxRetries, and still fail, so 429s this change is meant to absorb can surface as hard errors.
Reviewed by Cursor Bugbot for commit 97c9a17. Configure here.
| const jitter = 0.8 + 0.4 * Math.random(); | ||
| const delayMs = Math.round(baseDelay * jitter); | ||
|
|
||
| await new Promise(resolve => setTimeout(resolve, delayMs)); |
There was a problem hiding this comment.
Backoff delay ignores abort signal
Medium Severity
After a retryable failure the wait uses a raw setTimeout and does not listen to requestOptions.signal. An abort during that wait stays pending until the timer fires, so cancellation is delayed by up to several seconds once backoff reaches the 5s cap.
Reviewed by Cursor Bugbot for commit 97c9a17. Configure here.
|
Alberto Schiabel (@jkomyno) great to have the previous prs reviewed and incorporated.. |


Summary
Adds configurable exponential backoff retries with full jitter to @composio/providers/typesafe transport requests (createAsk). Automatically recovers from transient HTTP rate-limiting (429), gateway timeouts, and server errors (500, 502, 503, 504) without throwing premature errors during agent runs.
Key Changes
Type Definitions (ts/packages/providers/typesafe/src/types.ts):$$\text{delay} = \min(\text{backoffMs} \times 2^{\text{attempt}}, 5000) \times (0.8 + 0.4 \times \text{Math.random()})$$
Added optional maxRetries?: number (Default: 3).
Added optional backoffMs?: number (Default: 200).
Added optional retryStatusCodes?: number[] (Default: [429, 500, 502, 503, 504]).
Transport Execution Loop (ts/packages/providers/typesafe/src/decide.ts):
Implemented exponential backoff with randomized jitter in createAsk:
Distinguishes retryable errors (rate_limit, server_error, connection, timeout, or status in retryStatusCodes) from unretryable failures (aborted, authentication errors, 400 Bad Requests).
Short-circuits immediately if requestOptions.signal.aborted is triggered.
Unit Test Suite (ts/packages/providers/typesafe/test/transport.test.ts):
Added test case verifying recovery after transient 429 rate limit.
Added test case verifying retry exhaustion when 503 persistence failures occur.