Repository navigation
feat(server): retry transient Anthropic failures with jittered backoff inside the request budget - #15
Open
brainsparker wants to merge 1 commit into
Open
brainsparker wants to merge 1 commit into
brainsparker wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
The hosted backend calls Anthropic with a bare
fetchand no retry. Today a529 overloaded_error, a5xxblip, 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.529,500/502/503/504,408/409, thrown fetches, and a429that carries a shortRetry-After. Two retries by default.Retry-Afterheader overrides the computed delay.400,401,403,404,413,422, and a429withoutRetry-After(a spend or acceleration limit on our org, where retrying only adds load).503 {"error": "upstream_overloaded"}withRetry-After: 5instead of forwarding a bare529./v1/messagesresponse carriesX-SuperPaste-Upstream-Attempts, and each retry logs the attempt, status, delay, and Anthropicrequest-id.Config (
server/wrangler.toml, no code change needed):UPSTREAM_MAX_RETRIES(default"2","0"restores single-attempt behaviour) andUPSTREAM_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_overloadedto a friendlier message than the generic server-error string.Why this, why today
The failure is real and recurring. Anthropic documents
529 overloaded_erroras 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, honourRetry-After, never retry400class errors, and treat a429withoutRetry-Afteras 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(including529) 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:
RateLimit-Policy/RateLimitheaders per IETF draft-ietf-httpapi-ratelimit-headers-11 plus aGET /v1/usageendpoint 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..well-known/ai-catalog.jsonnow 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
server/src/upstream.ts: pureisRetryableResponse,backoffDelayMs,parseRetryAfterMs(delay-seconds and HTTP-date),retryPolicyFromEnv, andfetchWithRetrywith injectablefetch,sleep,random,now, andonRetryso the loop is fully testable without timers. It never throws: a final thrown attempt is reported as{ response: null, error }soindex.tskeeps its existing 502 mapping.index.tschanges are confined to the upstream call site, thejson()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.tomlgains 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 503upstream_overloadedwithRetry-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=0restores single-attempt behaviour.tsc -p tsconfig.jsonandtsc -p tsconfig.tests.jsonare clean.node --testthrough a local vitest API shim, and TypeScript 5.9.3 fetched from unpkg with integrity checks. Nothing from that setup is committed.npm testandnpm run typecheckshould behave identically in CI; one pre-existing test (helpers.test.ts) imports../src/indexwithout the.tsextension, which vitest resolves and barenode --testdoes not, so it was verified separately with the extension added.No client changes, no new dependencies, no secrets.