From d2ee536c16f7b5c6a678e4144d8a512bda19adb8 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:21:17 -0500 Subject: [PATCH] fix(core): classify AISDK network failures as transport errors (#45840) --- packages/core/src/aisdk.ts | 63 ++++++++++++++++++++++++++++++-- packages/core/test/aisdk.test.ts | 49 +++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index d44763d85ba..22a7d2d1b09 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -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), diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index a27eb9b6413..0de60087a29 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -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(