fix(ai): classify provider failures consistently

This commit is contained in:
Aiden Cline 2026-07-17 09:45:59 -05:00
commit 0960ef48fd
9 changed files with 398 additions and 46 deletions

View file

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

View file

@ -57,7 +57,12 @@ const SERVER_CODES = new Set([
"server_is_overloaded",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
const INVALID_REQUEST_CODES = new Set([
"invalid_prompt",
"invalid_request_error",
"request_too_large",
"validationexception",
])
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
@ -87,7 +92,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
isContextOverflow(text))
isContextOverflow(body) ||
isContextOverflow(input.message))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
@ -112,6 +118,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalReason({
...common,
@ -131,7 +138,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
status: input.status,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (
input.status === 400 ||
input.status === 404 ||

View file

@ -47,15 +47,14 @@ const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
export const redactHeaders = (headers: Headers.Input, redactedNames: ReadonlyArray<string | RegExp> = []) =>
Object.fromEntries(
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
name,
String(value),
]),
Object.entries(Headers.redact(Headers.fromInput(headers), [...redactedNames, SENSITIVE_NAME])).map(
([name, value]) => [name, String(value)],
),
)
const redactUrl = (value: string) => {
export const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
@ -151,7 +150,12 @@ const responseDetails = (
headers: redactHeaders(response.headers, redactedNames),
})
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
interface RedactionRequest {
readonly url: string
readonly headers: Headers.Input
}
const secretValues = (request: RedactionRequest) => {
const values = new Set<string>()
const add = (value: string) => {
if (value.length < 4) return
@ -176,13 +180,13 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
const redactBody = (body: string, request: RedactionRequest) =>
Array.from(secretValues(request)).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
export const redactResponseBody = (body: string | void, request: RedactionRequest) => {
if (body === undefined) return {}
const redacted = redactBody(body, request)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
@ -198,7 +202,7 @@ const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly redactedNames: ReadonlyArray<string | RegExp>
readonly body: ReturnType<typeof responseBody>
readonly body: ReturnType<typeof redactResponseBody>
readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
@ -219,7 +223,7 @@ const statusError =
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
const details = redactResponseBody(body, request)
return yield* new LLMError({
module: "RequestExecutor",
method: "execute",
@ -240,7 +244,32 @@ const statusError =
})
})
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const TIMEOUT_CODES = new Set([
"ETIMEDOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
])
const errorCause = (error: unknown) => {
if (HttpClientError.isHttpClientError(error) && "cause" in error.reason) return error.reason.cause
return error instanceof Error ? error.cause : undefined
}
const errorCode = (error: unknown) => {
if (typeof error !== "object" || error === null) return undefined
const code = Reflect.get(error, "code")
return typeof code === "string" ? code.toUpperCase() : undefined
}
const errorMessage = (error: unknown, fallback: string) => {
const cause = errorCause(error)
if (cause instanceof Error) return cause.message
if (error instanceof Error) return error.message
return fallback
}
export const mapHttpClientError = (error: unknown, redactedNames: ReadonlyArray<string | RegExp>) => {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
@ -257,23 +286,30 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
}),
})
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
}
const cause = errorCause(error)
const code = errorCode(cause) ?? errorCode(error)
const request = HttpClientError.isHttpClientError(error) ? error.request : undefined
if (
Cause.isTimeoutError(error) ||
Cause.isTimeoutError(cause) ||
(error instanceof Error && error.name === "TimeoutError") ||
(cause instanceof Error && cause.name === "TimeoutError") ||
(code !== undefined && TIMEOUT_CODES.has(code))
)
return transportError({ message: errorMessage(error, "HTTP transport timed out"), kind: code ?? "Timeout", request })
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" })
return transportError({ message: errorMessage(error, "HTTP transport failed"), kind: code })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
return transportError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
message: error.reason.description ?? errorMessage(error, "HTTP transport failed"),
kind: code ?? error.reason._tag,
request,
})
}
return transportError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
message: errorMessage(error, `HTTP transport failed: ${error.reason._tag}`),
kind: code ?? error.reason._tag,
request,
})
}
@ -287,7 +323,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
const redactedNames = yield* Headers.CurrentRedactedNames
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
.pipe(
Effect.mapError((error) => mapHttpClientError(error, redactedNames)),
Effect.flatMap(statusError(request, redactedNames)),
)
})
return Service.of({
execute: executeOnce,

View file

@ -6,6 +6,7 @@ import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import { mapHttpClientError } from "../executor"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@ -134,14 +135,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
Stream.unwrap(
Effect.map(Headers.CurrentRedactedNames, (redactedNames) =>
prepared.framing.frame(
response.stream.pipe(Stream.mapError((error) => mapHttpClientError(error, redactedNames))),
),
),
),

View file

@ -63,14 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
export const truncatedStream = (chunks: ReadonlyArray<string>, error: unknown = new Error("connection reset")) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
controller.error(error)
},
})
return input.respond(stream, { headers: SSE_HEADERS })

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src"
import { HttpContext, HttpRequestDetails, isContextOverflow } from "../src"
import { classifyProviderFailure } from "../src/provider-error"
describe("provider error classification", () => {
@ -7,6 +7,25 @@ describe("provider error classification", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
})
test("checks overflow evidence in the message when the response body is uninformative", () => {
expect(
classifyProviderFailure({
message: "Input is too long for requested model",
status: 400,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://provider.test", headers: {} }),
body: "{}",
}),
}),
).toMatchObject({ classification: "context-overflow" })
})
test("lets semantic invalid-request codes override server status", () => {
expect(classifyProviderFailure({ message: "too large", status: 500, code: "request_too_large" })._tag).toBe(
"InvalidRequest",
)
})
test("classifies V1 plain-text rate limit fallbacks", () => {
expect(
[

View file

@ -651,7 +651,22 @@ describe("OpenAI Chat route", () => {
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect(error).toMatchObject({ reason: { _tag: "Transport", message: "connection reset" } })
}),
)
it.effect("classifies response body timeouts as transport timeouts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
truncatedStream([], Object.assign(new Error("body timed out"), { code: "UND_ERR_BODY_TIMEOUT" })),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "Transport", kind: "UND_ERR_BODY_TIMEOUT", message: "body timed out" },
})
}),
)