From d56ce743737005b0b75c693e6a45589f75dedd2b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:25:28 -0500 Subject: [PATCH] fix(client): throw declared API errors as Error instances (#50788) --- packages/app/src/runtime/server/image.test.ts | 4 +- .../client/src/promise/generated/client.ts | 16 +++++++- packages/client/test/promise.test.ts | 40 +++++++++++++++++++ packages/httpapi-codegen/src/index.ts | 2 +- .../httpapi-codegen/test/generate.test.ts | 3 +- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/app/src/runtime/server/image.test.ts b/packages/app/src/runtime/server/image.test.ts index d7a7f95efd7..4a162f3ba05 100644 --- a/packages/app/src/runtime/server/image.test.ts +++ b/packages/app/src/runtime/server/image.test.ts @@ -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 () => { diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 976425b477c..936ae20d64b 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -330,7 +330,8 @@ export function make(options: ClientOptions) { } const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => { - 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 { } } +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 } diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 604184ec2c8..d718a0bb87c 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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 = { diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index ccc81be5abb..e8fe9bb9242 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -967,7 +967,7 @@ function renderPromiseClient(groups: ReadonlyArray) { if (group.endpoints[0]?.topLevel) return fields return `${JSON.stringify(group.identifier)}: { ${fields} }` }) - return `import type { ${imports.join(", ")} } from "./types.js"\nimport { ClientError } from "./client-error.js"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: RequestInit["headers"]\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: RequestInit["headers"]\n /** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */\n readonly onActivity?: () => void\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nconst maxSseEventBytes = 16 * 1024 * 1024\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n // A leading slash would replace any path prefix on baseUrl, so join relative to it.\n const baseUrl = new URL(options.baseUrl)\n if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"\n const url = new URL(descriptor.path.slice(1), baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (!next.done) requestOptions?.onActivity?.()\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` + return `import type { ${imports.join(", ")} } from "./types.js"\nimport { ClientError } from "./client-error.js"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: RequestInit["headers"]\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: RequestInit["headers"]\n /** Reports every chunk a streaming response receives, including keepalive comments that yield no event. */\n readonly onActivity?: () => void\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nconst maxSseEventBytes = 16 * 1024 * 1024\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n // A leading slash would replace any path prefix on baseUrl, so join relative to it.\n const baseUrl = new URL(options.baseUrl)\n if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"\n const url = new URL(descriptor.path.slice(1), baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw declared(await json(response) as DeclaredErrorBody)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (!next.done) requestOptions?.onActivity?.()\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\ntype DeclaredErrorBody = { readonly _tag?: string; readonly message?: string; readonly data?: { readonly message?: string } }\n\n/** Throw declared error bodies as Errors. The body's fields stay on the error, so narrowing on \`_tag\` or \`name\` still works. */\nfunction declared(body: DeclaredErrorBody) {\n const error = Object.assign(new Error(body.message ?? body.data?.message), body)\n if (body._tag) error.name = body._tag\n return error\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` } function promiseTypePrefix(group: string, path: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 74f6a0a6073..8432988dfda 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -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() })