Skip to content

fix(client): normalize both server error envelopes so err.code / err.fields mean one thing (#3918 follow-up) - #3927

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

fix(client): normalize both server error envelopes so err.code / err.fields mean one thing (#3918 follow-up)#3927
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-validation-error-fields-xo6zx9

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Third and last piece of the #3918 chain (after #3920 and #3925). This is the one that makes the original report's symptom — no per-field error display on dispatcher-served forms — actually go away for a caller.

⚠️ This one contains a deliberate SDK behaviour change. It's small and I believe it's a strict fix, but it's a contract change rather than a pure bug fix, so it deserves a real look — see the last section.

The bug

#3920 put fields[] on the wire for dispatcher-served routes. A caller still couldn't get at it, because two envelopes are in play and they disagree about where the semantic code and the field list live:

@objectstack/rest, flat:
  { error, code: 'VALIDATION_FAILED', fields: [...] }

runtime dispatcher, wrapped:
  { success: false, error: { message, code: 400,
      details: { code: 'VALIDATION_FAILED', fields: [...] } } }

error.code in the wrapped form is the HTTP status, not a semantic code. packages/client/src/index.ts read it straight through (errorBody?.code || errorBody?.error?.code), so err.code was the number 400 where the flat envelope gave 'VALIDATION_FAILED'.

The consequence is that the branch our own documentation teachescontent/docs/protocol/kernel/http-protocol.mdx:923

if (result.code === 'VALIDATION_FAILED') {
  result.fields.forEach(f => showFieldError(f.field, f.message));
}

…never matched on a dispatcher-served surface, and the field list was sitting unreachable at err.details.error.details.fields.

The fix

Normalized once, at the throw site:

  • err.code is always the semantic string. Read from the flat code, else the wrapped error.details.code, else a string error.code. A numeric value is never reported as a code. The HTTP status is on err.httpStatus, where it always was.
  • err.fields is the per-field list from either envelope. Left unset (not []) when the server sent none, so if (err.fields) is a safe test for "this failure is field-anchored".
  • err.details prefers a top-level details (unchanged), then the wrapped envelope's own details, then the whole body. The flat envelope has no top-level details, so it keeps falling through to the whole body exactly as before — only the wrapped shape changes, and only from "the entire response" to the structured object it actually carries.

content/docs/api/client-sdk.mdx enumerates the properties attached to a thrown error, so it's updated in the same commit: err.fields documented with a worked example, plus a note that err.code is always semantic and the status is only ever on err.httpStatus.

Verification

  • 8 new tests in client.test.ts covering both envelopes symmetrically: semantic code from each, fields[] from each, fields unset when absent, no numeric code even with no details to fall back on, and three details cases (flat unchanged / wrapped structured / top-level details still honoured).
  • The 4 that target the wrapped envelope fail against the pre-fix source; the other 4 are anti-regressions that correctly pass both ways.
  • Full client suite green: 192 tests / 14 files. Package builds; eslint clean.

The behaviour change — please sanity-check this call

Code reading err.code from a dispatcher-served route previously got a number and now gets a string (or undefined where the server sent no semantic code).

My reasoning for treating this as a fix rather than a break: err.httpStatus was always the correct and documented source for the status, err.code carrying two different types depending on which surface answered was the actual defect, and I grepped the repo — nothing branches on a numeric err.code. But a downstream consumer outside this repo that did err.code === 400 would need err.httpStatus === 400, and that's called out in the changeset.

If you'd rather keep the numeric value reachable, say so and I'll leave err.code alone and expose the semantic one as a new property instead — but then the documented err.code === 'VALIDATION_FAILED' branch stays broken for dispatcher routes, which is what this chain set out to fix.


Generated by Claude Code

…fields mean one thing (#3918 follow-up)

Two envelopes are in play and they disagree about where the semantic code and
the per-field list live:

  @objectstack/rest, flat:
    { error, code: 'VALIDATION_FAILED', fields: [...] }
  runtime dispatcher, wrapped:
    { success: false, error: { message, code: 400,
        details: { code: 'VALIDATION_FAILED', fields: [...] } } }

`error.code` in the WRAPPED form is the HTTP status, not a semantic code. The
client read it straight through, so `err.code` was the number 400 where the
flat envelope gave 'VALIDATION_FAILED' — the branch our own docs teach
(`if (err.code === 'VALIDATION_FAILED') err.fields.forEach(…)`) never matched
on a dispatcher-served surface, and the field list #3918 put on the wire for
those routes was unreachable at `err.details.error.details.fields`.

Normalized at the throw site:

- `err.code` is always the semantic STRING — flat `code`, else wrapped
  `error.details.code`, else a string `error.code`. A numeric value is never
  reported as a code; the status stays on `err.httpStatus`.
- `err.fields` is the per-field list from either envelope, left UNSET (not [])
  when the server sent none, so `if (err.fields)` tests "field-anchored".
- `err.details` prefers a top-level `details` (unchanged), then the wrapped
  envelope's own `details`, then the whole body — the flat envelope is
  unaffected.

Nothing in this repo read `err.code` as a number, but a consumer that branched
on `err.code === 400` should move to `err.httpStatus === 400`.

Documented in content/docs/api/client-sdk.mdx, which enumerates the attached
properties and now covers `err.fields`.

Covered by 8 cases in client.test.ts; the 4 that target the wrapped envelope
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 10:07am

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/client.

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

  • content/docs/ai/skills-reference.mdx (via packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/client)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/client)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client)
  • content/docs/permissions/authentication.mdx (via @objectstack/client)
  • content/docs/plugins/packages.mdx (via @objectstack/client)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/releases/implementation-status.mdx (via @objectstack/client)
  • content/docs/releases/v16.mdx (via @objectstack/client)

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.

…d envelope

The docs-drift check on #3927 flagged `api/error-catalog.mdx`, and unlike the
rest of the advisory list it was a real hit: its "Client-Side Error Handling"
callout enumerates exactly what the SDK attaches to a thrown error, which this
branch changes.

Two things were already wrong there before this branch, and one is new:

- The callout said the client attaches "only code, category, httpStatus,
  retryable, and details — not fieldErrors", and then the TypeScript example
  immediately called `apiError.fieldErrors?.forEach(...)`. The example could
  never have worked against the very SDK the callout describes.
- Nothing said `code` is a string while the status lives on `httpStatus` —
  the distinction this branch makes true for both wire formats.

Now: the callout lists `fields` among the attached properties, states the
`fields` (wire) vs `fieldErrors` (spec contract) naming split, notes that
`fields` is left unset rather than empty, and spells out the code-vs-status
rule. The example reads `apiError.fields` and widens its cast to
`EnhancedApiError & { fields?: FieldError[] }` so it actually typechecks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeZ7DmSKjiKmQzYh8L5CJS
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.

2 participants