From 3c12be213928b750378c90a5aa817460478a302b Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Tue, 15 Sep 2026 11:49:26 -0700 Subject: [PATCH 1/3] fix(session): retry OpenRouter upstream errors reported inside a 200 stream OpenRouter forwards upstream 429s and 5xxs as a JSON object in the SSE body, so they arrive as UnknownError with no statusCode and the retry classifier never saw them. Apply the same rule the APIError branch uses. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/retry.ts | 4 ++++ packages/opencode/test/session/retry.test.ts | 25 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..e3324a8e752a 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -137,6 +137,10 @@ export function retryable(error: Err, provider: string) { const json = parseJSON(msg) if (!json || typeof json !== "object") return undefined + // OpenRouter reports upstream failures inside a 200 stream as {code: , ...}, + // so the status never reaches the APIError branch above. Same rule as there: 429 and 5xx retry. + if (json.code === 429) return { message: "Rate Limited" } + if (typeof json.code === "number" && json.code >= 500) return { message: "Provider is overloaded" } const code = typeof json.code === "string" ? json.code : "" if (json.type === "error" && json.error?.type === "too_many_requests") { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9d..212f6b4f3b80 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -133,6 +133,31 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) + test("retries OpenRouter rate limits reported inside a 200 stream", () => { + const error = wrap( + JSON.stringify({ code: 429, message: "Provider returned error", metadata: { error_type: "rate_limit_exceeded" } }), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Rate Limited" }) + }) + + test("retries OpenRouter upstream 5xx reported inside a 200 stream", () => { + const error = wrap( + JSON.stringify({ + code: 502, + message: "Upstream error from Together: Stream error: h2 protocol error: error reading a body from connection", + metadata: { error_type: "provider_unavailable" }, + }), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) + }) + + test("does not retry OpenRouter 4xx request errors reported inside a 200 stream", () => { + const error = wrap( + JSON.stringify({ code: 400, message: "Error resolving schema reference '#/$defs/__schema0'" }), + ) + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() + }) + test("does not throw on numeric error codes", () => { const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } })) const result = SessionRetry.retryable(error, retryProvider) From e6f64ce92b13d3a1e20cbc344f04a61a9b4a90a2 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Tue, 15 Sep 2026 11:49:39 -0700 Subject: [PATCH 2/3] Prettier --- packages/opencode/test/session/retry.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 212f6b4f3b80..de3453bf0df8 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -135,7 +135,11 @@ describe("session.retry.retryable", () => { test("retries OpenRouter rate limits reported inside a 200 stream", () => { const error = wrap( - JSON.stringify({ code: 429, message: "Provider returned error", metadata: { error_type: "rate_limit_exceeded" } }), + JSON.stringify({ + code: 429, + message: "Provider returned error", + metadata: { error_type: "rate_limit_exceeded" }, + }), ) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Rate Limited" }) }) @@ -152,9 +156,7 @@ describe("session.retry.retryable", () => { }) test("does not retry OpenRouter 4xx request errors reported inside a 200 stream", () => { - const error = wrap( - JSON.stringify({ code: 400, message: "Error resolving schema reference '#/$defs/__schema0'" }), - ) + const error = wrap(JSON.stringify({ code: 400, message: "Error resolving schema reference '#/$defs/__schema0'" })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) From c357efb18d52d5aefe0efc51f69588d7c1410390 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Tue, 15 Sep 2026 12:05:04 -0700 Subject: [PATCH 3/3] Keep a 502-coded overflow out of the retry loop Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/retry.ts | 5 ++++- packages/opencode/test/session/retry.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index e3324a8e752a..e998d4e8c913 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -4,6 +4,7 @@ import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { MessageV2 } from "./message-v2" import { iife } from "@/util/iife" import { isRecord } from "@/util/record" +import { ProviderError } from "@/provider/error" export type Err = ReturnType @@ -138,7 +139,9 @@ export function retryable(error: Err, provider: string) { const json = parseJSON(msg) if (!json || typeof json !== "object") return undefined // OpenRouter reports upstream failures inside a 200 stream as {code: , ...}, - // so the status never reaches the APIError branch above. Same rule as there: 429 and 5xx retry. + // so the status never reaches the APIError branch above. Same rule as there: 429 and 5xx retry, + // except an overflow relayed with a 502 code, which must reach compaction instead of the retry loop. + if (ProviderError.parseStreamError(json)?.type === "context_overflow") return undefined if (json.code === 429) return { message: "Rate Limited" } if (typeof json.code === "number" && json.code >= 500) return { message: "Provider is overloaded" } const code = typeof json.code === "string" ? json.code : "" diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index de3453bf0df8..773abad66382 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -155,6 +155,11 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) + test("does not retry an OpenRouter context overflow relayed with a 502 code", () => { + const error = wrap(JSON.stringify({ code: 502, message: "Your input exceeds the context window of this model" })) + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() + }) + test("does not retry OpenRouter 4xx request errors reported inside a 200 stream", () => { const error = wrap(JSON.stringify({ code: 400, message: "Error resolving schema reference '#/$defs/__schema0'" })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()