fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
co-authored by
Aiden Cline
parent
d6fc5f414b
commit
7123aad5a8
@@ -15,6 +15,13 @@ import type { SystemError } from "bun"
|
|||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
|
||||||
|
interface FetchDecompressionError extends Error {
|
||||||
|
code: "ZlibError"
|
||||||
|
errno: number
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
export namespace MessageV2 {
|
export namespace MessageV2 {
|
||||||
export function isMedia(mime: string) {
|
export function isMedia(mime: string) {
|
||||||
return mime.startsWith("image/") || mime === "application/pdf"
|
return mime.startsWith("image/") || mime === "application/pdf"
|
||||||
@@ -906,7 +913,10 @@ export namespace MessageV2 {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
|
export function fromError(
|
||||||
|
e: unknown,
|
||||||
|
ctx: { providerID: ProviderID; aborted?: boolean },
|
||||||
|
): NonNullable<Assistant["error"]> {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case e instanceof DOMException && e.name === "AbortError":
|
case e instanceof DOMException && e.name === "AbortError":
|
||||||
return new MessageV2.AbortedError(
|
return new MessageV2.AbortedError(
|
||||||
@@ -938,6 +948,21 @@ export namespace MessageV2 {
|
|||||||
},
|
},
|
||||||
{ cause: e },
|
{ cause: e },
|
||||||
).toObject()
|
).toObject()
|
||||||
|
case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
|
||||||
|
if (ctx.aborted) {
|
||||||
|
return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
|
||||||
|
}
|
||||||
|
return new MessageV2.APIError(
|
||||||
|
{
|
||||||
|
message: "Response decompression failed",
|
||||||
|
isRetryable: true,
|
||||||
|
metadata: {
|
||||||
|
code: (e as FetchDecompressionError).code,
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ cause: e },
|
||||||
|
).toObject()
|
||||||
case APICallError.isInstance(e):
|
case APICallError.isInstance(e):
|
||||||
const parsed = ProviderError.parseAPICallError({
|
const parsed = ProviderError.parseAPICallError({
|
||||||
providerID: ctx.providerID,
|
providerID: ctx.providerID,
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export namespace SessionProcessor {
|
|||||||
error: e,
|
error: e,
|
||||||
stack: JSON.stringify(e.stack),
|
stack: JSON.stringify(e.stack),
|
||||||
})
|
})
|
||||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
|
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
|
||||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||||
needsCompaction = true
|
needsCompaction = true
|
||||||
Bus.publish(Session.Event.Error, {
|
Bus.publish(Session.Event.Error, {
|
||||||
|
|||||||
@@ -927,4 +927,31 @@ describe("session.message-v2.fromError", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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()',
|
||||||
|
)
|
||||||
|
;(zlibError as any).code = "ZlibError"
|
||||||
|
;(zlibError as any).errno = 0
|
||||||
|
;(zlibError as any).path = ""
|
||||||
|
|
||||||
|
const result = MessageV2.fromError(zlibError, { providerID })
|
||||||
|
|
||||||
|
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||||
|
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||||
|
expect((result as MessageV2.APIError).data.message).toInclude("decompression")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("classifies ZlibError as AbortedError when abort context is provided", () => {
|
||||||
|
const zlibError = new Error(
|
||||||
|
'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
|
||||||
|
)
|
||||||
|
;(zlibError as any).code = "ZlibError"
|
||||||
|
;(zlibError as any).errno = 0
|
||||||
|
|
||||||
|
const result = MessageV2.fromError(zlibError, { providerID, aborted: true })
|
||||||
|
|
||||||
|
expect(result.name).toBe("MessageAbortedError")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ describe("session.retry.retryable", () => {
|
|||||||
|
|
||||||
expect(SessionRetry.retryable(error)).toBeUndefined()
|
expect(SessionRetry.retryable(error)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("retries ZlibError decompression failures", () => {
|
||||||
|
const error = new MessageV2.APIError({
|
||||||
|
message: "Response decompression failed",
|
||||||
|
isRetryable: true,
|
||||||
|
metadata: { code: "ZlibError" },
|
||||||
|
}).toObject() as MessageV2.APIError
|
||||||
|
|
||||||
|
const retryable = SessionRetry.retryable(error)
|
||||||
|
expect(retryable).toBeDefined()
|
||||||
|
expect(retryable).toBe("Response decompression failed")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("session.message-v2.fromError", () => {
|
describe("session.message-v2.fromError", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user