From 29bd71459d4aa2bd04e18c20935316576bc35b20 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 11:38:00 -0500 Subject: [PATCH] fix(opencode): harden retry message classification --- packages/opencode/src/session/retry.ts | 25 +++++++++++++++----- packages/opencode/test/session/retry.test.ts | 22 +++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 3e3dbeb65b..7808ff11e4 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -29,9 +29,9 @@ export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout const RETRYABLE_MESSAGE = [ - /server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|provider returned error|too many requests|rate increased too quickly|rate[_\s-]?limit/i, - /fetch failed|network error|upstream connect|connection (?:error|refused|lost)|socket connection was closed|socket hang up|reset before headers|getaddrinfo|ENOTFOUND|EAI_AGAIN|timeout|timed? out|terminated/i, - /resource[_\s-]?exhausted|please retry your request|you can retry your request|try your request again/i, + /\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|rate[_\s-]?limit)\b|\bprovider returned error\b/i, + /\b(?:fetch failed|network error|upstream connect|connection (?:error|refused|lost)|socket connection was closed|socket hang up|reset before headers|getaddrinfo|ENOTFOUND|EAI_AGAIN)\b|^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed? out)\b/i, + /\b(?:resource[_\s-]?exhausted|please retry your request|you can retry your request|try your request again)\b/i, ] function cap(ms: number) { @@ -74,11 +74,13 @@ export function delay(attempt: number, error?: SessionV1.APIError) { export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined + const msg = isRecord(error.data) ? error.data.message : undefined + const retryableMessage = isRetryableMessage(msg) if (SessionV1.APIError.isInstance(error)) { const status = error.data.statusCode // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. - if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined + if (!error.data.isRetryable && !(status !== undefined && status >= 500) && !retryableMessage) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, @@ -128,7 +130,6 @@ export function retryable(error: Err, provider: string) { return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } - const msg = isRecord(error.data) ? error.data.message : undefined const json = parseJSON(msg) if (json && typeof json === "object") { const code = typeof json.code === "string" ? json.code : "" @@ -142,10 +143,22 @@ export function retryable(error: Err, provider: string) { return { message: "Rate Limited" } } } - if (typeof msg === "string" && RETRYABLE_MESSAGE.some((pattern) => pattern.test(msg))) return { message: msg } + if (retryableMessage && typeof msg === "string") return { message: msg } return undefined } +function isRetryableMessage(input: unknown) { + if (typeof input !== "string") return false + const json = parseJSON(input) + const messages = + json && typeof json === "object" + ? [json.message, json.code, json.error?.message, json.error?.code, json.error?.type] + : [input] + return messages.some( + (message) => typeof message === "string" && RETRYABLE_MESSAGE.some((pattern) => pattern.test(message)), + ) +} + function str(value: unknown) { if (value === undefined || value === null) return "" return String(value) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index fcfa3ee784..3791c5a23d 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -179,6 +179,28 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(wrap(msg), retryProvider)).toEqual({ message: msg }) }) + test("retries transient messages nested in json", () => { + const msg = JSON.stringify({ type: "error", error: { code: "server_error", message: "xxx" } }) + expect(SessionRetry.retryable(wrap(msg), retryProvider)).toEqual({ message: msg }) + }) + + test("retries transient API errors even when the SDK does not", () => { + const error = new SessionV1.APIError({ message: "server_error", isRetryable: false }).toObject() + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "server_error" }) + }) + + test.each(["Observer error", "Unterminated string in JSON", "Invalid timeout option"])( + "does not retry near-miss errors: %s", + (msg) => { + expect(SessionRetry.retryable(wrap(msg), retryProvider)).toBeUndefined() + }, + ) + + test("does not match unrelated json fields", () => { + const msg = JSON.stringify({ error: { message: "Invalid request" }, metadata: "server_error" }) + expect(SessionRetry.retryable(wrap(msg), retryProvider)).toBeUndefined() + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true)