Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()',
Expand Down
Loading