Skip to content

Commit dbb61ba

Browse files
okuyam2yYoshiaki Okuyama
authored andcommitted
fix(opencode): retry on 5xx errors from non-standard provider error types
Some API gateways throw non-APICallError errors with status codes in varying shapes (error.status, error.statusCode, error.response.status, or JSON-encoded in error.message). These were not being detected, causing 5xx errors to be classified as UnknownError instead of retryable APIError. Add ErrorWithStatus interface and multi-path status code extraction to the Error case in fromError(), with JSON message fallback.
1 parent 24bdd3c commit dbb61ba

2 files changed

Lines changed: 93 additions & 1 deletion

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ interface FetchDecompressionError extends Error {
2323
path: string
2424
}
2525

26+
/** Optional HTTP status fields found on non-APICallError error objects from providers */
27+
interface ErrorWithStatus {
28+
status?: number
29+
statusCode?: number
30+
response?: { status?: number; statusCode?: number }
31+
message?: string
32+
}
33+
2634
export namespace MessageV2 {
2735
export function isMedia(mime: string) {
2836
return mime.startsWith("image/") || mime === "application/pdf"
@@ -998,8 +1006,31 @@ export namespace MessageV2 {
9981006
},
9991007
{ cause: e },
10001008
).toObject()
1001-
case e instanceof Error:
1009+
case e instanceof Error: {
1010+
// Non-APICallError with HTTP status — treat 5xx as retryable.
1011+
const typed = e as Error & ErrorWithStatus
1012+
const code =
1013+
typed.status ?? typed.statusCode ??
1014+
typed.response?.status ?? typed.response?.statusCode
1015+
if (typeof code === "number" && code >= 500)
1016+
return new MessageV2.APIError(
1017+
{ message: errorMessage(e), statusCode: code, isRetryable: true },
1018+
{ cause: e },
1019+
).toObject()
1020+
// Fallback: status may be embedded in a JSON error message.
1021+
try {
1022+
const obj = JSON.parse(e.message) as ErrorWithStatus
1023+
const jsonCode = obj?.status ?? obj?.statusCode
1024+
if (typeof jsonCode === "number" && jsonCode >= 500) {
1025+
const msg = typeof obj?.message === "string" ? obj.message : errorMessage(e)
1026+
return new MessageV2.APIError(
1027+
{ message: msg, statusCode: jsonCode, isRetryable: true },
1028+
{ cause: e },
1029+
).toObject()
1030+
}
1031+
} catch {}
10021032
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
1033+
}
10031034
default:
10041035
try {
10051036
const parsed = ProviderError.parseStreamError(e)

packages/opencode/test/session/message-v2.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,67 @@ describe("session.message-v2.fromError", () => {
928928
})
929929
})
930930

931+
test("retries 5xx Error with statusCode property", () => {
932+
const err = new Error("Internal Server Error")
933+
;(err as any).statusCode = 502
934+
935+
const result = MessageV2.fromError(err, { providerID })
936+
937+
expect(MessageV2.APIError.isInstance(result)).toBe(true)
938+
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
939+
expect((result as MessageV2.APIError).data.statusCode).toBe(502)
940+
})
941+
942+
test("retries 5xx Error with status property", () => {
943+
const err = new Error("Bad Gateway")
944+
;(err as any).status = 503
945+
946+
const result = MessageV2.fromError(err, { providerID })
947+
948+
expect(MessageV2.APIError.isInstance(result)).toBe(true)
949+
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
950+
expect((result as MessageV2.APIError).data.statusCode).toBe(503)
951+
})
952+
953+
test("retries 5xx Error with response.status", () => {
954+
const err = new Error("Gateway Timeout")
955+
;(err as any).response = { status: 504 }
956+
957+
const result = MessageV2.fromError(err, { providerID })
958+
959+
expect(MessageV2.APIError.isInstance(result)).toBe(true)
960+
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
961+
expect((result as MessageV2.APIError).data.statusCode).toBe(504)
962+
})
963+
964+
test("retries 5xx from JSON-encoded Error message", () => {
965+
const err = new Error(JSON.stringify({ statusCode: 500, message: "server error" }))
966+
967+
const result = MessageV2.fromError(err, { providerID })
968+
969+
expect(MessageV2.APIError.isInstance(result)).toBe(true)
970+
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
971+
expect((result as MessageV2.APIError).data.statusCode).toBe(500)
972+
expect((result as MessageV2.APIError).data.message).toBe("server error")
973+
})
974+
975+
test("does not retry 4xx Error", () => {
976+
const err = new Error("Not Found")
977+
;(err as any).statusCode = 404
978+
979+
const result = MessageV2.fromError(err, { providerID })
980+
981+
expect(result.name).toBe("UnknownError")
982+
})
983+
984+
test("does not retry Error without statusCode", () => {
985+
const err = new Error("something went wrong")
986+
987+
const result = MessageV2.fromError(err, { providerID })
988+
989+
expect(result.name).toBe("UnknownError")
990+
})
991+
931992
test("classifies ZlibError from fetch as retryable APIError", () => {
932993
const zlibError = new Error(
933994
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',

0 commit comments

Comments
 (0)