From 849b1767bbfbed7d680926417ab89a4b19956802 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 13:13:19 -0500 Subject: [PATCH 1/5] fix(opencode): expand retryable error patterns --- packages/opencode/src/session/retry.ts | 16 ++++++++-------- packages/opencode/test/session/retry.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index d1864cb7a8a0..e46067dd247a 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -28,6 +28,13 @@ export const RETRY_BACKOFF_FACTOR = 2 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 = [ + /\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|provider[_\s-]?returned[_\s-]?error)\b|\brate[_\s-]?limit/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, + /\b(?:429|500|502|503|504|524)\b/, +] + function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } @@ -125,16 +132,9 @@ export function retryable(error: Err, provider: string) { const message = isRecord(error.data) ? error.data.message : undefined if (typeof message !== "string") return undefined const lower = message.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("rate_limit") || - lower.includes("too many requests") - ) { - return { message } - } if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } + if (RETRYABLE_MESSAGE.some((pattern) => pattern.test(message))) return { message } return undefined } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0e30a5473a2e..67e5deefe744 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -168,6 +168,19 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test.each([ + "Internal server error", + "Provider returned error", + "fetch failed", + "connection refused", + "EAI_AGAIN", + "response timed out", + "Please retry your request", + "upstream returned status 524", + ])("retries matching API error text: %s", (message) => { + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true) From 4096349fe83152e9ddaaab063ae8608dbc9d9c7b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 13:21:33 -0500 Subject: [PATCH 2/5] refactor(opencode): clarify retry message patterns --- packages/opencode/src/session/retry.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index e46067dd247a..70ee2f562062 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -28,11 +28,13 @@ export const RETRY_BACKOFF_FACTOR = 2 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 = [ - /\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|provider[_\s-]?returned[_\s-]?error)\b|\brate[_\s-]?limit/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, +const RETRYABLE_MESSAGE_PATTERNS = [ /\b(?:429|500|502|503|504|524)\b/, + /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, + /internal server error|internal_error|server error|server_error|service unavailable|service_unavailable|overloaded|provider returned error/i, + /fetch failed|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again/i, + /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, + /resource exhausted|resource_exhausted|retry your request/i, ] function cap(ms: number) { @@ -134,7 +136,7 @@ export function retryable(error: Err, provider: string) { const lower = message.toLowerCase() if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } - if (RETRYABLE_MESSAGE.some((pattern) => pattern.test(message))) return { message } + if (RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) return { message } return undefined } From 74346f1126e6a1887a59f196cebf6aaad291b3cc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 13:30:02 -0500 Subject: [PATCH 3/5] fix(opencode): classify retryable response bodies --- packages/opencode/src/session/retry.ts | 17 +++++++++++---- packages/opencode/test/session/retry.test.ts | 22 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 70ee2f562062..32581d3d7691 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -29,10 +29,10 @@ 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_PATTERNS = [ - /\b(?:429|500|502|503|504|524)\b/, + /\b(?:http(?: status)?|response status|status(?: code)?)[\s:=()-]*(?:429|500|502|503|504|524)\b/i, /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, /internal server error|internal_error|server error|server_error|service unavailable|service_unavailable|overloaded|provider returned error/i, - /fetch failed|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again/i, + /fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, /resource exhausted|resource_exhausted|retry your request/i, ] @@ -81,7 +81,12 @@ export function retryable(error: Err, provider: string) { 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) && + !matchesRetryableMessage(error.data.message) && + !matchesRetryableMessage(error.data.responseBody) + ) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, @@ -136,10 +141,14 @@ export function retryable(error: Err, provider: string) { const lower = message.toLowerCase() if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } - if (RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) return { message } + if (matchesRetryableMessage(message)) return { message } return undefined } +function matchesRetryableMessage(value: unknown) { + return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value)) +} + 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 67e5deefe744..72dcf4058a3c 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -173,6 +173,9 @@ describe("session.retry.retryable", () => { "Provider returned error", "fetch failed", "connection refused", + "connect ECONNREFUSED", + "request ETIMEDOUT", + "failed to fetch", "EAI_AGAIN", "response timed out", "Please retry your request", @@ -181,6 +184,25 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) }) + test.each(["max_tokens must be at most 500", "port 500 is blocked"])( + "does not treat unrelated numbers as HTTP statuses: %s", + (message) => { + expect(SessionRetry.retryable(wrap(message), retryProvider)).toBeUndefined() + }, + ) + + test("matches retryable API response bodies", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message: "Request failed", + isRetryable: false, + statusCode: 400, + responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }), + }).toObject(), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" }) + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true) From d2d8ccd96547b4f328b9b82fcecd8839c33e6a97 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 13:40:35 -0500 Subject: [PATCH 4/5] refactor(opencode): align retry status matching --- packages/opencode/src/session/retry.ts | 2 +- packages/opencode/test/session/retry.test.ts | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 32581d3d7691..b15acf5f08b9 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -29,7 +29,7 @@ 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_PATTERNS = [ - /\b(?:http(?: status)?|response status|status(?: code)?)[\s:=()-]*(?:429|500|502|503|504|524)\b/i, + /429|500|502|503|504|524/i, /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, /internal server error|internal_error|server error|server_error|service unavailable|service_unavailable|overloaded|provider returned error/i, /fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 72dcf4058a3c..238d37794dbe 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -184,13 +184,6 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) }) - test.each(["max_tokens must be at most 500", "port 500 is blocked"])( - "does not treat unrelated numbers as HTTP statuses: %s", - (message) => { - expect(SessionRetry.retryable(wrap(message), retryProvider)).toBeUndefined() - }, - ) - test("matches retryable API response bodies", () => { const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( new SessionV1.APIError({ From 6651d122a71338e6e36d3754a1d351777e2ea3e0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 5 Aug 2026 14:12:12 -0500 Subject: [PATCH 5/5] fix(opencode): cover additional retry messages --- packages/opencode/src/session/retry.ts | 6 +++--- packages/opencode/test/session/retry.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index b15acf5f08b9..22399e8703a8 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -31,10 +31,10 @@ export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for se const RETRYABLE_MESSAGE_PATTERNS = [ /429|500|502|503|504|524/i, /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, - /internal server error|internal_error|server error|server_error|service unavailable|service_unavailable|overloaded|provider returned error/i, - /fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, + /overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i, + /terminated|fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, - /resource exhausted|resource_exhausted|retry your request/i, + /try your request again|retry your request|resource exhausted|resource_exhausted/i, ] function cap(ms: number) { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 238d37794dbe..018f76fc3eaf 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -170,7 +170,11 @@ describe("session.retry.retryable", () => { test.each([ "Internal server error", + "internal error", + "server-error", "Provider returned error", + "provider-returned-error", + "terminated", "fetch failed", "connection refused", "connect ECONNREFUSED", @@ -179,11 +183,18 @@ describe("session.retry.retryable", () => { "EAI_AGAIN", "response timed out", "Please retry your request", + "try your request again", "upstream returned status 524", ])("retries matching API error text: %s", (message) => { expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) }) + test("retries hyphenated service-unavailable errors", () => { + expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({ + message: "Provider is overloaded", + }) + }) + test("matches retryable API response bodies", () => { const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( new SessionV1.APIError({