Skip to content

feat(server): retry transient Anthropic failures with jittered backoff inside the request budget - #15

Open
brainsparker wants to merge 1 commit into
mainfrom
feature/2026-11-04-upstream-retry
Open

brainsparker wants to merge 1 commit into
mainfrom
feature/2026-11-04-upstream-retry

Conversation

@brainsparker

Copy link
Copy Markdown
Owner

What this does

The hosted backend calls Anthropic with a bare fetch and no retry. Today a 529 overloaded_error, a 5xx blip, or a dropped connection turns straight into a failed paste, and the Mac app can only show "Server error (529). Try again." Anthropic's own SDKs retry exactly these failures twice by default. The Worker now does the same.

  • Retries 529, 500/502/503/504, 408/409, thrown fetches, and a 429 that carries a short Retry-After. Two retries by default.
  • Equal-jitter backoff (200 to 400 ms, then 400 to 800 ms, capped at 2 s) so recovering Workers do not all hit Anthropic on one beat. A short Retry-After header overrides the computed delay.
  • One deadline covers every attempt: each attempt's timeout is clamped to what is left of the original 60 s budget, and no retry starts with under 5 s remaining. A paste can never wait longer than a single attempt could before this change.
  • Not retried, passed through unchanged on the first attempt: 400, 401, 403, 404, 413, 422, and a 429 without Retry-After (a spend or acceleration limit on our org, where retrying only adds load).
  • When Anthropic is still overloaded after the retries, the Worker answers 503 {"error": "upstream_overloaded"} with Retry-After: 5 instead of forwarding a bare 529.
  • Every /v1/messages response carries X-SuperPaste-Upstream-Attempts, and each retry logs the attempt, status, delay, and Anthropic request-id.
  • One paste still costs exactly one unit of daily quota regardless of attempts. A request where nothing ever reached Anthropic still costs nothing (existing 502 path).
  • Magic Copy rides the same loop with no changes to its guardrails or error shape.

Config (server/wrangler.toml, no code change needed): UPSTREAM_MAX_RETRIES (default "2", "0" restores single-attempt behaviour) and UPSTREAM_RETRY_BASE_MS (default "400"). Bad values keep the defaults, so a typo cannot disable the model call.

No client change is required. Existing app versions see fewer failures immediately on deploy. A future client can map upstream_overloaded to a friendlier message than the generic server-error string.

Why this, why today

The failure is real and recurring. Anthropic documents 529 overloaded_error as high traffic across all users, which usually clears in seconds. August 2026 alone had overload or degradation incidents on at least nine separate days per continuumcode.ai's incident review, and the topic is active enough that OpenRouter (Oct 28), INFRO (Oct 24), and Requesty (Nov 4) all published 529 handling guides in the last two weeks. Their consistent advice: retry once or twice with jittered backoff, honour Retry-After, never retry 400 class errors, and treat a 429 without Retry-After as a spend cap. That is exactly the policy implemented here.

The proxy is below SDK parity. Anthropic's SDKs retry connection errors, 408, 409, 429, and >=500 (including 529) twice by default with backoff (agentnotebook.dev writeup). Because this Worker builds the request by hand, it had zero retries. For a hotkey product where the whole interaction is "press Option+V, text appears," a transient upstream blip is the worst possible failure mode: the user has no context for what went wrong and no cheap way to retry other than pressing again.

Why SuperPaste over the other candidates today. SuperPaste is the most active repo in the org right now (Smart Share links and the local-provider merge landed Oct 29) and has no open PRs, so this lands on a clean queue. The runner-ups I passed on:

  1. SuperPaste quota visibility: RateLimit-Policy / RateLimit headers per IETF draft-ietf-httpapi-ratelimit-headers-11 plus a GET /v1/usage endpoint so the app can show "12 of 15 left today." Strong user signal (metered AI apps hitting limits without warning is a top complaint across ChatGPT, Gemini, and CHAI threads this year), but it is only user-visible once the Swift client reads the headers, and the sandbox has no Swift toolchain to verify that half. Good next server-plus-client PR.
  2. MCP-Profiles Server Card: publish .well-known/ai-catalog.json now that SEP-2127 is Final (Oct 6) and the extension is Beta. Timely, but that repo already has five unreviewed PRs from this agent, so adding a sixth has low marginal value until the queue clears.

Implementation notes

  • New module server/src/upstream.ts: pure isRetryableResponse, backoffDelayMs, parseRetryAfterMs (delay-seconds and HTTP-date), retryPolicyFromEnv, and fetchWithRetry with injectable fetch, sleep, random, now, and onRetry so the loop is fully testable without timers. It never throws: a final thrown attempt is reported as { response: null, error } so index.ts keeps its existing 502 mapping.
  • index.ts changes are confined to the upstream call site, the json() helper (optional extra headers), and the two response paths that gain the attempts header. Discarded retry bodies are cancelled so connections can be reused.
  • wrangler.toml gains the two vars with a comment matching the existing daily-limit style. README gains a "Hosted backend: upstream retry" section.

Tests

  • server/tests/upstream.test.ts (17 tests): Retry-After parsing, the retryable/non-retryable status matrix, 429 conditional retry, equal-jitter math and cap, retry count limits, thrown fetches, deadline enforcement, per-attempt abort signals, onRetry payload, env parsing with bad values.
  • server/tests/retry.test.ts (12 tests): drives the Worker's default export end to end. 529 then 200 is a normal paste; 529 forever is a 503 upstream_overloaded with Retry-After: 5; quota charged once for three attempts; exhausted 529 still charges (matches existing policy); 500 and dropped-connection recovery; all-throws yields the existing 502 with no charge; 400 and 429-without-Retry-After pass through on one attempt; 429 with Retry-After retries; UPSTREAM_MAX_RETRIES=0 restores single-attempt behaviour.
  • Full server suite: 156 tests, all new tests pass, all pre-existing tests pass. tsc -p tsconfig.json and tsc -p tsconfig.tests.json are clean.
  • Sandbox note: the build environment here has no npm registry access, so the suite was run with Node 24's native type stripping and node --test through a local vitest API shim, and TypeScript 5.9.3 fetched from unpkg with integrity checks. Nothing from that setup is committed. npm test and npm run typecheck should behave identically in CI; one pre-existing test (helpers.test.ts) imports ../src/index without the .ts extension, which vitest resolves and bare node --test does not, so it was verified separately with the extension added.

No client changes, no new dependencies, no secrets.

…f inside the request budget

The Worker called Anthropic with a bare fetch, so every 529 overloaded_error,
5xx blip, or dropped connection reached the Mac app as a failed paste.
Anthropic's SDKs retry those twice by default; the proxy now does the same,
bounded by the existing 60s deadline. An exhausted 529 becomes a clean 503
upstream_overloaded with Retry-After. One paste still costs one quota unit.
Tunable via UPSTREAM_MAX_RETRIES and UPSTREAM_RETRY_BASE_MS in wrangler.toml.
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.

1 participant