fix(ai): handle remaining provider failures

This commit is contained in:
Aiden Cline 2026-07-17 14:41:38 -05:00
commit 0eacf8d736
8 changed files with 332 additions and 40 deletions

View file

@ -3,6 +3,7 @@ export { Auth } from "./route/auth"
export { Provider } from "./provider" export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package" export { ProviderPackage } from "./provider-package"
export { classifyProviderFailure, isContextOverflow, isContextOverflowFailure } from "./provider-error" export { classifyProviderFailure, isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type { ProviderFailure } from "./provider-error"
export type { export type {
RouteModelInput, RouteModelInput,
RouteRoutedModelInput, RouteRoutedModelInput,

View file

@ -1,6 +1,6 @@
import { EventStreamCodec } from "@smithy/eventstream-codec" import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { Effect, Stream } from "effect" import { Effect, Option, Schema, Stream } from "effect"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { ProviderShared } from "./shared" import { ProviderShared } from "./shared"
@ -53,8 +53,13 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
}) })
cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength } cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength }
if (decoded.headers[":message-type"]?.value !== "event") continue const messageType = decoded.headers[":message-type"]?.value
const eventType = decoded.headers[":event-type"]?.value const eventType =
messageType === "event"
? decoded.headers[":event-type"]?.value
: messageType === "exception"
? decoded.headers[":exception-type"]?.value
: undefined
if (typeof eventType !== "string") continue if (typeof eventType !== "string") continue
const payload = utf8.decode(decoded.body) const payload = utf8.decode(decoded.body)
if (!payload) continue if (!payload) continue
@ -84,4 +89,52 @@ export const framing = (route: string): Framing.Definition<object> => ({
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))), frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
}) })
class StreamExceptionError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message)
}
}
// The AI SDK Bedrock decoder ignores AWS exception frames before its language
// model stream can expose them. Fail the byte stream first so the shared AI SDK
// adapter can classify the transport error instead of accepting a false finish.
export function monitorExceptions(response: Response) {
if (!response.body || !response.headers.get("content-type")?.includes("application/vnd.amazon.eventstream"))
return response
let state = initialFrameBuffer
const body = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
state = appendChunk(state, chunk)
while (state.buffer.length - state.offset >= 4) {
const view = state.buffer.subarray(state.offset)
const totalLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false)
if (view.length < totalLength) break
const decoded = eventCodec.decode(view.subarray(0, totalLength))
state = { buffer: state.buffer, offset: state.offset + totalLength }
const exceptionType = decoded.headers[":exception-type"]?.value
if (decoded.headers[":message-type"]?.value === "exception" && typeof exceptionType === "string") {
const payload = Option.getOrUndefined(
Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(utf8.decode(decoded.body)),
)
const message =
ProviderShared.isRecord(payload) && typeof payload.message === "string" ? payload.message : undefined
controller.error(new StreamExceptionError(message ?? `Bedrock ${exceptionType}`, exceptionType))
return
}
}
controller.enqueue(chunk)
},
}),
)
return new Response(body, {
headers: new Headers(response.headers),
status: response.status,
statusText: response.statusText,
})
}
export * as BedrockEventStream from "./bedrock-event-stream" export * as BedrockEventStream from "./bedrock-event-stream"

View file

@ -46,26 +46,40 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const CONTENT_POLICY_CODES = new Set(["content_filter", "content_policy_violation", "safety"])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const RATE_LIMIT_CODES = new Set(["resource_exhausted", "throttlingexception", "too_many_requests"])
const SERVER_CODES = new Set([ const SERVER_CODES = new Set([
"api_error", "api_error",
"internal",
"internal_error", "internal_error",
"internal_server_error",
"internalserverexception", "internalserverexception",
"modelstreamerrorexception", "modelstreamerrorexception",
"modeltimeoutexception",
"overloaded_error", "overloaded_error",
"response_error",
"server_error", "server_error",
"server_is_overloaded", "server_is_overloaded",
"serviceunavailableexception", "serviceunavailableexception",
]) ])
const INVALID_REQUEST_CODES = new Set([ const INVALID_REQUEST_CODES = new Set([
"invalid_argument",
"invalid_prompt", "invalid_prompt",
"invalid_request_error", "invalid_request_error",
"model_not_found",
"not_found",
"not_found_error",
"resourcenotfoundexception",
"request_too_large", "request_too_large",
"validationexception", "validationexception",
]) ])
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|throttl|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i const CONTENT_POLICY_TEXT =
/content[-_\s]?(?:filter|policy)|safety (?:filter|policy|rating)|blocked (?:by|due to) safety/i
const INVALID_REQUEST_TEXT = /validation (?:error|exception)/i
const SERVER_TEXT = /internal server error|service unavailable/i
export interface ProviderFailure { export interface ProviderFailure {
readonly message: string readonly message: string
@ -98,7 +112,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
texts.some(isContextOverflow)) texts.some(isContextOverflow))
) )
return new InvalidRequestReason({ ...common, classification: "context-overflow" }) return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (texts.some((text) => CONTENT_POLICY_TEXT.test(text))) return new ContentPolicyReason(common) if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || texts.some((text) => CONTENT_POLICY_TEXT.test(text)))
return new ContentPolicyReason(common)
if ( if (
codes.some((code) => QUOTA_CODES.has(code)) || codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && texts.some((text) => QUOTA_TEXT.test(text))) (input.status === 429 && texts.some((text) => QUOTA_TEXT.test(text)))
@ -106,12 +121,15 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
return new QuotaExceededReason(common) return new QuotaExceededReason(common)
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" }) if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" }) if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationReason({ ...common, kind: "invalid" }) if (codes.some((code) => code === "authentication_error" || code === "unauthenticated"))
if (codes.includes("permission_error")) return new AuthenticationReason({ ...common, kind: "invalid" })
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if ( if (
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception") codes.some(
(code) => code === "accessdeniedexception" || code === "permission_error" || code === "permission_denied",
)
) )
return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
if (codes.some((code) => code.includes("rate_limit") || RATE_LIMIT_CODES.has(code)))
return new RateLimitReason({ return new RateLimitReason({
...common, ...common,
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
@ -123,8 +141,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs, retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit, rateLimit: input.rateLimit,
}) })
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) if (codes.some((code) => INVALID_REQUEST_CODES.has(code)) || texts.some((text) => INVALID_REQUEST_TEXT.test(text)))
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))) return new InvalidRequestReason(common)
if (
codes.some((code) => SERVER_CODES.has(code) || code.includes("unavailable")) ||
texts.some((text) => SERVER_TEXT.test(text))
)
return new ProviderInternalReason({ return new ProviderInternalReason({
...common, ...common,
status: input.status, status: input.status,
@ -160,8 +182,15 @@ function providerCodes(value: string) {
const error = isRecord(decoded.error) ? decoded.error : undefined const error = isRecord(decoded.error) ? decoded.error : undefined
const response = isRecord(decoded.response) ? decoded.response : undefined const response = isRecord(decoded.response) ? decoded.response : undefined
const responseError = isRecord(response?.error) ? response.error : undefined const responseError = isRecord(response?.error) ? response.error : undefined
return [decoded.code, decoded.status, error?.code, error?.type, error?.status, responseError?.code, responseError?.type] return [
.filter((value): value is string => typeof value === "string") decoded.code,
decoded.status,
error?.code,
error?.type,
error?.status,
responseError?.code,
responseError?.type,
].filter((value): value is string => typeof value === "string")
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {

View file

@ -121,6 +121,7 @@ describe("RequestExecutor", () => {
yield* classify("Request rate increased too quickly") yield* classify("Request rate increased too quickly")
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}') yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
yield* classify('{"type":"error","error":{"code":"rate_limit_exceeded"}}') yield* classify('{"type":"error","error":{"code":"rate_limit_exceeded"}}')
yield* classify('{"code":"resource_exhausted"}')
}), }),
) )
@ -135,7 +136,6 @@ describe("RequestExecutor", () => {
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}') yield* classify('{"code":"service_unavailable"}')
}), }),
) )

View file

@ -26,6 +26,14 @@ describe("provider error classification", () => {
) )
}) })
test("does not treat incidental safety text as a content-policy failure", () => {
expect(classifyProviderFailure({ message: "Internal safety check failed", status: 500 })._tag).toBe(
"ProviderInternal",
)
expect(classifyProviderFailure({ message: "Blocked by safety policy", status: 400 })._tag).toBe("ContentPolicy")
expect(classifyProviderFailure({ message: "Blocked", status: 400, code: "SAFETY" })._tag).toBe("ContentPolicy")
})
test("classifies V1 plain-text rate limit fallbacks", () => { test("classifies V1 plain-text rate limit fallbacks", () => {
expect( expect(
[ [
@ -47,12 +55,48 @@ describe("provider error classification", () => {
).toEqual(["RateLimit", "RateLimit", "RateLimit", "RateLimit"]) ).toEqual(["RateLimit", "RateLimit", "RateLimit", "RateLimit"])
}) })
test("classifies V1 overloaded provider codes", () => { test("classifies canonical provider retry codes", () => {
expect( expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map( ['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
(message) => classifyProviderFailure({ message })._tag, (message) => classifyProviderFailure({ message })._tag,
), ),
).toEqual(["ProviderInternal", "ProviderInternal"]) ).toEqual(["RateLimit", "ProviderInternal"])
})
test("keeps temporary per-minute quota wording retryable", () => {
expect(classifyProviderFailure({ message: "You exceeded your per-minute quota", status: 429 })._tag).toBe(
"RateLimit",
)
})
test("classifies canonical Google error codes", () => {
expect(
["UNAUTHENTICATED", "PERMISSION_DENIED", "INVALID_ARGUMENT", "NOT_FOUND", "INTERNAL"].map(
(code) => classifyProviderFailure({ message: "Provider failed", code })._tag,
),
).toEqual(["Authentication", "Authentication", "InvalidRequest", "InvalidRequest", "ProviderInternal"])
})
test("classifies stripped Bedrock stream errors from their messages", () => {
expect(
["Internal server error", "Throttling exception", "Validation error: invalid input"].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "RateLimit", "InvalidRequest"])
})
test("classifies documented Bedrock exception codes", () => {
expect(
["accessDeniedException", "modelTimeoutException", "resourceNotFoundException"].map(
(code) => classifyProviderFailure({ message: "Bedrock failed", code })._tag,
),
).toEqual(["Authentication", "ProviderInternal", "InvalidRequest"])
})
test("classifies Anthropic not-found stream errors as invalid requests", () => {
expect(classifyProviderFailure({ message: "Model unavailable", code: "not_found_error" })._tag).toBe(
"InvalidRequest",
)
}) })
test("classifies nested provider codes when a top-level code is also present", () => { test("classifies nested provider codes when a top-level code is also present", () => {

View file

@ -6,6 +6,7 @@ import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers" import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse" import * as BedrockConverse from "../../src/protocols/bedrock-converse"
import { BedrockEventStream } from "../../src/protocols/bedrock-event-stream"
import { it } from "../lib/effect" import { it } from "../lib/effect"
import { fixedResponse } from "../lib/http" import { fixedResponse } from "../lib/http"
import { import {
@ -34,6 +35,16 @@ const eventFrame = (type: string, payload: object) =>
body: utf8Encoder.encode(JSON.stringify(payload)), body: utf8Encoder.encode(JSON.stringify(payload)),
}) })
const exceptionFrame = (type: string, payload: object) =>
codec.encode({
headers: {
":message-type": { type: "string", value: "exception" },
":exception-type": { type: "string", value: type },
":content-type": { type: "string", value: "application/json" },
},
body: utf8Encoder.encode(JSON.stringify(payload)),
})
const concat = (frames: ReadonlyArray<Uint8Array>) => { const concat = (frames: ReadonlyArray<Uint8Array>) => {
const total = frames.reduce((sum, frame) => sum + frame.length, 0) const total = frames.reduce((sum, frame) => sum + frame.length, 0)
const out = new Uint8Array(total) const out = new Uint8Array(total)
@ -48,6 +59,8 @@ const concat = (frames: ReadonlyArray<Uint8Array>) => {
const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) => const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) =>
concat(payloads.map(([type, payload]) => eventFrame(type, payload))) concat(payloads.map(([type, payload]) => eventFrame(type, payload)))
const exceptionStreamBody = (type: string, payload: object) => exceptionFrame(type, payload)
// Override the default SSE content-type with the binary event-stream type so // Override the default SSE content-type with the binary event-stream type so
// the cassette layer treats the body as bytes when recording. // the cassette layer treats the body as bytes when recording.
const fixedBytes = (bytes: Uint8Array) => const fixedBytes = (bytes: Uint8Array) =>
@ -357,10 +370,10 @@ describe("Bedrock Converse route", () => {
it.effect("classifies throttlingException as a rate limit", () => it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = concat([
["messageStart", { role: "assistant" }], eventStreamBody(["messageStart", { role: "assistant" }]),
["throttlingException", { message: "Slow down" }], exceptionStreamBody("throttlingException", { message: "Slow down" }),
) ])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip) const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" }) expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
@ -371,7 +384,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe( const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide( Effect.provide(
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])), fixedBytes(exceptionStreamBody("validationException", { message: "Input is too long for requested model" })),
), ),
Effect.flip, Effect.flip,
) )
@ -384,6 +397,35 @@ describe("Bedrock Converse route", () => {
}), }),
) )
it.effect("fails monitored AI SDK bodies on exception wire frames", () =>
Effect.gen(function* () {
const response = BedrockEventStream.monitorExceptions(
new Response(exceptionStreamBody("throttlingException", { message: "Slow down" }), {
headers: { "content-type": "application/vnd.amazon.eventstream" },
}),
)
const error = yield* Effect.tryPromise({
try: () => response.arrayBuffer(),
catch: (error) => error,
}).pipe(Effect.flip)
expect(error).toMatchObject({ code: "throttlingException", message: "Slow down" })
}),
)
it.effect("classifies serviceUnavailableException wire frames as provider failures", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(exceptionStreamBody("serviceUnavailableException", { message: "Service unavailable" })),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Service unavailable" })
}),
)
it.effect("rejects requests with no auth path", () => it.effect("rejects requests with no auth path", () =>
Effect.gen(function* () { Effect.gen(function* () {
const unsignedModel = AmazonBedrock.configure({ const unsignedModel = AmazonBedrock.configure({

View file

@ -47,6 +47,7 @@ import {
type UsageInput, type UsageInput,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route" import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { BedrockEventStream } from "@opencode-ai/ai/protocols/bedrock-event-stream"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
@ -172,8 +173,9 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
...opts, ...opts,
timeout: false, timeout: false,
}) })
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res const response = pkg === "@ai-sdk/amazon-bedrock" ? BedrockEventStream.monitorExceptions(res) : res
return wrapSSE(res, chunkTimeout, chunkAbortCtl) if (!chunkAbortCtl || typeof chunkTimeout !== "number") return response
return wrapSSE(response, chunkTimeout, chunkAbortCtl)
} }
return options return options
@ -739,10 +741,12 @@ function llmError(method: string, error: unknown) {
const cause = error instanceof Error ? error.cause : undefined const cause = error instanceof Error ? error.cause : undefined
const failures = [error, cause] const failures = [error, cause]
const code = failures.map(machineCode).find((value) => value !== undefined) const code = failures.map(machineCode).find((value) => value !== undefined)
const providerCode = apiFailureCode(error)?.toLowerCase()
const reason = (() => { const reason = (() => {
if ( if (
error instanceof ChunkTimeoutError || error instanceof ChunkTimeoutError ||
failures.some((failure) => failure instanceof Error && failure.name === "TimeoutError") || failures.some((failure) => failure instanceof Error && failure.name === "TimeoutError") ||
providerCode === "timeout_error" ||
(code !== undefined && TRANSPORT_TIMEOUT_CODES.has(code)) (code !== undefined && TRANSPORT_TIMEOUT_CODES.has(code))
) )
return new TransportReason({ message: errorMessage(error), kind: code ?? "Timeout" }) return new TransportReason({ message: errorMessage(error), kind: code ?? "Timeout" })
@ -751,8 +755,7 @@ function llmError(method: string, error: unknown) {
return new TransportReason({ message: errorMessage(error), kind: code }) return new TransportReason({ message: errorMessage(error), kind: code })
const malformed = failures.find(isMalformedError) const malformed = failures.find(isMalformedError)
if (malformed) return new InvalidProviderOutputReason({ message: malformed.message }) if (malformed) return new InvalidProviderOutputReason({ message: malformed.message })
if (LoadAPIKeyError.isInstance(error)) if (LoadAPIKeyError.isInstance(error)) return new AuthenticationReason({ message: error.message, kind: "missing" })
return new AuthenticationReason({ message: error.message, kind: "missing" })
if (NoSuchModelError.isInstance(error)) return new InvalidRequestReason({ message: error.message }) if (NoSuchModelError.isInstance(error)) return new InvalidRequestReason({ message: error.message })
if ( if (
LoadSettingError.isInstance(error) || LoadSettingError.isInstance(error) ||
@ -780,12 +783,13 @@ function apiCallReason(error: APICallError) {
const malformed = isMalformedError(error.cause) ? error.cause : undefined const malformed = isMalformedError(error.cause) ? error.cause : undefined
if (error.statusCode !== undefined && error.statusCode < 400 && malformed) if (error.statusCode !== undefined && error.statusCode < 400 && malformed)
return new InvalidProviderOutputReason({ message: malformed.message }) return new InvalidProviderOutputReason({ message: malformed.message })
const code = apiFailureCode(error.data) ?? apiFailureCode(error.responseBody) const evidence = apiFailureEvidence(error.responseBody)
const code = apiFailureCode(error.data) ?? apiFailureCode(evidence)
if (error.statusCode === undefined) { if (error.statusCode === undefined) {
const reason = const reason =
code === undefined && error.responseBody === undefined code === undefined && evidence === undefined
? undefined ? undefined
: classifyProviderFailure({ message: error.message, code, evidence: error.responseBody }) : classifyProviderFailure({ message: error.message, code, evidence })
if (reason && reason._tag !== "UnknownProvider") return reason if (reason && reason._tag !== "UnknownProvider") return reason
if (error.isRetryable) return new TransportReason({ message: error.message }) if (error.isRetryable) return new TransportReason({ message: error.message })
return reason ?? new UnknownProviderReason({ message: error.message }) return reason ?? new UnknownProviderReason({ message: error.message })
@ -793,16 +797,13 @@ function apiCallReason(error: APICallError) {
const retryAfter = retryAfterMs(error.responseHeaders) const retryAfter = retryAfterMs(error.responseHeaders)
const reason = classifyProviderFailure({ const reason = classifyProviderFailure({
message: error.message, message: error.message,
evidence: error.responseBody, evidence,
status: error.statusCode, status: error.statusCode,
code, code,
retryAfterMs: retryAfter, retryAfterMs: retryAfter,
}) })
if (!error.isRetryable || (reason._tag !== "UnknownProvider" && reason._tag !== "InvalidRequest")) return reason if (!error.isRetryable || (reason._tag !== "UnknownProvider" && reason._tag !== "InvalidRequest")) return reason
if ( if (classifyProviderFailure({ message: error.message, evidence, code })._tag !== "UnknownProvider") return reason
classifyProviderFailure({ message: error.message, evidence: error.responseBody, code })._tag !== "UnknownProvider"
)
return reason
return new ProviderInternalReason({ message: error.message, status: error.statusCode, retryAfterMs: retryAfter }) return new ProviderInternalReason({ message: error.message, status: error.statusCode, retryAfterMs: retryAfter })
} }
@ -825,6 +826,7 @@ const TRANSPORT_CONNECTION_CODES = new Set([
"FAILEDTOOPENSOCKET", "FAILEDTOOPENSOCKET",
"UND_ERR_SOCKET", "UND_ERR_SOCKET",
]) ])
const FAILURE_EVIDENCE_LIMIT = 65_536
function field(error: unknown, name: string) { function field(error: unknown, name: string) {
return typeof error === "object" && error !== null ? Reflect.get(error, name) : undefined return typeof error === "object" && error !== null ? Reflect.get(error, name) : undefined
@ -843,6 +845,8 @@ function apiFailureCode(error: unknown): string | undefined {
const nested = field(error, "error") ?? field(field(error, "response"), "error") const nested = field(error, "error") ?? field(field(error, "response"), "error")
const nestedCode = nested === undefined || nested === error ? undefined : apiFailureCode(nested) const nestedCode = nested === undefined || nested === error ? undefined : apiFailureCode(nested)
if (nestedCode) return nestedCode if (nestedCode) return nestedCode
if (typeof field(error, "originalMessage") === "string" && typeof field(error, "originalStatusCode") === "number")
return "modelstreamerrorexception"
const code = field(error, "code") const code = field(error, "code")
if (typeof code === "string" || typeof code === "number") return String(code) if (typeof code === "string" || typeof code === "number") return String(code)
const status = field(error, "status") const status = field(error, "status")
@ -854,14 +858,18 @@ function apiFailureCode(error: unknown): string | undefined {
function apiFailureMessage(error: unknown): string { function apiFailureMessage(error: unknown): string {
const message = field(error, "message") const message = field(error, "message")
if (typeof message === "string") return message if (typeof message === "string") return message
const originalMessage = field(error, "originalMessage")
if (typeof originalMessage === "string") return originalMessage
const nested = field(error, "error") ?? field(field(error, "response"), "error") const nested = field(error, "error") ?? field(field(error, "response"), "error")
return nested === undefined || nested === error ? String(error) : apiFailureMessage(nested) return nested === undefined || nested === error ? String(error) : apiFailureMessage(nested)
} }
function apiFailureStatus(error: unknown): number | undefined { function apiFailureStatus(error: unknown): number | undefined {
const status = field(error, "status") const status = field(error, "status")
const statusCode = field(error, "statusCode")
const originalStatusCode = field(error, "originalStatusCode")
const code = field(error, "code") const code = field(error, "code")
const value = [status, code] const value = [status, statusCode, originalStatusCode, code]
.map((value) => (typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined)) .map((value) => (typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined))
.find((value) => value !== undefined && Number.isInteger(value) && value >= 400 && value < 600) .find((value) => value !== undefined && Number.isInteger(value) && value >= 400 && value < 600)
if (value !== undefined) return value if (value !== undefined) return value
@ -869,6 +877,10 @@ function apiFailureStatus(error: unknown): number | undefined {
return nested === undefined || nested === error ? undefined : apiFailureStatus(nested) return nested === undefined || nested === error ? undefined : apiFailureStatus(nested)
} }
function apiFailureEvidence(error: string | undefined) {
return error === undefined ? undefined : error.slice(0, FAILURE_EVIDENCE_LIMIT)
}
function retryAfterMs(headers: Record<string, string> | undefined) { function retryAfterMs(headers: Record<string, string> | undefined) {
if (!headers) return undefined if (!headers) return undefined
const normalized = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])) const normalized = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]))

View file

@ -492,12 +492,53 @@ it.effect("classifies structured AI SDK stream errors", () =>
}), }),
) )
it.effect("classifies structured stream messages without codes", () => it.effect("classifies AI Gateway errors with statusCode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* streamFailure( const error = yield* streamFailure(
streamingLanguage({ type: "error", error: { message: "Rate limit exceeded" } }), streamingLanguage({
type: "error",
error: { type: "internal_server_error", message: "Gateway failed", statusCode: 503 },
}),
) )
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 503 } })
}),
)
it.effect("classifies AI Gateway timeouts as retryable", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
streamingLanguage({
type: "error",
error: { type: "timeout_error", message: "Gateway timed out", statusCode: 408 },
}),
)
expect(error).toMatchObject({ reason: { _tag: "Transport", kind: "Timeout" } })
}),
)
it.effect("classifies stripped Bedrock model stream errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
streamingLanguage({
type: "error",
error: {
message: "The model stream failed",
originalMessage: "Upstream provider failed",
originalStatusCode: 424,
},
}),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 424 } })
}),
)
it.effect("classifies structured stream messages without codes", () =>
Effect.gen(function* () {
const error = yield* streamFailure(streamingLanguage({ type: "error", error: { message: "Rate limit exceeded" } }))
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } }) expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
}), }),
) )
@ -528,11 +569,81 @@ it.effect("classifies numeric string stream statuses", () =>
}), }),
) )
it.effect("classifies readable stream failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure({
...failingLanguage(new Error("unused")),
doStream: async () => ({
stream: new ReadableStream<LanguageModelV3StreamPart>({
start(controller) {
controller.error(Object.assign(new Error("connection reset"), { code: "ECONNRESET" }))
},
}),
request: { body: {} },
}),
})
expect(error).toMatchObject({ method: "readStream", reason: { _tag: "Transport", kind: "ECONNRESET" } })
}),
)
it.live("times out stalled SSE chunks", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(": connected\n\n"))
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
}),
),
(server) =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let wrappedFetch: typeof fetch | undefined
yield* aisdk.hook.sdk((event) => {
wrappedFetch = event.options.fetch
event.sdk = {}
})
yield* aisdk.hook.language((event) => {
event.language = {
...failingLanguage(new Error("unused")),
doStream: async () => {
const fetcher = wrappedFetch
if (!fetcher) throw new Error("AI SDK fetch was not configured")
const response = await fetcher(server.url, { method: "POST" })
if (!response.body) throw new Error("AI SDK response body was missing")
return {
stream: response.body.pipeThrough(
new TransformStream<Uint8Array, LanguageModelV3StreamPart>({ transform() {} }),
),
request: { body: {} },
}
},
}
})
const resolved = yield* aisdk.model(model("@ai-sdk/openai", { chunkTimeout: 10 }))
const request = LLM.request({ model: resolved, prompt: "Hello" })
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(request)
const error = yield* resolved.route
.streamPrepared(prepared.body, request, { http: { execute: () => Effect.die("unused") } })
.pipe(Stream.runDrain, Effect.flip)
expect(error).toMatchObject({ method: "readStream", reason: { _tag: "Transport", kind: "Timeout" } })
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("classifies missing AI SDK API keys", () => it.effect("classifies missing AI SDK API keys", () =>
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* streamFailure( const error = yield* streamFailure(failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })))
failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })),
)
expect(error).toMatchObject({ reason: { _tag: "Authentication", kind: "missing" } }) expect(error).toMatchObject({ reason: { _tag: "Authentication", kind: "missing" } })
}), }),