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

@ -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(