fix(client): throw declared API errors as Error instances (#50788)

This commit is contained in:
Aiden Cline
2026-09-22 21:25:28 -05:00
committed by GitHub
parent 17abc5906b
commit d56ce74373
5 changed files with 60 additions and 5 deletions
@@ -99,13 +99,13 @@ describe("readLocalImage", () => {
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
const error = { _tag: "RequestError", message: "Cannot read image" }
const { api } = setup(() => Response.json(error, { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
})
test("propagates missing-file errors", async () => {
const error = { _tag: "FileNotFoundError", path: "image.png", message: "File not found: image.png" }
const { api } = setup(() => Response.json(error, { status: 404 }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
})
test("does not turn an unexpected HTTP status into a Blob", async () => {
@@ -330,7 +330,8 @@ export function make(options: ClientOptions) {
}
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
if (descriptor.declaredStatuses.includes(response.status))
throw declared((await json(response)) as DeclaredErrorBody)
try {
await response.body?.cancel()
} catch {}
@@ -2200,6 +2201,19 @@ async function json(response: Response): Promise<unknown> {
}
}
type DeclaredErrorBody = {
readonly _tag?: string
readonly message?: string
readonly data?: { readonly message?: string }
}
/** Throw declared error bodies as Errors. The body's fields stay on the error, so narrowing on `_tag` or `name` still works. */
function declared(body: DeclaredErrorBody) {
const error = Object.assign(new Error(body.message ?? body.data?.message), body)
if (body._tag) error.name = body._tag
return error
}
function isContentType(response: Response, expected: string) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
}
+40
View File
@@ -151,6 +151,46 @@ test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", asyn
})
})
test("declared errors are thrown as Error instances that keep the body", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "InvalidRequestError", message: "Incompatible auth server", kind: "integration_authorization" },
{ status: 400 },
),
})
const error = await client.integration.oauth
.connect({ integrationID: "mcp_test", methodID: "oauth", location: { directory: "/repo" } })
.then(
() => undefined,
(cause: unknown) => cause,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toBe("Incompatible auth server")
expect(error.name).toBe("InvalidRequestError")
expect(error.stack).toContain("InvalidRequestError: Incompatible auth server")
expect(error).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_authorization" })
})
test("declared errors with a data envelope read the nested message", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } }, { status: 400 }),
})
const error = await client.worktree.create({ projectID: "prj_test" }).then(
() => undefined,
(cause: unknown) => cause,
)
expect(error).toBeInstanceOf(Error)
if (!(error instanceof Error)) throw error
expect(error.message).toBe("Worktree directory unavailable")
expect(error.name).toBe("WorktreeError")
expect(error).toMatchObject({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } })
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
File diff suppressed because one or more lines are too long
@@ -1051,7 +1051,8 @@ describe("HttpApiCodegen.generate", () => {
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({ name: "Missing", message: "gone", _tag: "Missing" })
expect(emitted.module.isMissing(error)).toBeTrue()
})