fix(sdk): wrap thrown error bodies in Error

SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause.
This commit is contained in:
Kit Langton 2026-05-09 18:46:43 -04:00 committed by GitHub
parent ba9e4b67ed
commit 11363170ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 138 additions and 20 deletions

View file

@ -394,8 +394,16 @@ describe("HttpApi SDK", () => {
const missing = yield* capture(() => sdk.session.get({ sessionID }))
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
// Result-tuple path: error body is preserved as-is so existing
// consumers reading `result.error.name` / `JSON.stringify(error)`
// keep working byte-for-byte.
expect(missing.error).toEqual(expected)
expect(thrown).toEqual(expected)
// throwOnError path: SDK wraps the body in a real Error with the
// server's message, with the original parsed body preserved under
// `.cause.body`.
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toBe(expected.data.message)
expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
return {
status: missing.status,
error: missing.error,

View file

@ -0,0 +1,74 @@
/**
* Regression tests for the SDK error shape the v2 SDK's `throwOnError: true`
* path used to throw raw values (empty strings or POJOs from JSON-decoded
* error bodies). The TUI catches those and `e.message`/`e.stack` are
* undefined, so users see `[object Object]` or a blank crash.
*
* Both cases must throw a real `Error` instance with a non-empty `.message`
* extracted from the response body, plus `.status` and `.body` attached.
*/
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
void Log.init({ print: false })
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function client(directory: string) {
return createOpencodeClient({
baseUrl: "http://test",
directory,
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
})
}
describe("v2 SDK error shape", () => {
test("404 with NamedError body throws a real Error carrying the server message", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { body?: any; status?: number }
expect(err.message).toContain("Session not found")
expect(cause.status).toBe(404)
expect(cause.body).toMatchObject({
name: "NotFoundError",
data: { message: expect.stringContaining("Session not found") },
})
})
test("400 with empty body throws a real Error naming the status", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
// POST /sync/history with `aggregate: -1` triggers schema validation
// that returns an empty 400 body (verified via plan-mode probe).
await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { status?: number }
expect(err.message.length).toBeGreaterThan(0)
expect(cause.status).toBe(400)
})
})