Skip to content

Commit 3615d5a

Browse files
authored
fix(server): map Account failures to typed 500 instead of defect (#26448)
1 parent 11d9e82 commit 3615d5a

3 files changed

Lines changed: 109 additions & 2 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
8282
.add(
8383
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
8484
success: described(ConsoleStateResponse, "Active Console provider metadata"),
85+
error: HttpApiError.InternalServerError,
8586
}).annotateMerge(
8687
OpenApi.annotations({
8788
identifier: "experimental.console.get",
@@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
9192
),
9293
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
9394
success: described(ConsoleOrgList, "Switchable Console orgs"),
95+
error: HttpApiError.InternalServerError,
9496
}).annotateMerge(
9597
OpenApi.annotations({
9698
identifier: "experimental.console.listOrgs",

packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
2626

2727
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
2828
const [state, groups] = yield* Effect.all(
29-
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
29+
[
30+
config.getConsoleState(),
31+
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
32+
],
3033
{
3134
concurrency: "unbounded",
3235
},
@@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
4043

4144
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
4245
const [groups, active] = yield* Effect.all(
43-
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
46+
[
47+
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
48+
account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
49+
],
4450
{
4551
concurrency: "unbounded",
4652
},
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, describe, expect, mock, test } from "bun:test"
2+
import { Effect, Layer, Option } from "effect"
3+
4+
// Account.orgsByAccount() can fail with AccountServiceError when the
5+
// upstream Anthropic Console API is unreachable. The HTTP API used to
6+
// pipe the call through Effect.orDie, which converts the typed error
7+
// into a defect — surfacing as a 500 with the raw stack trace embedded
8+
// in the response body.
9+
//
10+
// The handlers now map the failure onto HttpApiError.InternalServerError
11+
// and the endpoints declare it as a typed error. Operators get a
12+
// structured 500 response with no stack-trace leak, and future error
13+
// middleware can recognize the failure type instead of seeing a defect.
14+
//
15+
// To force the failure path, mock @/account/account so its defaultLayer
16+
// provides an Account.Service whose orgsByAccount returns Effect.fail.
17+
18+
const ORIG = await import("../../src/account/account")
19+
20+
const failingAccountLayer = Layer.succeed(
21+
ORIG.Service,
22+
ORIG.Service.of({
23+
active: () => Effect.succeed(Option.none()),
24+
activeOrg: () => Effect.succeed(Option.none()),
25+
list: () => Effect.succeed([]),
26+
orgsByAccount: () =>
27+
Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })),
28+
remove: () => Effect.void,
29+
use: () => Effect.void,
30+
orgs: () => Effect.succeed([]),
31+
config: () => Effect.succeed(Option.none()),
32+
token: () => Effect.succeed(Option.none()),
33+
login: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })),
34+
poll: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })),
35+
}),
36+
)
37+
38+
const mocked = {
39+
...ORIG,
40+
defaultLayer: failingAccountLayer,
41+
layer: failingAccountLayer,
42+
Account: {
43+
...ORIG.Account,
44+
defaultLayer: failingAccountLayer,
45+
layer: failingAccountLayer,
46+
},
47+
}
48+
49+
void mock.module("@/account/account", () => mocked)
50+
void mock.module("../../src/account/account", () => mocked)
51+
52+
const { Flag } = await import("@opencode-ai/core/flag/flag")
53+
const Log = await import("@opencode-ai/core/util/log")
54+
const { Server } = await import("../../src/server/server")
55+
const { ExperimentalPaths } = await import("../../src/server/routes/instance/httpapi/groups/experimental")
56+
const { resetDatabase } = await import("../fixture/db")
57+
const { disposeAllInstances, tmpdir } = await import("../fixture/fixture")
58+
59+
void Log.init({ print: false })
60+
61+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
62+
63+
afterEach(async () => {
64+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
65+
await disposeAllInstances()
66+
await resetDatabase()
67+
})
68+
69+
function httpApiApp() {
70+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
71+
return Server.Default().app
72+
}
73+
74+
async function probe(path: string) {
75+
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
76+
return httpApiApp().request(path, {
77+
headers: { "x-opencode-directory": tmp.path },
78+
})
79+
}
80+
81+
describe("HTTP API account failure mapping", () => {
82+
test("/experimental/console returns a structured 500, not a stack-trace defect", async () => {
83+
const response = await probe(ExperimentalPaths.console)
84+
expect(response.status).toBe(500)
85+
const body = await response.text()
86+
expect(body).not.toContain("\n at ")
87+
const json = JSON.parse(body)
88+
expect(json._tag).toBe("InternalServerError")
89+
})
90+
91+
test("/experimental/console/orgs returns a structured 500, not a stack-trace defect", async () => {
92+
const response = await probe(ExperimentalPaths.consoleOrgs)
93+
expect(response.status).toBe(500)
94+
const body = await response.text()
95+
expect(body).not.toContain("\n at ")
96+
const json = JSON.parse(body)
97+
expect(json._tag).toBe("InternalServerError")
98+
})
99+
})

0 commit comments

Comments
 (0)