Skip to content

fix(sdk): honor Retry-After on 429 before falling back - #1158

Draft
aryansk wants to merge 2 commits into
superagent-ai:mainfrom
aryansk:fix/1145-retry-after-429
Draft

fix(sdk): honor Retry-After on 429 before falling back#1158
aryansk wants to merge 2 commits into
superagent-ai:mainfrom
aryansk:fix/1145-retry-after-429

Conversation

@aryansk

@aryansk aryansk commented Aug 15, 2026

Copy link
Copy Markdown

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-declared Retry-After is a WAIT signal, not an "abandon this model" signal.

Fix

On a 429, parse the Retry-After header as delay-seconds:

  • Present, valid, and below the threshold — sleep the declared duration, then retry the primary model once (never an instant fallback burn).
  • Missing, malformed, or above the threshold — preserve the existing fallback behavior unchanged.
  • One retry max per call chain — a second 429 during the retry goes straight to the normal fallback path (no blocking retry loop).

The threshold is configurable via the new FallbackOptions.retryAfterThresholdSeconds option (default 15s).

Tests

Three new cases in model-fallback.test.ts:

  • 429 with a short Retry-After → waits, retries the primary (2 calls to the primary, no fallback)
  • 429 with Retry-After above the threshold → falls back immediately
  • 429 with no Retry-After → falls back immediately (existing behavior locked in)

All 165 SDK tests pass; tsc build 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
callProvider no longer treats every 429 as an instant trigger to the fallback model. When the primary returns 429 with a valid Retry-After shorter than a threshold (default 15s, via new FallbackOptions.retryAfterThresholdSeconds), the SDK sleeps that duration and retries the primary once instead of immediately hitting the fallback on the same burst.

If Retry-After is missing, invalid, or above the threshold—or a retry already happened in that call chain—behavior stays the same: fall back to fallbackModelString for retryable errors (429, 500, 502, 503).

Implementation adds callProviderInternal with a retriedPrimaryAfterRetryAfter flag and maybeRetryPrimaryAfterRetryAfter, wired on both the superagent timeout path and the standard fetch path. model-fallback.test.ts adds cases for short wait + primary retry, long Retry-After, and missing header.

Reviewed by Cursor Bugbot for commit 7abaee4. Configure here.

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>
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

@aryansk is attempting to deploy a commit to the Superagent Team on Vercel.

A member of the Team first needs to authorize it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread sdk/typescript/src/providers/index.ts Outdated
retriedPrimaryAfterRetryAfter,
});
if (retried) {
return retried;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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.
@aryansk

aryansk commented Aug 15, 2026

Copy link
Copy Markdown
Author

Addressed both findings from the automated review (commit 71b6b19):

  1. Retry errors hit cold-start catch (medium): response handling now lives outside the timeout try/catch — once a response has arrived, the cold-start timeout concern is over, so a rejection from the Retry-After retry chain (or a fallback-model call) can no longer be misrouted to the always-on URL by the abort/timeout message heuristic. Real provider errors propagate to the caller. Regression test: a retried primary returning a non-retryable 400 whose body mentions "timeout" must reject with the 400 (not be swapped for a fallback response); the test fails on the previous layout.

  2. Threshold option not publicly wired (low): retryAfterThresholdSeconds is now passed from ClientConfig through SafetyClient.fallbackOptions exactly like enableFallback/fallbackTimeoutMs/fallbackUrl, and added to the SDK docs table + example. Test asserts createClient({ retryAfterThresholdSeconds: 30 }) surfaces the value on the client.

Validation: full SDK suite 167/167 pass, tsc --noEmit clean, no merge conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

providers/index.ts — 429 triggers instant fallback with no Retry-After consulted

1 participant