diff --git a/README.md b/README.md index f0122e0..e06f8e6 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ SHA256(salt + message[4] + message[7] + message[20] + CLI_VERSION).slice(0, 3) The salt is hardcoded in Claude Code's source. This changes per conversation because the user's message text changes. +### Retries and error log + +Rate-limited (429) and overloaded (529) responses are retried up to twice, waiting as long as `Retry-After` asks. A requested wait longer than 10 seconds is not absorbed inside the request: the response goes straight back to OpenCode, whose own retry loop shows the wait and can be cancelled. The OAuth token endpoint is retried the same way. + +Every failed response handed back to OpenCode is written to stderr and appended, with its request id, to `~/.local/share/opencode/log/claude-bridge-api-errors.log` (under `$XDG_DATA_HOME` when set). + ## Requirements - [OpenCode](https://opencode.ai) v1.2+ @@ -228,6 +234,7 @@ src/ constants.ts — Version strings, URLs, env overrides, header values (117 lines) keychain.ts — macOS Keychain access for OAuth tokens oauth.ts — OAuth PKCE flow with local callback server + retry.ts — Retry policy for API responses and the token endpoint scripts/ validate-opencode-oauth.js — Side-by-side wire comparison tool diff --git a/src/index.test.ts b/src/index.test.ts index 4922d07..bc52758 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -9,6 +9,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { translateToolArgsJsonString, @@ -17,7 +20,7 @@ import { AGENT_TYPE_OPENCODE_TO_CLAUDE, shouldUseClaudeToolSchemas, } from "./claude-tools.js"; -import { extractOAuthErrorDetail } from "./oauth.js"; +import { extractOAuthErrorDetail, refreshTokens } from "./oauth.js"; import { selectClaudeCredentials } from "./keychain.js"; import { default as pluginModule, @@ -29,8 +32,16 @@ import { shouldInjectClaudeTools, stripAssistantPrefillForClaude, stripSystemCacheControl, + toSystemBlocks, } from "./index.js"; import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js"; +import { + MAX_RETRY_DELAY_MS, + getResponseRetryDelayMs, + getTokenRetryDelayMs, + parseRetryAfterMs, + waitForRetry, +} from "./retry.js"; // ── Helpers (extracted / reimplemented from index.ts for unit testing) ──────── @@ -1268,3 +1279,258 @@ describe("selectClaudeCredentials", () => { assert.equal(selectClaudeCredentials([null, real]), real); }); }); + +describe("retry policy", () => { + const now = Date.parse("2026-10-04T12:00:00Z"); + + it("reads Retry-After as delay seconds or as an HTTP date", () => { + assert.equal(parseRetryAfterMs("3", now), 3000); + assert.equal(parseRetryAfterMs("Sun, 04 Oct 2026 12:00:05 GMT", now), 5000); + assert.equal(parseRetryAfterMs("Sun, 04 Oct 2026 11:59:00 GMT", now), 0); + assert.equal(parseRetryAfterMs(null, now), null); + assert.equal(parseRetryAfterMs("", now), null); + assert.equal(parseRetryAfterMs("-1", now), null); + assert.equal(parseRetryAfterMs("soon", now), null); + }); + + it("waits as long as Retry-After asks, else backs off", () => { + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": "4" }), 0, now), 4000); + assert.equal(getResponseRetryDelayMs(new Headers(), 0, now), 2000); + assert.equal(getResponseRetryDelayMs(new Headers(), 1, now), 4000); + }); + + it("refuses a wait longer than the bridge absorbs", () => { + const seconds = String(MAX_RETRY_DELAY_MS / 1000 + 1); + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": seconds }), 0, now), null); + assert.equal(getResponseRetryDelayMs(new Headers({ "retry-after": "18000" }), 0, now), null); + }); + + it("reads the token endpoint's retry_after from the body", () => { + assert.equal(getTokenRetryDelayMs(529, '{"retry_after":2}', 0), 2000); + assert.equal(getTokenRetryDelayMs(429, '{"error":{"retry_after":"3"}}', 0), 3000); + assert.equal(getTokenRetryDelayMs(529, '{"retry_after":600}', 0), null); + }); + + it("backs off the token endpoint when the body has no retry_after", () => { + assert.equal(getTokenRetryDelayMs(529, "not json", 0), 2000); + assert.equal(getTokenRetryDelayMs(529, "{}", 3), 8000); + assert.equal(getTokenRetryDelayMs(429, "{}", 1, 0.5), 2500); + }); + + it("ends a retry wait early when the request is aborted", async () => { + const controller = new AbortController(); + const reason = new Error("aborted by caller"); + const started = Date.now(); + const wait = waitForRetry(5000, controller.signal); + controller.abort(reason); + await assert.rejects(wait, (err) => err === reason); + assert.ok(Date.now() - started < 1000); + await assert.rejects(waitForRetry(10, controller.signal), (err) => err === reason); + }); +}); + +describe("bridge fetch wrapper", () => { + type Reply = { status: number; retryAfter?: string }; + + // Drives the plugin's real fetch wrapper against a stubbed network. Auth is a + // valid stored token, so no refresh (and no real token request) can happen; + // 429 cases only use waits the bridge refuses, which return before refreshing. + // HOME and XDG_DATA_HOME point at a scratch dir so the developer's own Claude + // CLI credentials and opencode log dir are never touched. + async function sendThroughBridge( + replies: Reply[], + signal?: AbortSignal, + requestBody: Record = { + model: "claude-haiku-4-5", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + }, + ) { + const originalFetch = globalThis.fetch; + const originalEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + HOME: process.env.HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + }; + const scratchDir = mkdtempSync(join(tmpdir(), "bridge-retry-test-")); + process.env.HOME = scratchDir; + process.env.XDG_DATA_HOME = join(scratchDir, "data"); + const messageCalls: string[] = []; + const sentBodies: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.includes("/v1/messages")) return new Response("{}", { status: 404 }); + messageCalls.push(url); + sentBodies.push(String(init?.body)); + const reply = replies[Math.min(messageCalls.length - 1, replies.length - 1)]; + const body = reply.status === 200 + ? { type: "message" } + : { type: "error", error: { type: "api_error", message: `failure ${reply.status}` } }; + const headers: Record = { "request-id": `req_${messageCalls.length}` }; + if (reply.retryAfter !== undefined) headers["retry-after"] = reply.retryAfter; + return new Response(JSON.stringify(body), { status: reply.status, headers }); + }) as typeof fetch; + + try { + const hooks = await pluginModule.server({ client: { auth: { set: async () => {} } } }); + const loaded = await hooks.auth.loader( + async () => ({ + type: "oauth", + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 3_600_000, + }), + { models: {} }, + ) as { fetch: (input: string, init?: RequestInit) => Promise }; + const response = await loaded.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify(requestBody), + signal, + }); + const errorLogPath = join(scratchDir, "data", "opencode", "log", "claude-bridge-api-errors.log"); + const errorLog = existsSync(errorLogPath) ? readFileSync(errorLogPath, "utf8") : ""; + return { status: response.status, calls: messageCalls.length, sentBodies, errorLog }; + } finally { + globalThis.fetch = originalFetch; + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(scratchDir, { recursive: true, force: true }); + } + } + + it("retries an overloaded response and returns the recovered one", async () => { + const { status, calls } = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 200 }]); + assert.deepEqual({ status, calls }, { status: 200, calls: 2 }); + }); + + it("stops after two retries and returns the last overloaded response", async () => { + const { status, calls } = await sendThroughBridge([{ status: 529, retryAfter: "0" }]); + assert.deepEqual({ status, calls }, { status: 529, calls: 3 }); + }); + + it("returns a long Retry-After at once instead of waiting inside the request", async () => { + const started = Date.now(); + const { status, calls } = await sendThroughBridge([{ status: 429, retryAfter: "18000" }]); + assert.deepEqual({ status, calls }, { status: 429, calls: 1 }); + assert.ok(Date.now() - started < 1000); + }); + + it("does not retry other errors", async () => { + const { status, calls } = await sendThroughBridge([{ status: 500 }]); + assert.deepEqual({ status, calls }, { status: 500, calls: 1 }); + }); + + it("records the final failed response in the API error log", async () => { + const { status, errorLog } = await sendThroughBridge([{ status: 529, retryAfter: "0" }, { status: 500 }]); + assert.equal(status, 500); + // Only the response returned to opencode is logged, not the retried ones. + assert.match(errorLog, /status=500 url=https:\/\/api\.anthropic\.com\/v1\/messages\S* request-id=req_2/); + assert.match(errorLog, /response: .*failure 500/); + assert.doesNotMatch(errorLog, /status=529/); + }); + + it("logs nothing for a successful response", async () => { + const { errorLog } = await sendThroughBridge([{ status: 200 }]); + assert.equal(errorLog, ""); + }); + + it("rewrites a request whose system prompt is a plain string", async () => { + const { status, sentBodies } = await sendThroughBridge([{ status: 200 }], undefined, { + model: "claude-haiku-4-5", + max_tokens: 16, + system: "Be brief.", + messages: [{ role: "user", content: "hi" }], + }); + assert.equal(status, 200); + const system = JSON.parse(sentBodies[0]).system; + assert.ok(Array.isArray(system), "system must be sent as blocks"); + assert.match(system[0].text, /^x-anthropic-billing-header:/); + }); + + it("gives up the retry wait when the request is aborted", async () => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error("cancelled")), 20); + await assert.rejects( + sendThroughBridge([{ status: 529, retryAfter: "5" }], controller.signal), + /cancelled/, + ); + }); +}); + +describe("token endpoint retry", () => { + type Reply = { status: number; body: string }; + + // Runs refreshTokens against a stand-in `curl` placed first on PATH, so the + // real retry loop executes without a network. POSIX-only (shebang script). + function refreshWithReplies(replies: Reply[]): { result: unknown; calls: number } { + const dir = mkdtempSync(join(tmpdir(), "bridge-token-test-")); + const originalPath = process.env.PATH; + writeFileSync(join(dir, "replies.json"), JSON.stringify(replies)); + writeFileSync(join(dir, "curl"), `#!${process.execPath} +const fs = require("node:fs"); +const dir = ${JSON.stringify(dir)}; +const countFile = dir + "/count"; +const count = fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, "utf8")) : 0; +fs.writeFileSync(countFile, String(count + 1)); +const replies = JSON.parse(fs.readFileSync(dir + "/replies.json", "utf8")); +const reply = replies[Math.min(count, replies.length - 1)]; +process.stdout.write(reply.body + "\\n__HTTP_STATUS__" + reply.status); +`); + chmodSync(join(dir, "curl"), 0o755); + process.env.PATH = `${dir}:${originalPath}`; + try { + let result: unknown; + try { + result = refreshTokens("test-refresh-token"); + } catch (err) { + result = err instanceof Error ? err.message : err; + } + return { result, calls: Number(readFileSync(join(dir, "count"), "utf8")) }; + } finally { + process.env.PATH = originalPath; + rmSync(dir, { recursive: true, force: true }); + } + } + + const ok = { + status: 200, + body: JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }), + }; + const overloaded = (retryAfter: number) => ({ + status: 529, + body: JSON.stringify({ error: { type: "overloaded_error", message: "Overloaded" }, retry_after: retryAfter }), + }); + + it("retries an overloaded token endpoint", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(0), ok]); + assert.equal(calls, 2); + assert.equal((result as { access: string }).access, "new-access"); + }); + + it("reports the endpoint's real last answer once retries run out", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(0)]); + assert.equal(calls, 3); + assert.match(String(result), /failed \(529\): Overloaded/); + }); + + it("does not sit through a long requested wait", { skip: process.platform === "win32" }, () => { + const { result, calls } = refreshWithReplies([overloaded(600), ok]); + assert.equal(calls, 1); + assert.match(String(result), /failed \(529\)/); + }); +}); + +describe("plain-string system prompt", () => { + it("wraps a string in a single text block", () => { + assert.deepEqual(toSystemBlocks("Be brief."), [{ type: "text", text: "Be brief." }]); + }); + + it("passes blocks through and turns an absent prompt into no blocks", () => { + const blocks = [{ type: "text", text: "x" }]; + assert.equal(toSystemBlocks(blocks), blocks); + assert.deepEqual(toSystemBlocks(undefined), []); + assert.deepEqual(toSystemBlocks(""), []); + }); +}); diff --git a/src/index.ts b/src/index.ts index d9fcaac..bf4c3ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { hostname, userInfo } from "node:os"; -import { join } from "node:path"; +import { appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir, hostname, userInfo } from "node:os"; +import { dirname, join } from "node:path"; import { createAuthorizationRequest, exchangeCodeForTokens, @@ -10,6 +10,12 @@ import { startOAuthCallbackServer, } from "./oauth.js"; import { getClaudeTokens, readClaudeCredentials } from "./keychain.js"; +import { + MAX_RESPONSE_RETRIES, + RETRYABLE_STATUSES, + getResponseRetryDelayMs, + waitForRetry, +} from "./retry.js"; import { BETA_FLAGS, CLI_VERSION, @@ -216,6 +222,16 @@ export function rewriteSystemBlocksForModel( }); } +/** + * The Messages API takes `system` as a plain string or as text blocks; the + * request rewrite (billing header, prompt shaping) works on blocks only. + */ +export function toSystemBlocks(system: unknown): unknown { + if (!system) return []; + if (typeof system === "string") return [{ type: "text", text: system }]; + return system; +} + export function stripSystemCacheControl( system: Array<{ type?: string; text?: string; cache_control?: unknown }>, ): Array<{ type?: string; text?: string }> { @@ -310,6 +326,46 @@ const oauthProfileCache = new Map>(); const SYSTEM_PROMPT_CACHE_PATH = process.env.ANTHROPIC_SYSTEM_PROMPT_PATH || join(process.env.HOME || "", ".cache", "opencode-claude-bridge", "claude-system-prompt.json"); +/** + * Where failed API responses are recorded, next to opencode's own logs. + * opencode only surfaces a one-line summary of a failed request; the full + * error body and request id are what a bug report needs. + */ +function getApiErrorLogPath(): string { + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(dataHome, "opencode", "log", "claude-bridge-api-errors.log"); +} + +/** Record a failed API response on stderr and in the error log; never throws. */ +async function logApiErrorResponse(response: Response, requestUrl: string): Promise { + try { + const requestId = response.headers.get("request-id") || response.headers.get("x-request-id"); + const responseBody = await response.clone().text(); + const statusLine = `[opencode-claude-bridge] API error status=${response.status} url=${requestUrl}` + + (requestId ? ` request-id=${requestId}` : ""); + + console.error(statusLine); + if (responseBody) { + console.error(`[opencode-claude-bridge] API error response: ${responseBody.slice(0, 8000)}`); + } + + try { + const logPath = getApiErrorLogPath(); + mkdirSync(dirname(logPath), { recursive: true }); + const entry = [ + `time=${new Date().toISOString()}`, + statusLine, + responseBody ? `response: ${responseBody}` : "", + ].filter(Boolean).join("\n"); + appendFileSync(logPath, `${entry}\n\n`, "utf8"); + } catch (err) { + console.error(`[opencode-claude-bridge] Failed to write the API error log: ${err}`); + } + } catch (err) { + console.error(`[opencode-claude-bridge] Failed to read the API error response: ${err}`); + } +} + function buildAuthFailureResponse(detail: string): Response { const message = [ "Claude OAuth credentials are no longer valid.", @@ -763,7 +819,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { parsed.temperature = 1; } - if (!parsed.system) parsed.system = []; + parsed.system = toSystemBlocks(parsed.system); if (Array.isArray(parsed.messages)) { parsed.messages = stripAssistantPrefillForClaude(parsed.messages); } @@ -899,7 +955,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { const finalUrl = requestUrl?.toString() ?? (input instanceof Request ? input.url : String(input)); - // ── Request (with 429 auto-refresh retry) ── + // ── Request (with 429/529 retry) ── const outHeaders: Record = {}; headers.forEach((v, k) => { outHeaders[k] = v; }); @@ -912,11 +968,22 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { let response = await doFetch(); - // 429 auto-refresh: rate limits are per-access-token, so refreshing - // the token gives us a fresh rate limit bucket. Try up to 2 retries. - if (response.status === 429) { - for (let retry = 0; retry < 2; retry++) { - console.error(`[opencode-claude-bridge] 429 rate limited (attempt ${retry + 1}/2), refreshing token...`); + // Retry rate-limited (429) and overloaded (529) responses, waiting + // as long as Retry-After asks. A longer wait than the bridge absorbs + // returns the response as-is so opencode's own retry takes over. + for ( + let retry = 0; + retry < MAX_RESPONSE_RETRIES && RETRYABLE_STATUSES.has(response.status); + retry++ + ) { + const delayMs = getResponseRetryDelayMs(response.headers, retry); + if (delayMs === null) break; + const attempt = `attempt ${retry + 1}/${MAX_RESPONSE_RETRIES}`; + + if (response.status === 429) { + // 429 auto-refresh: rate limits are per-access-token, so + // refreshing the token gives us a fresh rate limit bucket. + console.error(`[opencode-claude-bridge] 429 rate limited (${attempt}), refreshing token...`); let freshToken: string | null = null; try { freshToken = await refreshAuth(auth, client); @@ -930,13 +997,18 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => { break; } outHeaders["authorization"] = `Bearer ${freshToken}`; - // Back off briefly: 1s first retry, 2s second - await new Promise((r) => setTimeout(r, 1000 * (retry + 1))); - response = await doFetch(); - if (response.status !== 429) break; + } else { + console.error(`[opencode-claude-bridge] ${response.status} overloaded (${attempt}), retrying in ${Math.ceil(delayMs / 1000)}s...`); } + + // Release the failed response's connection before reusing it. + await response.body?.cancel().catch(() => {}); + await waitForRetry(delayMs, init?.signal); + response = await doFetch(); } + if (!response.ok) await logApiErrorResponse(response, finalUrl); + // ── Map Claude-style tool names and arguments back to OpenCode ── if (!response.body) return response; diff --git a/src/oauth.ts b/src/oauth.ts index 2acdb23..b76e8fa 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -9,6 +9,7 @@ import { SCOPES, USER_AGENT, } from "./constants.js"; +import { RETRYABLE_STATUSES, getTokenRetryDelayMs } from "./retry.js"; export interface OAuthTokens { access: string; @@ -69,6 +70,7 @@ function curlPost( const payload = JSON.stringify(body); for (let attempt = 0; attempt < retries; attempt++) { + let retryDelayMs: number; try { const result = execFileSync( "curl", @@ -84,23 +86,27 @@ function curlPost( const status = parseInt(parts[parts.length - 1], 10); const responseBody = parts.slice(0, -1).join("\n__HTTP_STATUS__"); - if (status !== 429 || attempt === retries - 1) { + if (!RETRYABLE_STATUSES.has(status) || attempt === retries - 1) { return { status, body: responseBody }; } + // Null when the endpoint asks for a longer wait than this blocking call + // should sit through: hand its real answer back instead. + const delayMs = getTokenRetryDelayMs(status, responseBody, attempt); + if (delayMs === null) return { status, body: responseBody }; + retryDelayMs = delayMs; console.error( - `[opencode-oauth] Token endpoint 429 (attempt ${attempt + 1}/${retries}), retrying...`, + `[opencode-oauth] Token endpoint ${status} (attempt ${attempt + 1}/${retries}), retrying...`, ); } catch (err) { if (attempt === retries - 1) throw err; + retryDelayMs = 1000 * Math.pow(2, attempt) + Math.random() * 1000; } - sleep(1000 * Math.pow(2, attempt) + Math.random() * 1000); + sleep(retryDelayMs); } - return { - status: 429, - body: '{"error":{"type":"rate_limit_error","message":"Rate limited"}}', - }; + // Unreachable: the last attempt always returns or throws. + throw new Error("Token endpoint request was not attempted"); } export function extractOAuthErrorDetail(body: string): string { diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..d8906d7 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,95 @@ +/** + * Retry policy shared by the Messages API wrapper and the OAuth token + * endpoint. Pure apart from the timer in waitForRetry, so it can be tested + * without a network. + */ + +/** Statuses the bridge retries itself: rate limited, and overloaded. */ +export const RETRYABLE_STATUSES = new Set([429, 529]); + +/** Retries per request; opencode's own retry loop takes over after these. */ +export const MAX_RESPONSE_RETRIES = 2; + +/** + * Longest wait the bridge sits through before retrying. A longer requested + * wait (a usage limit that resets in hours) is handed straight back to the + * caller: opencode's retry loop shows the wait and can be cancelled, while a + * wait inside fetch looks like a hung request. + */ +export const MAX_RETRY_DELAY_MS = 10_000; + +function getOverloadedBackoffMs(attempt: number): number { + return Math.min(2000 * 2 ** attempt, 8000); +} + +/** Parse a Retry-After value (delay seconds or an HTTP date) into milliseconds. */ +export function parseRetryAfterMs(value: string | null, now = Date.now()): number | null { + if (!value?.trim()) return null; + const seconds = Number(value); + if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : null; + const date = Date.parse(value); + if (Number.isFinite(date)) return Math.max(date - now, 0); + return null; +} + +/** + * Delay before retry `attempt` (0-based) of a retryable response, or null + * when the server asked for a longer wait than the bridge should absorb. + */ +export function getResponseRetryDelayMs( + headers: Headers, + attempt: number, + now = Date.now(), +): number | null { + const delayMs = parseRetryAfterMs(headers.get("retry-after"), now) + ?? getOverloadedBackoffMs(attempt); + return delayMs > MAX_RETRY_DELAY_MS ? null : delayMs; +} + +/** + * Delay before retry `attempt` (0-based) of the token endpoint, which reports + * its requested wait as `retry_after` seconds in the JSON body. Null when that + * wait is longer than the bridge should absorb — the token request is a + * blocking call, so a long sleep would stall opencode entirely. + */ +export function getTokenRetryDelayMs( + status: number, + body: string, + attempt: number, + random = Math.random(), +): number | null { + let requestedMs: number | null = null; + try { + const parsed = JSON.parse(body) as { + retry_after?: number | string; + error?: { retry_after?: number | string }; + }; + const seconds = Number(parsed.retry_after ?? parsed.error?.retry_after); + if (Number.isFinite(seconds) && seconds >= 0) requestedMs = seconds * 1000; + } catch {} + + const delayMs = requestedMs + ?? (status === 529 + ? getOverloadedBackoffMs(attempt) + : 1000 * 2 ** attempt + random * 1000); + return delayMs > MAX_RETRY_DELAY_MS ? null : delayMs; +} + +/** Wait before a retry, ending early (with the abort reason) when the request is aborted. */ +export function waitForRetry(ms: number, signal?: AbortSignal | null): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +}