fix(sdk): honor Retry-After on 429 before falling back - #1158
Conversation
callProvider() treated a 429 as an instant-fallback trigger, so under concurrent load the same burst that rate-limited the primary also burned the fallback - both models ended up slammed. A 429 with a short provider-declared Retry-After means "wait and retry", not "abandon". On a 429, parse the Retry-After header as delay-seconds. If it is present, valid, and below a configurable threshold (default 15s via FallbackOptions.retryAfterThresholdSeconds), sleep and retry the primary model once before considering fallback. Missing, malformed, or long Retry-After values preserve the existing fallback behavior, and only one retry happens per call chain (no blocking retry loop). Tests cover the short-wait retry, above-threshold fallback, and missing-header fallback paths. Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@aryansk is attempting to deploy a commit to the Superagent Team on Vercel. A member of the Team first needs to authorize it. |
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.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 7abaee4. Configure here.
| retriedPrimaryAfterRetryAfter, | ||
| }); | ||
| if (retried) { | ||
| return retried; |
There was a problem hiding this comment.
Retry errors hit cold-start catch
Medium Severity
await maybeRetryPrimaryAfterRetryAfter(...) sits inside the superagent cold-start try/catch. Rejections from the Retry-After retry chain are caught by the timeout heuristic (abort/timeout substring checks), unlike return callProvider(fallback) which bypasses that catch. Fallback-model failures mentioning those words can be misrouted to the always-on URL, masking the real error—especially likely with the default superagent model plus a fallbackModel.
Reviewed by Cursor Bugbot for commit 7abaee4. Configure here.
| * instead of instantly burning the fallback on the same rate-limit burst. Longer or | ||
| * missing `Retry-After` values preserve the current fallback behavior. Default: 15. | ||
| */ | ||
| retryAfterThresholdSeconds?: number; |
There was a problem hiding this comment.
Threshold option not publicly wired
Low Severity
retryAfterThresholdSeconds was added on FallbackOptions, but ClientConfig and SafetyClient were not updated the way enableFallback, fallbackTimeoutMs, and fallbackUrl are. FallbackOptions and callProvider are not package exports either, so createClient users cannot configure the threshold despite the PR documenting it as configurable.
Reviewed by Cursor Bugbot for commit 7abaee4. Configure here.
Two review findings from the automated review on superagent-ai#1158: 1. The Retry-After retry chain ran inside the cold-start try/catch, so a failure from the retried primary whose message happened to mention "timeout"/"abort" was swallowed by the timeout heuristic and masked by an always-on fallback call. Response handling now lives outside the timeout catch: once a response has arrived, the timeout concern is over, and retry-chain rejections propagate to the caller. 2. retryAfterThresholdSeconds existed only on FallbackOptions; it is now wired through ClientConfig -> SafetyClient.fallbackOptions and documented in the SDK docs, so createClient users can actually configure it.
|
Addressed both findings from the automated review (commit
Validation: full SDK suite 167/167 pass, |


Closes #1145
Problem
callProvider()treats a 429 as an instant-fallback trigger. Under concurrent load, the same burst that rate-limited the primary model immediately hits the fallback — both models end up rate-limited. A 429 with a provider-declaredRetry-Afteris a WAIT signal, not an "abandon this model" signal.Fix
On a 429, parse the
Retry-Afterheader as delay-seconds:The threshold is configurable via the new
FallbackOptions.retryAfterThresholdSecondsoption (default 15s).Tests
Three new cases in
model-fallback.test.ts:Retry-After→ waits, retries the primary (2 calls to the primary, no fallback)Retry-Afterabove the threshold → falls back immediatelyRetry-After→ falls back immediately (existing behavior locked in)All 165 SDK tests pass;
tscbuild clean.Note
Medium Risk
Changes retry/fallback timing for all guard/redact paths that use model fallback; bounded wait (default ≤15s) and one retry limit risk but can add latency under rate limits.
Overview
callProviderno longer treats every 429 as an instant trigger to the fallback model. When the primary returns 429 with a validRetry-Aftershorter than a threshold (default 15s, via newFallbackOptions.retryAfterThresholdSeconds), the SDK sleeps that duration and retries the primary once instead of immediately hitting the fallback on the same burst.If
Retry-Afteris missing, invalid, or above the threshold—or a retry already happened in that call chain—behavior stays the same: fall back tofallbackModelStringfor retryable errors (429, 500, 502, 503).Implementation adds
callProviderInternalwith aretriedPrimaryAfterRetryAfterflag andmaybeRetryPrimaryAfterRetryAfter, wired on both the superagent timeout path and the standard fetch path.model-fallback.test.tsadds cases for short wait + primary retry, longRetry-After, and missing header.Reviewed by Cursor Bugbot for commit 7abaee4. Configure here.