Skip to content

fix(runtime): route every domain catch through errorFromThrown (#3918 follow-up) - #3925

Merged
os-zhuang merged 1 commit into
mainfrom
claude/dispatcher-validation-error-fields-xo6zx9
Jul 29, 2026
Merged

fix(runtime): route every domain catch through errorFromThrown (#3918 follow-up)#3925
os-zhuang merged 1 commit into
mainfrom
claude/dispatcher-validation-error-fields-xo6zx9

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #3920 (which closed #3918). Same family of bug, one tier further down — found while doing the #3918 work, not fixed there because it was out of that issue's stated scope.

The bug

#3867 taught dispatcher-plugin's errorResponseBase to read an error's status (not just statusCode). #3920 taught HttpDispatcher.errorFromThrown the VALIDATION_FAILED shape. Both fixes were invisible to a whole tier of handlers underneath them: the domain modules each catch their own errors and called deps.error(e.message, e.statusCode || 500) directly, bypassing errorFromThrown entirely — 13 call sites, 9 of them in /packages alone.

So on /packages, /meta/_drafts, /ui, /security and the /mcp transport:

security.ts is the clearest illustration: its comment says "The service throws typed errors carrying their HTTP status: PermissionDeniedError → 403, SuggestionNotFoundError → 404, SuggestionStateError → 409" — and then read statusCode, which none of them set.

The fix

Every such catch now calls deps.errorFromThrown(e, …), so both earlier fixes finally reach the routes that need them. This is a pure convergence — no new concepts, and errorFromThrown already understands status, statusCode and the validation shape.

Deliberate per-route fallbacks are preserved, not flattened to 500:

route fallback kept why
/meta save (metadata-service branch) 501 reached only when the protocol has no saveMetaItem, so "unsupported" is the honest default
/meta two-part lookup 404 it's a not-found path

…but a validation failure on either now answers 400 with its fields instead of being swallowed by the fallback. A 501 "Not Implemented" for a bad field value was never right.

domains/keys.ts is deliberately NOT converted. It discards the underlying error on purpose — the comment says "Never surface the underlying error (could echo row contents)" — so converting it would turn a privacy guard into a leak. Its literal 'Failed to create API key' is the correct answer and stays. Guarded by a note in the test file so nobody "finishes the job" later.

Also added a note to the handlePackagesRequest docblock so new handlers route through the shared helper instead of re-deriving the status.

Verification

  • 13 new tests in domains/error-passthrough.test.ts, driven through the real routes: one status-survives case, one validation case and one 500-fallback case per converted module, plus a statusCode-still-works anti-regression and the two preserved-fallback cases.
  • 8 of the 13 fail against the pre-fix source — verified by reverting the five domain files and re-running.
  • Full runtime suite green: 764 tests / 54 files (was 735/52 before this change). Package builds; eslint clean.

No behaviour change for errors that already carried statusCode — that read is preserved, only widened.


Generated by Claude Code

…follow-up)

#3867 taught `errorResponseBase` to read `status` (not just `statusCode`) and
#3918 taught `errorFromThrown` the `VALIDATION_FAILED` shape. Both fixes were
invisible to the tier underneath: the domain modules each caught their own
errors and called `deps.error(e.message, e.statusCode || 500)` directly,
bypassing `errorFromThrown` — 13 call sites, 9 of them in `/packages`.

So on `/packages`, `/meta/_drafts`, `/ui`, `/security` and the `/mcp`
transport, a deliberate 404 still rendered as a 500 (every protocol-layer
domain error carries its status as `status`, the exact read #3867 fixed one
tier up), and a `ValidationError` still lost its `fields[]` and its 400 —
re-opening #3918 on the very routes it was filed against.

Every such catch now calls `deps.errorFromThrown(e, …)`. Deliberate per-route
fallbacks are preserved rather than flattened to 500: `/meta` save keeps 501,
the `/meta` two-part lookup keeps 404 — but a validation failure on either now
answers 400 with its fields instead of being swallowed by the fallback.

`domains/keys.ts` is deliberately NOT converted: it discards the underlying
error on purpose because the message could echo row contents, so its literal
'Failed to create API key' stays.

Covered by `domains/error-passthrough.test.ts` — 13 cases driven through the
real routes, 8 of which fail on the pre-fix source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeZ7DmSKjiKmQzYh8L5CJS
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 29, 2026 9:54am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 4cf7c61 into main Jul 29, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/dispatcher-validation-error-fields-xo6zx9 branch July 29, 2026 10:00
os-zhuang added a commit that referenced this pull request Jul 29, 2026
…ccess:false (#3913 follow-up) (#3951)

#3937 settled that a failed action reports in the payload at HTTP 200 — "an
action that fails is a normal outcome, not a transport error". That is a
statement about the action REJECTING: a business rule saying no. The same exit
was also covering a third case it never argued for.

A TypeError in a handler, a driver blowing up, a sandbox timeout are not
outcomes the action chose to report — they are the server failing to produce
one. Serving them as 200 hid every handler crash from the layers that exist to
catch server faults: gateway error rates, retry and circuit-breaker policy, APM
auto-capture, alerting, fetch().ok.

Those are 500 now, through the same errorFromThrown exit every other domain
catch has used since #3925 — which also puts a driver dump behind the
internal-error-leak sanitiser (#3867) instead of letting it reach the client
verbatim in a 200 body.

Nothing #3937 put in the payload moves. Rejection and crash are told apart by
the error's NAME, the signal @objectstack/rest already uses on this exact
distinction: a plain Error, a SandboxError carrying innerMessage, anything with
code/fields, and a ValidationError by name are rejections; TypeError /
ReferenceError / a driver's own class, and a SandboxError with no innerMessage
(timeout, capability denial) are crashes. A throw with no name at all is not
confidently a fault and keeps its 200 — deliberately the narrow direction.

Also in the same exit: an error carrying its own status/statusCode (a plugin's
FORBIDDEN with status 403) is served with it rather than buried in a 200
payload. Record ValidationErrors deliberately carry no .status, so #3937's cases
never reach that branch.

Documented in api/error-catalog.mdx (new Action Errors section with the full
status table and the two-check pattern a raw fetch caller needs) and
ui/actions.mdx.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[17.0.0-rc.0] Runtime dispatcher error paths drop ValidationError.fields[] and downgrade status to 500 (rest-server maps it correctly)

2 participants