fix(ai): handle retryable SDK failures

This commit is contained in:
Aiden Cline 2026-07-17 11:21:15 -05:00
commit 7c655f6a61
5 changed files with 331 additions and 49 deletions

View file

@ -69,6 +69,8 @@ const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
export interface ProviderFailure {
readonly message: string
/** Provider text used only for classification and never retained on the resulting reason. */
readonly evidence?: string | undefined
readonly status?: number | undefined
readonly code?: string | undefined
readonly retryAfterMs?: number | undefined
@ -81,10 +83,11 @@ export interface ProviderFailure {
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] {
const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
const evidence = input.evidence ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message), ...providerCodes(evidence)]
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
const text = body || input.message
const texts = [body, input.message, evidence]
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
@ -92,12 +95,14 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
isContextOverflow(body) ||
isContextOverflow(input.message))
texts.some(isContextOverflow))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (texts.some((text) => CONTENT_POLICY_TEXT.test(text))) return new ContentPolicyReason(common)
if (
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && texts.some((text) => QUOTA_TEXT.test(text)))
)
return new QuotaExceededReason(common)
if (input.status === 401) return new AuthenticationReason({ ...common, kind: "invalid" })
if (input.status === 403) return new AuthenticationReason({ ...common, kind: "insufficient-permissions" })
@ -112,7 +117,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (RATE_LIMIT_TEXT.test(text))
if (texts.some((text) => RATE_LIMIT_TEXT.test(text)))
return new RateLimitReason({
...common,
retryAfterMs: input.retryAfterMs,
@ -153,7 +158,10 @@ function providerCodes(value: string) {
const decoded = Option.getOrUndefined(decodeJson(value))
if (!isRecord(decoded)) return []
const error = isRecord(decoded.error) ? decoded.error : undefined
return [decoded.code, error?.code, error?.type].filter((value): value is string => typeof value === "string")
const response = isRecord(decoded.response) ? decoded.response : undefined
const responseError = isRecord(response?.error) ? response.error : undefined
return [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> {

View file

@ -9,7 +9,8 @@ export type {
Interface as LLMClientShape,
Service as LLMClientService,
} from "./client"
export * from "./executor"
export { RequestExecutor, Service, fetchLayer, layer } from "./executor"
export type { Interface } from "./executor"
export { Auth } from "./auth"
export { AuthOptions } from "./auth-options"
export { Endpoint } from "./endpoint"

View file

@ -61,8 +61,9 @@ describe("provider error classification", () => {
'{"code":"bad_request","error":{"code":"usage_not_included"}}',
'{"code":"bad_request","error":{"code":"server_error"}}',
'{"code":"bad_request","error":{"type":"invalid_request_error"}}',
'{"type":"response.failed","response":{"error":{"code":"server_error"}}}',
].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest", "ProviderInternal"])
})
test("keeps unknown and malformed provider payloads non-retryable", () => {

View file

@ -1,38 +1,6 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "./effect/app-node"
import type {
JSONSchema7,
JSONValue,
LanguageModelV3,
LanguageModelV3CallOptions,
LanguageModelV3FinishReason,
LanguageModelV3FunctionTool,
LanguageModelV3Message,
LanguageModelV3Prompt,
LanguageModelV3StreamPart,
LanguageModelV3ToolChoice,
SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
AuthenticationReason,
classifyProviderFailure,
FinishReason,
InvalidRequestReason,
InvalidProviderOutputReason,
LLMEvent,
LLMError,
Model,
ProviderID,
ProviderMetadata,
ToolResultValue,
TransportReason,
UnknownProviderReason,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import {
APICallError,
EmptyResponseBodyError,
@ -46,7 +14,38 @@ import {
NoSuchModelError,
TypeValidationError,
UnsupportedFunctionalityError,
type JSONSchema7,
type JSONValue,
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3FinishReason,
type LanguageModelV3FunctionTool,
type LanguageModelV3Message,
type LanguageModelV3Prompt,
type LanguageModelV3StreamPart,
type LanguageModelV3ToolChoice,
type SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
AuthenticationReason,
classifyProviderFailure,
FinishReason,
InvalidRequestReason,
InvalidProviderOutputReason,
LLMEvent,
LLMError,
Model,
ProviderID,
ProviderInternalReason,
ProviderMetadata,
ToolResultValue,
TransportReason,
UnknownProviderReason,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
@ -762,9 +761,12 @@ function llmError(method: string, error: unknown) {
UnsupportedFunctionalityError.isInstance(error)
)
return new InvalidRequestReason({ message: error.message })
const providerCode = apiFailureCode(error)
if (providerCode)
return classifyProviderFailure({ message: apiFailureMessage(error), code: providerCode })
const providerReason = classifyProviderFailure({
message: apiFailureMessage(error),
code: apiFailureCode(error),
status: apiFailureStatus(error),
})
if (providerReason._tag !== "UnknownProvider") return providerReason
return new UnknownProviderReason({ message: errorMessage(error) })
})()
return new LLMError({
@ -780,15 +782,28 @@ function apiCallReason(error: APICallError) {
return new InvalidProviderOutputReason({ message: malformed.message })
const code = apiFailureCode(error.data) ?? apiFailureCode(error.responseBody)
if (error.statusCode === undefined) {
if (code) return classifyProviderFailure({ message: error.message, code })
const reason =
code === undefined && error.responseBody === undefined
? undefined
: classifyProviderFailure({ message: error.message, code, evidence: error.responseBody })
if (reason && reason._tag !== "UnknownProvider") return reason
if (error.isRetryable) return new TransportReason({ message: error.message })
return new UnknownProviderReason({ message: error.message })
return reason ?? new UnknownProviderReason({ message: error.message })
}
return classifyProviderFailure({
const retryAfter = retryAfterMs(error.responseHeaders)
const reason = classifyProviderFailure({
message: error.message,
evidence: error.responseBody,
status: error.statusCode,
code,
retryAfterMs: retryAfter,
})
if (!error.isRetryable || (reason._tag !== "UnknownProvider" && reason._tag !== "InvalidRequest")) return reason
if (
classifyProviderFailure({ message: error.message, evidence: error.responseBody, code })._tag !== "UnknownProvider"
)
return reason
return new ProviderInternalReason({ message: error.message, status: error.statusCode, retryAfterMs: retryAfter })
}
const TRANSPORT_TIMEOUT_CODES = new Set([
@ -798,6 +813,8 @@ const TRANSPORT_TIMEOUT_CODES = new Set([
"UND_ERR_HEADERS_TIMEOUT",
])
const TRANSPORT_CONNECTION_CODES = new Set([
"CONNECTIONCLOSED",
"CONNECTIONREFUSED",
"EAI_AGAIN",
"ECONNREFUSED",
"ECONNRESET",
@ -805,6 +822,7 @@ const TRANSPORT_CONNECTION_CODES = new Set([
"ENETUNREACH",
"ENOTFOUND",
"EPIPE",
"FAILEDTOOPENSOCKET",
"UND_ERR_SOCKET",
])
@ -822,19 +840,46 @@ function apiFailureCode(error: unknown): string | undefined {
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(error))
return apiFailureCode(decoded)
}
const nested = field(error, "error") ?? field(field(error, "response"), "error")
const nestedCode = nested === undefined || nested === error ? undefined : apiFailureCode(nested)
if (nestedCode) return nestedCode
const code = field(error, "code")
if (typeof code === "string") return code
if (typeof code === "string" || typeof code === "number") return String(code)
const status = field(error, "status")
if (typeof status === "string") return status
const type = field(error, "type")
if (typeof type === "string" && type !== "error") return type
const nested = field(error, "error")
return nested === undefined || nested === error ? undefined : apiFailureCode(nested)
}
function apiFailureMessage(error: unknown) {
function apiFailureMessage(error: unknown): string {
const message = field(error, "message")
if (typeof message === "string") return message
const nested = field(field(error, "error"), "message")
return typeof nested === "string" ? nested : String(error)
const nested = field(error, "error") ?? field(field(error, "response"), "error")
return nested === undefined || nested === error ? String(error) : apiFailureMessage(nested)
}
function apiFailureStatus(error: unknown): number | undefined {
const status = field(error, "status")
const code = field(error, "code")
const value = [status, code]
.map((value) => (typeof value === "number" ? value : typeof value === "string" ? Number(value) : undefined))
.find((value) => value !== undefined && Number.isInteger(value) && value >= 400 && value < 600)
if (value !== undefined) return value
const nested = field(error, "error") ?? field(field(error, "response"), "error")
return nested === undefined || nested === error ? undefined : apiFailureStatus(nested)
}
function retryAfterMs(headers: Record<string, string> | undefined) {
if (!headers) return undefined
const normalized = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]))
const millis = Number(normalized["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = normalized["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now())
}
function errorMessage(error: unknown) {

View file

@ -1,6 +1,8 @@
import {
APICallError,
InvalidPromptError,
InvalidResponseDataError,
LoadAPIKeyError,
type LanguageModelV3,
type LanguageModelV3CallOptions,
type LanguageModelV3StreamPart,
@ -8,7 +10,7 @@ import {
import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, Message } from "@opencode-ai/ai"
import { InvalidRequestReason, LLM, LLMError, Message } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect, Stream } from "effect"
@ -289,6 +291,26 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
)
it.effect("classifies AI SDK API failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Bad Request",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 400,
responseBody: '{"error":{"code":"insufficient_quota","detail":"quota-body-secret"}}',
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "QuotaExceeded" } })
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
expect(JSON.stringify(error)).not.toContain("quota-body-secret")
}),
)
it.effect("keeps retryable quota responses terminal", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
@ -306,6 +328,132 @@ it.effect("classifies AI SDK API failures", () =>
}),
)
it.effect("uses response bodies as transient classification evidence", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Bad Request",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 400,
responseBody: "Input is too long for requested model overflow-body-secret",
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest", classification: "context-overflow" } })
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
expect(JSON.stringify(error)).not.toContain("overflow-body-secret")
}),
)
it.effect("honors retryable AI SDK request timeouts", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "HTTP 408",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 408,
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 408 } })
}),
)
it.effect("honors retryable AI SDK conflicts", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "HTTP 409",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 409,
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 409 } })
}),
)
it.effect("keeps semantic invalid requests terminal on retryable statuses", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Conflict",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 409,
responseBody: '{"error":{"code":"request_too_large"}}',
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
}),
)
it.effect("retries unknown coded conflicts", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Conflict",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 409,
responseBody: '{"error":{"code":"conflict"}}',
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 409 } })
}),
)
it.effect("preserves AI SDK retry delays without HTTP diagnostics", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Too Many Requests",
url: "https://provider.test/v1",
requestBodyValues: {},
statusCode: 429,
responseHeaders: { "Retry-After-Ms": "250" },
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "RateLimit", retryAfterMs: 250 } })
expect("http" in error.reason ? error.reason.http : undefined).toBeUndefined()
}),
)
it.effect("classifies statusless retryable API failures as transport failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(
new APICallError({
message: "Cannot connect to API",
url: "https://provider.test/v1",
requestBodyValues: {},
isRetryable: true,
}),
),
)
expect(error).toMatchObject({ reason: { _tag: "Transport" } })
}),
)
it.effect("classifies AI SDK timeouts", () =>
Effect.gen(function* () {
const timeout = yield* streamFailure(failingLanguage(new DOMException("timed out", "TimeoutError")))
@ -324,6 +472,16 @@ it.effect("classifies AI SDK connection failures", () =>
}),
)
it.effect("classifies Bun connection failures", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(Object.assign(new Error("connection refused"), { code: "ConnectionRefused" })),
)
expect(error).toMatchObject({ reason: { _tag: "Transport", kind: "CONNECTIONREFUSED" } })
}),
)
it.effect("classifies structured AI SDK stream errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
@ -334,6 +492,75 @@ it.effect("classifies structured AI SDK stream errors", () =>
}),
)
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" } })
}),
)
it.effect("classifies nested OpenAI Responses stream errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
streamingLanguage({
type: "error",
error: {
type: "response.failed",
response: { error: { code: "server_error", message: "Provider failed" } },
},
}),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", message: "Provider failed" } })
}),
)
it.effect("classifies numeric string stream statuses", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
streamingLanguage({ type: "error", error: { code: "503", message: "Unavailable" } }),
)
expect(error).toMatchObject({ reason: { _tag: "ProviderInternal", status: 503 } })
}),
)
it.effect("classifies missing AI SDK API keys", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(new LoadAPIKeyError({ message: "API key is missing" })),
)
expect(error).toMatchObject({ reason: { _tag: "Authentication", kind: "missing" } })
}),
)
it.effect("classifies invalid AI SDK prompts", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
failingLanguage(new InvalidPromptError({ prompt: [], message: "unsupported prompt" })),
)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
}),
)
it.effect("preserves existing LLM errors", () =>
Effect.gen(function* () {
const original = new LLMError({
module: "test",
method: "run",
reason: new InvalidRequestReason({ message: "invalid" }),
})
const error = yield* streamFailure(failingLanguage(original))
expect(error).toBe(original)
}),
)
it.effect("classifies malformed AI SDK response causes", () =>
Effect.gen(function* () {
const error = yield* streamFailure(