diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index eb39519854cb..8979557cd75e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,6 +23,14 @@ interface FetchDecompressionError extends Error { path: string } +/** Optional HTTP status fields found on non-APICallError error objects from providers */ +interface ErrorWithStatus { + status?: number + statusCode?: number + response?: { status?: number; statusCode?: number } + message?: string +} + export namespace MessageV2 { export function isMedia(mime: string) { return mime.startsWith("image/") || mime === "application/pdf" @@ -998,8 +1006,31 @@ 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.response?.status ?? typed.response?.statusCode + if (typeof code === "number" && code >= 500) + return new MessageV2.APIError( + { message: errorMessage(e), 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 jsonCode = obj?.status ?? obj?.statusCode + if (typeof jsonCode === "number" && jsonCode >= 500) { + const msg = typeof obj?.message === "string" ? obj.message : errorMessage(e) + return new MessageV2.APIError( + { message: msg, statusCode: jsonCode, isRetryable: true }, + { cause: e }, + ).toObject() + } + } catch {} return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() + } default: try { const parsed = ProviderError.parseStreamError(e) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 3634d6fb7ec8..aba60f4dee93 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -928,6 +928,67 @@ describe("session.message-v2.fromError", () => { }) }) + test("retries 5xx Error with statusCode property", () => { + const err = new Error("Internal Server Error") + ;(err as any).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 = new Error("Bad Gateway") + ;(err as any).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 = new Error("Gateway Timeout") + ;(err as any).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 from JSON-encoded Error 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("does not retry 4xx Error", () => { + const err = new Error("Not Found") + ;(err as any).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()',