mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 08:12:18 +00:00
fix(core): classify AISDK network failures as transport errors (#45840)
This commit is contained in:
parent
e12e04f482
commit
d2ee536c16
2 changed files with 108 additions and 4 deletions
|
|
@ -612,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
|||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: llmError,
|
||||
catch: (error) => llmError(error, "request"),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: llmError,
|
||||
onError: (error) => llmError(error, "read"),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
|
|
@ -744,7 +744,7 @@ function streamPartEvents(
|
|||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError(event.error))
|
||||
return Effect.fail(llmError(event.error, "read"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,9 +794,20 @@ function messageValue(input: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
function llmError(error: unknown) {
|
||||
function llmError(error: unknown, operation: "request" | "read") {
|
||||
if (error instanceof AIError) return error
|
||||
if (APICallError.isInstance(error)) return apiCallError(error)
|
||||
const network = networkFailure(error)
|
||||
if (network)
|
||||
return new AIError({
|
||||
reason: new TransportError({
|
||||
message: network.message.trim() === "" ? unknownErrorMessage(error) : network.message,
|
||||
cause: error,
|
||||
transport: "http",
|
||||
operation,
|
||||
code: network.code,
|
||||
}),
|
||||
})
|
||||
return new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: unknownErrorMessage(error),
|
||||
|
|
@ -806,6 +817,50 @@ function llmError(error: unknown) {
|
|||
})
|
||||
}
|
||||
|
||||
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
|
||||
// Bun network error list in handleFetchError; the messages are undici's fetch
|
||||
// TypeError and stream termination strings plus our SSE chunk timeout error.
|
||||
// Unrecognized shapes still retry via the UnknownProvider default; this match
|
||||
// only adds transport semantics (continuation eligibility, display).
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EPIPE",
|
||||
"ConnectionRefused",
|
||||
"ConnectionClosed",
|
||||
"FailedToOpenSocket",
|
||||
])
|
||||
const NETWORK_ERROR_MESSAGES = new Set([
|
||||
"fetch failed",
|
||||
"failed to fetch",
|
||||
"terminated",
|
||||
"other side closed",
|
||||
"sse read timed out",
|
||||
])
|
||||
|
||||
const NativeErrorShape = Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
cause: Schema.optionalKey(Schema.Unknown),
|
||||
})
|
||||
const decodeNativeErrorShape = Schema.decodeUnknownOption(NativeErrorShape)
|
||||
|
||||
function networkFailure(error: unknown, depth = 0): { message: string; code?: string } | undefined {
|
||||
if (depth > 4) return undefined
|
||||
const shape = Option.getOrUndefined(decodeNativeErrorShape(error))
|
||||
if (!shape) return undefined
|
||||
// Prefer the deepest match: wrappers like undici's "fetch failed" TypeError
|
||||
// carry the specific network code on their cause.
|
||||
const cause = networkFailure(shape.cause, depth + 1)
|
||||
if (cause) return cause
|
||||
if (shape.code !== undefined && (NETWORK_ERROR_CODES.has(shape.code) || shape.code.startsWith("UND_ERR")))
|
||||
return { message: shape.message, code: shape.code }
|
||||
if (NETWORK_ERROR_MESSAGES.has(shape.message.trim().toLowerCase()))
|
||||
return { message: shape.message, code: shape.code }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function apiCallError(error: APICallError) {
|
||||
const failure = RequestExecutor.httpFailure({
|
||||
message: providerErrorMessage(error),
|
||||
|
|
|
|||
|
|
@ -823,6 +823,55 @@ it.effect("retries status-less AI SDK transport failures", () =>
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies native fetch failures as request transport errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = Object.assign(new TypeError("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED" }),
|
||||
})
|
||||
const error = yield* streamFailure(cause)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: "ECONNREFUSED",
|
||||
})
|
||||
expect(error.message).toBe("connect ECONNREFUSED 127.0.0.1:443")
|
||||
expect(error.reason.cause).toBe(cause)
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies mid-stream socket drops as read transport errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const cause = Object.assign(new Error("terminated"), {
|
||||
cause: Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }),
|
||||
})
|
||||
const error = yield* streamFailure(cause, true)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "UND_ERR_SOCKET",
|
||||
})
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies the SSE chunk timeout as a read transport error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(new Error("SSE read timed out"), true)
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps unrecognized error codes on the unknown provider path", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(Object.assign(new Error("kaput"), { code: "E_SOMETHING_ELSE" }), true)
|
||||
expect(error.reason).toBeInstanceOf(UnknownProviderError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue