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
76 changes: 60 additions & 16 deletions packages/sdk/js/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,65 @@ function rewrite(request: Request, values: { directory?: string; workspace?: str
return next
}

/**
* Wrap whatever the generated client decoded from a non-2xx error body into a
* real `Error` so downstream formatters (TUI, CLI `run`, ACP, plugins) get a
* useful `.message` instead of `[object Object]` or a bare `{}`. The original
* parsed body and status stay under `.cause` for callers that need structured
* fields.
*
* Empty / unparseable bodies and network failures are wrapped unconditionally
* (matching the prior behavior of this interceptor). Non-empty structured or
* string bodies are only wrapped on the `{ throwOnError: true }` path; the
* result-tuple path keeps the raw parsed body so existing `result.error.<field>`
* reads stay byte-for-byte identical.
*/
export function wrapClientError(
error: unknown,
response: Response | undefined,
request: Request | undefined,
opts: { throwOnError?: boolean } | undefined,
): unknown {
if (error instanceof Error) return error

const isEmpty =
error === undefined ||
error === null ||
error === "" ||
(typeof error === "object" && Object.keys(error).length === 0)

if (isEmpty) {
const reason = response ? "(empty response body)" : "network error (no response)"
return new Error(`opencode server ${describeRequest(request, response)}: ${reason}`, {
cause: { body: error, status: response?.status },
})
}

if (!opts?.throwOnError) return error

// opencode 4xx NamedError bodies arrive as POJOs — extract a useful message.
if (typeof error === "object") {
const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown }
const message =
(typeof obj.data?.message === "string" && obj.data.message) ||
(typeof obj.message === "string" && obj.message) ||
(typeof obj.name === "string" && obj.name) ||
describeRequest(request, response)
return new Error(message, { cause: { body: error, status: response?.status } })
}

return new Error(typeof error === "string" ? error : String(error), {
cause: { body: error, status: response?.status },
})
}

function describeRequest(request: Request | undefined, response: Response | undefined) {
const method = request?.method ?? "?"
const url = request?.url ?? "?"
const statusText = response?.statusText ? " " + response.statusText : ""
return `${method} ${url}${response ? " -> " + response.status : ""}${statusText}`
}

export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
Expand Down Expand Up @@ -84,21 +143,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp

return response
})
client.interceptors.error.use((error, response, request) => {
const isEmpty =
error === undefined ||
error === null ||
error === "" ||
(typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0)

if (!isEmpty) return error

const method = request?.method ?? "?"
const url = request?.url ?? "?"
if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`)

const statusText = response.statusText ? " " + response.statusText : ""
return new Error(`opencode server ${method} ${url} -> ${response.status}${statusText}: (empty response body)`)
})
client.interceptors.error.use(wrapClientError)
return new OpencodeClient({ client })
}
100 changes: 99 additions & 1 deletion packages/sdk/js/test/v2-client-error-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import { createClient } from "../src/v2/gen/client/client.gen.js"
import type { ResolvedRequestOptions } from "../src/v2/gen/client/types.gen.js"
import { createOpencodeClient, wrapClientError } from "../src/v2/client.js"

function createInstrumentedClient(fetch: typeof globalThis.fetch) {
const client = createClient({
Expand Down Expand Up @@ -75,3 +76,100 @@ test("error interceptor receives resolved options for request validator errors",

expectResolvedOptions(seen[0])
})

const throwOpts = { throwOnError: true }

describe("wrapClientError", () => {
test("extracts data.message from a NamedError-shaped body on the throw path", () => {
const body = { data: { message: "session is locked" }, name: "LockedError" }
const wrapped = wrapClientError(body, new Response(null, { status: 423 }), undefined, throwOpts)
expect(wrapped).toBeInstanceOf(Error)
expect((wrapped as Error).message).toBe("session is locked")
expect((wrapped as Error).cause).toEqual({ body, status: 423 })
})

test("falls back to message, then name, then a request description", () => {
expect((wrapClientError({ message: "direct" }, undefined, undefined, throwOpts) as Error).message).toBe("direct")
expect((wrapClientError({ name: "OnlyName" }, undefined, undefined, throwOpts) as Error).message).toBe("OnlyName")

const request = new Request("https://example.test/x", { method: "POST" })
const response = new Response(null, { status: 418, statusText: "Teapot" })
const wrapped = wrapClientError({ unrelated: true }, response, request, throwOpts) as Error
expect(wrapped.message).toBe("POST https://example.test/x -> 418 Teapot")
})

test("wraps a non-empty string body on the throw path", () => {
const wrapped = wrapClientError("plain failure", undefined, undefined, throwOpts) as Error
expect(wrapped).toBeInstanceOf(Error)
expect(wrapped.message).toBe("plain failure")
expect(wrapped.cause).toEqual({ body: "plain failure", status: undefined })
})

test("passes a non-empty body through unchanged on the result-tuple path", () => {
const body = { data: { message: "keep raw" }, name: "Whatever" }
expect(wrapClientError(body, new Response(null, { status: 500 }), undefined, undefined)).toBe(body)
expect(wrapClientError("raw string", undefined, undefined, { throwOnError: false })).toBe("raw string")
})

test("returns existing Error instances unchanged", () => {
const original = new Error("already an error")
expect(wrapClientError(original, undefined, undefined, throwOpts)).toBe(original)
})

test("wraps empty bodies with a descriptive message regardless of throwOnError", () => {
const response = new Response(null, { status: 502, statusText: "Bad Gateway" })
const request = new Request("https://example.test/y", { method: "GET" })
for (const opts of [throwOpts, undefined]) {
const wrapped = wrapClientError({}, response, request, opts) as Error
expect(wrapped).toBeInstanceOf(Error)
expect(wrapped.message).toBe("opencode server GET https://example.test/y -> 502 Bad Gateway: (empty response body)")
}
})

test("describes a missing response as a network error", () => {
const request = new Request("https://example.test/z", { method: "GET" })
const wrapped = wrapClientError(undefined, undefined, request, throwOpts) as Error
expect(wrapped.message).toBe("opencode server GET https://example.test/z: network error (no response)")
})
})

describe("createOpencodeClient error wrapping", () => {
test("throws a real Error with the extracted message for a structured body", async () => {
const client = createOpencodeClient({
baseUrl: "https://example.test",
fetch: async () => jsonResponse({ data: { message: "session is locked" } }, { status: 423 }),
})
const error = await client.global.health({ throwOnError: true }).catch((err) => err)
expect(error).toBeInstanceOf(Error)
expect(error.message).toBe("session is locked")
expect(error.cause?.status).toBe(423)
})

test("leaves the raw parsed body on the result-tuple path", async () => {
const body = { data: { message: "nope" }, name: "LockedError" }
const client = createOpencodeClient({
baseUrl: "https://example.test",
fetch: async () => jsonResponse(body, { status: 423 }),
})
const result = await client.global.health()
expect(result.error).toEqual(body)
})

test("wraps an empty error body with a descriptive message", async () => {
const client = createOpencodeClient({
baseUrl: "https://example.test",
fetch: async () => new Response("", { status: 500, statusText: "Internal Server Error" }),
})
const error = await client.global.health({ throwOnError: true }).catch((err) => err)
expect(error).toBeInstanceOf(Error)
expect(error.message).toContain("(empty response body)")
expect(error.message).toContain("500")
})
})

function jsonResponse(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
...init,
})
}
Loading