fix(server): return diagnosable body for schema rejections
Effect's default Respondable for HttpApiSchemaError returns 400 with an empty body. The renderer / SDK / curl get nothing actionable — just "GET /url → 400 Bad Request: (empty response body)". When a real user hit this on Windows yesterday (corrupted DB row → schema rejected the response), we spent ~an hour reverse-engineering the cause from the URL alone. PR #26457 previously tried to surface the reason in a structured body ({data, errors, success}) and got reverted in #26546 because some plugins broke. The proximate cause was the SDK throwing raw POJOs to plugins instead of Errors, which has since been fixed by `wrapClientError` (`50dcc4f1a`). Use the same NamedError shape every other 4xx/5xx in this API already uses (e.g. NotFoundError 404): {"name":"BadRequest","data":{"message":"...","kind":"Body"}} The SDK's wrapClientError extracts data.message automatically, so any caller that handles existing 404 NotFoundError bodies handles this identically — no new contract. Verified end-to-end: BEFORE status: 400 body: "" SDK Error.message: opencode server GET .../message?... → 400: (empty response body) AFTER status: 400 body: {"name":"BadRequest","data":{"message":"Expected number, got null at [0][\"parts\"][0][\"tokens\"][\"output\"]","kind":"Body"}} SDK Error.message: Expected number, got null at [0]["parts"][0]["tokens"]["output"] Includes a regression test that asserts the body shape on a real Body schema rejection (POST /sync/history with invalid aggregate).
This commit is contained in:
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
|
||||
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
// Effect's default Respondable for HttpApiSchemaError returns 400 with an
|
||||
// empty body. That gives the renderer / SDK / curl no information about
|
||||
// what was actually rejected (Body field, Query param, etc.). PR #26457
|
||||
// previously tried `{data:{}, errors:[], success:false}` and broke a
|
||||
// plugin (#26546) — root cause was the SDK throwing raw POJOs instead
|
||||
// of Errors, which has since been fixed by `wrapClientError`.
|
||||
//
|
||||
// We use the same shape every other 4xx/5xx in the API already uses —
|
||||
// NamedError serialization (`{name, data}`). The SDK's `wrapClientError`
|
||||
// extracts `.data.message` automatically, so plugins that already handle
|
||||
// 404 NotFoundError bodies handle this with no changes.
|
||||
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
|
||||
"@opencode/HttpApiSchemaError",
|
||||
) {}
|
||||
|
||||
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(
|
||||
SchemaErrorMiddleware,
|
||||
(error) =>
|
||||
Effect.gen(function* () {
|
||||
log.warn("schema rejection", {
|
||||
kind: error.kind,
|
||||
reason: error.cause.message,
|
||||
})
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{
|
||||
name: "BadRequest",
|
||||
data: {
|
||||
message: error.cause.message,
|
||||
kind: error.kind,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression"
|
||||
import { corsVaryFix } from "./middleware/cors-vary"
|
||||
import { errorLayer } from "./middleware/error"
|
||||
import { fenceLayer } from "./middleware/fence"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
@@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
@@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
httpApiAuthLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
schemaErrorLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user