Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import { APICallError } from "ai"
import { STATUS_CODES } from "http"
import z from "zod"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"

// Canonical, serializable classification of a provider/API failure. Populated
// once where the payload is parsed and carried on APIError.data.providerFailure
// so retry/UI/observability read one field instead of re-sniffing strings.
// (free_quota_exhausted stays a retry-time concept — it depends on retry-after
// headers and wall-clock resetAt — and is intentionally not a providerFailure
// kind; context overflow keeps its own ContextOverflowError name.)
export const ProviderFailureKind = z.enum([
"auth",
"rate_limit",
"quota_exhausted",
"server_overload",
"invalid_request",
"transport_disconnect",
"decompression",
"unknown",
])
export type ProviderFailureKind = z.infer<typeof ProviderFailureKind>

function apiCallErrorKind(statusCode: number | undefined, code: string | undefined): ProviderFailureKind {
if (code === "insufficient_quota" || code === "usage_not_included") return "quota_exhausted"
if (code === "invalid_prompt") return "invalid_request"
if (code === "server_error" || code === "server_is_overloaded") return "server_overload"
if (statusCode === 401 || statusCode === 403) return "auth"
if (statusCode === 429) return "rate_limit"
// 400/422 are client-side request rejections. Overflow 4xx is already routed
// to context_overflow before this runs, so what reaches here is a genuine
// invalid request rather than an over-long prompt.
if (statusCode === 400 || statusCode === 422) return "invalid_request"
if (statusCode !== undefined && statusCode >= 500) return "server_overload"
return "unknown"
}
Comment thread
Astro-Han marked this conversation as resolved.

// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
const OVERFLOW_PATTERNS = [
Expand Down Expand Up @@ -117,6 +150,8 @@ export type ParsedStreamError =
message: string
isRetryable: boolean
responseBody: string
kind?: ProviderFailureKind
code?: string
}

export function parseStreamError(input: unknown): ParsedStreamError | undefined {
Expand All @@ -132,6 +167,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
if (body.type !== "error") return

const error = isRecord(body.error) ? body.error : undefined
const code = typeof error?.code === "string" ? error.code : undefined
switch (error?.code) {
case "context_length_exceeded":
return {
Expand All @@ -145,20 +181,26 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
message: "Quota exceeded. Check your plan and billing details.",
isRetryable: false,
responseBody,
kind: "quota_exhausted",
code,
}
case "usage_not_included":
return {
type: "api_error",
message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.",
isRetryable: false,
responseBody,
kind: "quota_exhausted",
code,
}
case "invalid_prompt":
return {
type: "api_error",
message: typeof error.message === "string" ? error.message : "Invalid prompt.",
isRetryable: false,
responseBody,
kind: "invalid_request",
code,
}
case "server_is_overloaded":
case "server_error":
Expand All @@ -167,6 +209,8 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
message: typeof error.message === "string" ? error.message : "Server error.",
isRetryable: true,
responseBody,
kind: "server_overload",
code,
}
}
}
Expand All @@ -185,6 +229,8 @@ export type ParsedAPICallError =
responseHeaders?: Record<string, string>
responseBody?: string
metadata?: Record<string, string>
kind?: ProviderFailureKind
code?: string
}

export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
Expand All @@ -199,6 +245,7 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal
}

const metadata = input.error.url ? { url: input.error.url } : undefined
const code = typeof body?.error?.code === "string" ? body.error.code : undefined
return {
type: "api_error",
message: m,
Expand All @@ -207,6 +254,8 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal
responseHeaders: input.error.responseHeaders,
responseBody: input.error.responseBody,
metadata,
kind: apiCallErrorKind(input.error.statusCode, code),
code,
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or, sql } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider"
import { ProviderFailureKind } from "@/provider/error"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import { formatToolFailureForModel } from "./tool-failure"
Expand Down Expand Up @@ -162,6 +163,15 @@ export const APIError = NamedError.create(
// Optional for backwards compat with historical JSON; classifyRetry guards on
// providerID === ProviderID.opencode and falls to `unknown` when absent.
providerID: z.string().optional(),
// Canonical provider-failure classification, computed once at fromError time.
// Optional for back-compat with rows persisted before this field existed;
// consumers fall back to message sniffing when it is absent.
providerFailure: z
.object({
kind: ProviderFailureKind,
code: z.string().optional(),
})
.optional(),
}),
)
export type APIError = z.infer<typeof APIError.Schema>
Expand Down Expand Up @@ -1336,6 +1346,7 @@ export function fromError(
code: transport.code,
message: (e as Error).message || "",
},
providerFailure: { kind: "transport_disconnect", code: transport.code },
},
{ cause: e },
).toObject()
Expand All @@ -1352,6 +1363,7 @@ export function fromError(
code: (e as FetchDecompressionError).code,
message: e.message,
},
providerFailure: { kind: "decompression", code: (e as FetchDecompressionError).code },
},
{ cause: e },
).toObject()
Expand Down Expand Up @@ -1379,6 +1391,7 @@ export function fromError(
responseBody: parsed.responseBody,
metadata: parsed.metadata,
providerID: ctx.providerID,
providerFailure: parsed.kind ? { kind: parsed.kind, code: parsed.code } : undefined,
},
{ cause: e },
).toObject()
Expand All @@ -1405,6 +1418,7 @@ export function fromError(
isRetryable: parsed.isRetryable,
responseBody: parsed.responseBody,
providerID: ctx.providerID,
providerFailure: parsed.kind ? { kind: parsed.kind, code: parsed.code } : undefined,
},
{
cause: e,
Expand Down
114 changes: 113 additions & 1 deletion packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,16 +1660,19 @@ describe("session.message-v2.fromError", () => {
{
code: "insufficient_quota",
message: "Quota exceeded. Check your plan and billing details.",
kind: "quota_exhausted",
},
{
code: "usage_not_included",
message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.",
kind: "quota_exhausted",
},
{
code: "invalid_prompt",
message: "Invalid prompt from test",
kind: "invalid_request",
},
]
] as const

cases.forEach((item) => {
const input = {
Expand All @@ -1688,6 +1691,7 @@ describe("session.message-v2.fromError", () => {
isRetryable: false,
responseBody: JSON.stringify(input),
providerID,
providerFailure: { kind: item.kind, code: item.code },
},
})
})
Expand All @@ -1714,6 +1718,7 @@ describe("session.message-v2.fromError", () => {
isRetryable: true,
responseBody: JSON.stringify(body),
providerID,
providerFailure: { kind: "server_overload", code: "server_error" },
},
})
})
Expand Down Expand Up @@ -1741,6 +1746,7 @@ describe("session.message-v2.fromError", () => {
isRetryable: true,
responseBody: JSON.stringify(body),
providerID,
providerFailure: { kind: "server_overload", code: "server_error" },
},
})
})
Expand Down Expand Up @@ -1901,6 +1907,112 @@ describe("session.message-v2.fromError", () => {
expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
})

test("populates providerFailure for transport disconnects", () => {
const error = Object.assign(new Error("connect ECONNREFUSED"), {
code: "ECONNREFUSED",
syscall: "connect",
})

const result = MessageV2.fromError(error, { providerID })

expect((result as MessageV2.APIError).data.providerFailure).toStrictEqual({
kind: "transport_disconnect",
code: "ECONNREFUSED",
})
})

test("populates providerFailure for decompression failures", () => {
const zlibError = Object.assign(
new Error('ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages".'),
{ code: "ZlibError", errno: 0, path: "" },
)

const result = MessageV2.fromError(zlibError, { providerID })

expect((result as MessageV2.APIError).data.providerFailure).toStrictEqual({
kind: "decompression",
code: "ZlibError",
})
})

test("classifies APICallError status codes into providerFailure kinds", () => {
const cases = [
{ statusCode: 400, kind: "invalid_request" },
{ statusCode: 401, kind: "auth" },
{ statusCode: 403, kind: "auth" },
{ statusCode: 422, kind: "invalid_request" },
{ statusCode: 429, kind: "rate_limit" },
{ statusCode: 503, kind: "server_overload" },
] as const
Comment thread
Astro-Han marked this conversation as resolved.

cases.forEach(({ statusCode, kind }) => {
const error = new APICallError({
message: `${statusCode} failure`,
url: "https://example.com",
requestBodyValues: {},
statusCode,
responseHeaders: { "content-type": "application/json" },
isRetryable: statusCode >= 500,
})
const result = MessageV2.fromError(error, { providerID })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.providerFailure).toStrictEqual({
kind,
code: undefined,
})
})
})
})

describe("session.message-v2.APIError providerFailure back-compat", () => {
test("parses historical APIError rows that predate providerFailure", () => {
const legacyRow = {
name: "APIError",
data: {
message: "Server error",
isRetryable: true,
providerID,
},
}

const parsed = MessageV2.APIError.Schema.parse(legacyRow)

expect(parsed.data.providerFailure).toBeUndefined()
})

test("round-trips providerFailure through the persisted schema", () => {
const row = {
name: "APIError",
data: {
message: "Rate limited",
isRetryable: true,
providerID,
providerFailure: { kind: "rate_limit", code: "rate_limit_exceeded" },
},
}

const parsed = MessageV2.APIError.Schema.parse(row)

expect(parsed.data.providerFailure).toStrictEqual({
kind: "rate_limit",
code: "rate_limit_exceeded",
})
})

test("rejects unknown providerFailure kinds", () => {
const row = {
name: "APIError",
data: {
message: "x",
isRetryable: false,
providerFailure: { kind: "totally_made_up" },
},
}

expect(() => MessageV2.APIError.Schema.parse(row)).toThrow()
})
})

describe("session.message-v2.ToolStateError.reason", () => {
Expand Down
Loading