fix(server): map Account failures to typed 500 instead of defect (#26448)
This commit is contained in:
@@ -82,6 +82,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||||
|
error: HttpApiError.InternalServerError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.console.get",
|
identifier: "experimental.console.get",
|
||||||
@@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||||
|
error: HttpApiError.InternalServerError,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "experimental.console.listOrgs",
|
identifier: "experimental.console.listOrgs",
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||||||
|
|
||||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||||
const [state, groups] = yield* Effect.all(
|
const [state, groups] = yield* Effect.all(
|
||||||
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
|
[
|
||||||
|
config.getConsoleState(),
|
||||||
|
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||||
|
],
|
||||||
{
|
{
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
},
|
},
|
||||||
@@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||||||
|
|
||||||
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
|
||||||
const [groups, active] = yield* Effect.all(
|
const [groups, active] = yield* Effect.all(
|
||||||
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
|
[
|
||||||
|
account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||||
|
account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))),
|
||||||
|
],
|
||||||
{
|
{
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user