Procházet zdrojové kódy

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

Kit Langton před 4 měsíci
rodič
revize
3615d5aab1

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

@@ -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",

+ 8 - 2
packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts

@@ -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",
         },
         },

+ 99 - 0
packages/opencode/test/server/httpapi-account-error-mapping.test.ts

@@ -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")
+  })
+})