From 4b4e18f91c63b291f4be29ab62ca5edbe2c3c60e Mon Sep 17 00:00:00 2001 From: Yoshiaki Okuyama Date: Sun, 12 Apr 2026 20:20:17 +0900 Subject: [PATCH] fix(opencode): extract statusCode from error variants for 5xx retry Some OpenAI-compatible providers throw native Error subclasses or plain objects instead of ai-sdk's APICallError. These fell through to NamedError.Unknown and were not retried, even on 5xx status codes. Extract statusCode from common error shapes (Error.status, Error.statusCode, Error.response.status, JSON-encoded message) and treat 5xx as retryable in both `instanceof Error` and plain object fallback branches. Closes #19203 --- packages/opencode/src/session/message-v2.ts | 50 ++++- .../opencode/test/session/message-v2.test.ts | 183 ++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4c18d1f7e09d..d84ab899cc1c 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -24,6 +24,15 @@ interface FetchDecompressionError extends Error { path: string } +/** Common shapes for non-APICallError provider errors carrying HTTP status */ +interface ErrorWithStatus { + status?: number + statusCode?: number + code?: number + response?: { status?: number; statusCode?: number } + message?: string +} + export namespace MessageV2 { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" @@ -1024,8 +1033,33 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() - case e instanceof Error: + case e instanceof Error: { + // Non-APICallError with HTTP status - treat 5xx as retryable. + const typed = e as Error & ErrorWithStatus + const code = + typed.status ?? typed.statusCode ?? typed.code ?? + typed.response?.status ?? typed.response?.statusCode + if (typeof code === "number" && code >= 500) + return new MessageV2.APIError( + { message: e.message, statusCode: code, isRetryable: true }, + { cause: e }, + ).toObject() + // Fallback: status may be embedded in a JSON error message. + try { + const obj = JSON.parse(e.message) as ErrorWithStatus + const status = + obj?.status ?? obj?.statusCode ?? obj?.code ?? + obj?.response?.status ?? obj?.response?.statusCode + if (typeof status === "number" && status >= 500) { + const msg = typeof obj?.message === "string" ? obj.message : e.message + return new MessageV2.APIError( + { message: msg, statusCode: status, isRetryable: true }, + { cause: e }, + ).toObject() + } + } catch {} return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() + } default: try { const parsed = ProviderError.parseStreamError(e) @@ -1051,6 +1085,20 @@ export namespace MessageV2 { ).toObject() } } catch {} + // Plain-object 5xx - treat as retryable. + if (typeof e === "object" && e !== null) { + const typed = e as ErrorWithStatus + const code = typed.status ?? typed.statusCode ?? typed.code ?? typed.response?.status ?? typed.response?.statusCode + if (typeof code === "number" && code >= 500) + return new MessageV2.APIError( + { + message: typeof typed.message === "string" ? typed.message : JSON.stringify(e), + statusCode: code, + isRetryable: true, + }, + { cause: e }, + ).toObject() + } return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() } } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 64a5d3e4b257..778a59d610af 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1003,6 +1003,189 @@ describe("session.message-v2.fromError", () => { }) }) + test("retries 5xx Error with statusCode property", () => { + const err = Object.assign(new Error("Internal Server Error"), { statusCode: 502 }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("retries 5xx Error with status property", () => { + const err = Object.assign(new Error("Bad Gateway"), { status: 503 }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(503) + }) + + test("retries 5xx Error with response.status", () => { + const err = Object.assign(new Error("Gateway Timeout"), { response: { status: 504 } }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(504) + }) + + test("retries 5xx Error with response.statusCode", () => { + const err = Object.assign(new Error("Bad Gateway"), { response: { statusCode: 502 } }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("retries 5xx from JSON-encoded Error message with status property", () => { + const err = new Error(JSON.stringify({ status: 500, message: "server error" })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(500) + }) + + test("retries 5xx from JSON-encoded Error message and extracts inner message", () => { + const err = new Error(JSON.stringify({ statusCode: 500, message: "server error" })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(500) + expect((result as MessageV2.APIError).data.message).toBe("server error") + }) + + test("retries 5xx from JSON-encoded Error message with nested response.statusCode", () => { + const err = new Error(JSON.stringify({ response: { statusCode: 503 }, message: "upstream" })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(503) + expect((result as MessageV2.APIError).data.message).toBe("upstream") + }) + + test("retries 5xx from JSON-encoded Error message with nested response.status", () => { + const err = new Error(JSON.stringify({ response: { status: 502 }, message: "bad gw" })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("does not retry 4xx from JSON-encoded Error message", () => { + const err = new Error(JSON.stringify({ statusCode: 422, message: "invalid request" })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(result.name).toBe("UnknownError") + }) + + test("retries 5xx from plain object with status property", () => { + const err = { status: 500, message: "Internal Server Error" } + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(500) + }) + + test("retries 5xx from plain object with statusCode", () => { + const err = { statusCode: 502, message: "Bad Gateway" } + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("retries 5xx from plain object with response.status", () => { + const err = { response: { status: 500 }, message: "fail" } + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + }) + + test("retries 5xx from plain object with response.statusCode", () => { + const err = { response: { statusCode: 503 }, message: "unavailable" } + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(503) + }) + + test("retries 5xx Error with code property (OpenRouter format)", () => { + const err = Object.assign(new Error("Network connection lost."), { code: 502 }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("retries 5xx from plain object with code property (OpenRouter format)", () => { + const err = { code: 502, message: "Network connection lost.", metadata: { error_type: "provider_unavailable" } } + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("retries 5xx from JSON-encoded message with code property (OpenRouter format)", () => { + const err = new Error(JSON.stringify({ code: 502, message: "Network connection lost." })) + + const result = MessageV2.fromError(err, { providerID }) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.statusCode).toBe(502) + }) + + test("does not retry 4xx from plain object", () => { + const err = { statusCode: 404, message: "Not Found" } + + const result = MessageV2.fromError(err, { providerID }) + + expect(result.name).toBe("UnknownError") + }) + + test("does not retry 4xx Error", () => { + const err = Object.assign(new Error("Not Found"), { statusCode: 404 }) + + const result = MessageV2.fromError(err, { providerID }) + + expect(result.name).toBe("UnknownError") + }) + + test("does not retry Error without statusCode", () => { + const err = new Error("something went wrong") + + const result = MessageV2.fromError(err, { providerID }) + + expect(result.name).toBe("UnknownError") + }) + test("classifies ZlibError from fetch as retryable APIError", () => { const zlibError = new Error( 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',