Edge-case audit on PR #26631 caught two real exposures:
1. DoS amplification: Effect's Issue formatter recursively dumps the
rejected `actual` value with no truncation. A 5K-element invalid
array produced a 358 KB 400 response. Cap to 1 KB.
2. Secret echo: a token mis-posted to a typed endpoint (e.g.
`{aggregate:"sk-..."}`) was mirrored verbatim in `data.message` AND
in the warn log. Same cap mitigates — the field path is preserved,
the rejected value is truncated.
Adds two regression tests:
- Query rejection (was uncovered; reachable in production)
- 50 KB invalid payload → response body stays < 2 KB
Code review on PR #26631 surfaced two blocking gaps:
1. The OpenAPI legacy shim (addLegacyErrorSchemas in public.ts) still
declared BadRequestError as the old {data, errors, success} shape,
so the SDK's typed result.error path saw fields that don't match
the actual wire body. Update the shim and regenerate the SDK so
typed consumers see {name, data:{message,kind}}.
2. The regression test only triggered kind:"Payload" (request body
parse). The user-reported failure was kind:"Body" (response encode
on a corrupt stored row). Add a second test that mirrors the OMO/
Windows scenario: stored step-finish part with NaN tokens.output
makes the messages endpoint 400 — assert the new body has the field
path in data.message.
Plus an end-to-end SDK canary in sdk-error-shape.test.ts: asserts the
v2 SDK's wrapClientError extracts data.message into Error.message and
preserves the full body in cause.body, so future regressions of either
the server shape OR wrapClientError surface immediately.
bun typecheck, all 18 tests in 4 adjacent files pass.
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).