fix(server): map Account failures to typed 500 instead of defect (#26448)

This commit is contained in:
Kit Langton
2026-05-08 23:44:28 -04:00
committed by GitHub
parent 11d9e82eaf
commit 3615d5aab1
3 changed files with 109 additions and 2 deletions

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect, Layer, Option } from "effect"
// Account.orgsByAccount() can fail with AccountServiceError when the
// upstream Anthropic Console API is unreachable. The HTTP API used to
// pipe the call through Effect.orDie, which converts the typed error
// into a defect — surfacing as a 500 with the raw stack trace embedded
// in the response body.
//
// The handlers now map the failure onto HttpApiError.InternalServerError
// and the endpoints declare it as a typed error. Operators get a
// structured 500 response with no stack-trace leak, and future error
// middleware can recognize the failure type instead of seeing a defect.
//
// To force the failure path, mock @/account/account so its defaultLayer
// provides an Account.Service whose orgsByAccount returns Effect.fail.
const ORIG = await import("../../src/account/account")
const failingAccountLayer = Layer.succeed(
ORIG.Service,
ORIG.Service.of({
active: () => Effect.succeed(Option.none()),
activeOrg: () => Effect.succeed(Option.none()),
list: () => Effect.succeed([]),
orgsByAccount: () =>
Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })),
remove: () => Effect.void,
use: () => Effect.void,
orgs: () => Effect.succeed([]),
config: () => Effect.succeed(Option.none()),
token: () => Effect.succeed(Option.none()),
login: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })),
poll: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })),
}),
)
const mocked = {
...ORIG,
defaultLayer: failingAccountLayer,
layer: failingAccountLayer,
Account: {
...ORIG.Account,
defaultLayer: failingAccountLayer,
layer: failingAccountLayer,
},
}
void mock.module("@/account/account", () => mocked)
void mock.module("../../src/account/account", () => mocked)
const { Flag } = await import("@opencode-ai/core/flag/flag")
const Log = await import("@opencode-ai/core/util/log")
const { Server } = await import("../../src/server/server")
const { ExperimentalPaths } = await import("../../src/server/routes/instance/httpapi/groups/experimental")
const { resetDatabase } = await import("../fixture/db")
const { disposeAllInstances, tmpdir } = await import("../fixture/fixture")
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function httpApiApp() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function probe(path: string) {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
return httpApiApp().request(path, {
headers: { "x-opencode-directory": tmp.path },
})
}
describe("HTTP API account failure mapping", () => {
test("/experimental/console returns a structured 500, not a stack-trace defect", async () => {
const response = await probe(ExperimentalPaths.console)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).not.toContain("\n at ")
const json = JSON.parse(body)
expect(json._tag).toBe("InternalServerError")
})
test("/experimental/console/orgs returns a structured 500, not a stack-trace defect", async () => {
const response = await probe(ExperimentalPaths.consoleOrgs)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).not.toContain("\n at ")
const json = JSON.parse(body)
expect(json._tag).toBe("InternalServerError")
})
})