diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..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 @@ -137,6 +138,12 @@ 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, + // 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 : "" 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..773abad66382 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -133,6 +133,38 @@ 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 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() + }) + test("does not throw on numeric error codes", () => { const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } })) const result = SessionRetry.retryable(error, retryProvider)