mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(client): throw declared API errors as Error instances (#50788)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user