Skip to content

Commit 03d26f7

Browse files
authored
fix(runtime,spec)!: the dispatcher's error.code is the semantic string; the HTTP status moves to httpStatus (#3842) (#3971)
`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it into the field `ApiErrorSchema` reserves for a semantic string, so `error.code` came back as 400/403/503 — a number duplicating the response status and occupying the one slot a caller branches on. The real code then had to go elsewhere, and did, three elsewheres: `details.code`, `details.type` and `error.type`. `code` is now the semantic string, `httpStatus` carries the number, and `details` is genuine context only. Every code already on the wire moves verbatim — PERMISSION_DENIED, ROUTE_NOT_FOUND, PASSWORD_EXPIRED, PROJECT_MEMBERSHIP_REQUIRED, VALIDATION_FAILED, unauthenticated. This moves a field; #3841 still owns renaming any of them. A branch with no code of its own gets one derived from the status via one declared map in the spec (`HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`), drawing only on catalogued `StandardErrorCode` members. Spec: `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode` gains `method_not_allowed` / `precondition_required` (both additive). BREAKING — `DispatcherErrorCode` members go from '404'|'405'|'501'|'503' to the four semantic spellings the removed `error.type` declared, and `DispatcherErrorResponseSchema.error.code` becomes a string; that schema had declared the opposite of `ApiErrorSchema` for the same field, which is what let the deviation stand. Also aligned, being the same wire surface: `dispatcher-plugin`'s `errorResponseBase` (which used to discard a thrown error's `.code` outright, having nowhere to put it) and its inline 404, plus the MCP 405. All bodies now come from one builder, guarded both ways by `error-envelope.conformance.test.ts` — every branch driven and parsed against the schema imported from the spec, plus a source scan so a new branch cannot reintroduce a numeric `code`. Deletes the #3687 pin, which asked to be deleted rather than updated once the dispatcher was fixed.
1 parent 0e73f38 commit 03d26f7

45 files changed

Lines changed: 1146 additions & 251 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842)
8+
9+
`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
10+
straight into the field `ApiErrorSchema` reserves for a semantic string, so
11+
`error.code` came back as `400`/`403`/`503` — a number, duplicating the response
12+
status and occupying the one slot a caller is meant to branch on. The real code
13+
then had to go somewhere else, and did, three somewhere-elses: `details.code`
14+
(auth gate, permission denial, anonymous deny), `details.type`
15+
(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three
16+
parking spots, because the declared one was full.
17+
18+
**FROM → TO on the wire.** A dispatcher error body
19+
20+
```json
21+
{ "success": false,
22+
"error": { "message": "", "code": 403, "details": { "code": "PERMISSION_DENIED" } } }
23+
```
24+
25+
is now
26+
27+
```json
28+
{ "success": false,
29+
"error": { "code": "PERMISSION_DENIED", "message": "", "httpStatus": 403 } }
30+
```
31+
32+
| Reading | Was | Now |
33+
|---|---|---|
34+
| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` |
35+
| HTTP status | `error.code` | `error.httpStatus` (or the response status) |
36+
| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) |
37+
38+
**One-line fix for a direct reader:** replace `body.error.details?.code ??
39+
body.error.type` with `body.error.code`, and `body.error.code` with
40+
`body.error.httpStatus`. **SDK callers need no change**`ObjectStackClient`
41+
already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still
42+
reads the old shape, so a client newer than its server is unaffected.
43+
44+
Every code already on the wire moves **verbatim**`PERMISSION_DENIED`,
45+
`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`,
46+
`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not
47+
rename anything. Reconciling the repo's two code vocabularies is #3841, and this
48+
leaves it exactly one map and one enum to sweep instead of four parking spots.
49+
50+
A branch with no code of its own is served one derived from the status, via the
51+
single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`
52+
in `@objectstack/spec/api` (`403``permission_denied`, `503`
53+
`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code`
54+
is required; drawing it from `StandardErrorCode` keeps a derived code a
55+
catalogued one rather than an invented string.
56+
57+
**Spec changes:**
58+
59+
- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is
60+
`EnhancedApiErrorSchema.httpStatus`. Additive.
61+
- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`,
62+
the two statuses the runtime returns that the enum could not name. Additive.
63+
- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string
64+
spellings of HTTP statuses, for matching against the numeric `error.code`). It
65+
is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' |
66+
'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum
67+
declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO
68+
`DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read
69+
`error.httpStatus`. TypeScript flags every call site.
70+
- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()`
71+
(was `z.number().int()`), `error.type` is **removed** (folded into `code`), and
72+
`error.httpStatus` / `error.details` are declared. This schema is what
73+
legitimised the deviation — it declared the opposite of `ApiErrorSchema` for
74+
the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO
75+
`{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`.
76+
77+
**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s
78+
`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405.
79+
`errorResponseBase` previously discarded a thrown error's `.code` outright — it
80+
had nowhere to put it — so the two exits of one surface disagreed about what a
81+
caller would see; they now agree. Every body on this surface is built by one
82+
helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by
83+
`error-envelope.conformance.test.ts`: each branch driven and parsed against the
84+
schema imported from `packages/spec`, plus a source scan so a new branch cannot
85+
quietly reintroduce a numeric `code` or a `type`-as-code sibling.
86+
87+
This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be
88+
deleted rather than updated once the dispatcher was fixed.

content/docs/api/client-sdk.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,9 +522,12 @@ try {
522522
`error.code` is always the **semantic** code as a string — the numeric HTTP
523523
status lives on `error.httpStatus` and nowhere else. This holds regardless of
524524
which server surface answered: the REST server replies with a flat
525-
`{ error, code, fields }` body while the runtime dispatcher replies with a
526-
wrapped `{ success, error: { message, code, details } }` body whose `error.code`
527-
is the HTTP status, and the client normalizes both before throwing.
525+
`{ error, code, fields }` body and the runtime dispatcher replies with a wrapped
526+
`{ success, error: { code, message, httpStatus, details } }` body, and the
527+
client normalizes both before throwing. (Dispatcher bodies from servers older
528+
than #3842 put the status in `error.code` and the semantic code in
529+
`error.details.code`; the client still reads those, so an SDK newer than the
530+
server it talks to behaves the same.)
528531

529532
### Per-field validation errors
530533

content/docs/api/error-catalog.mdx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Complete reference for all ObjectStack error codes with causes, fix
55

66
# Error Code Catalog
77

8-
ObjectStack uses a structured error system with **9 error categories** and **51 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
8+
ObjectStack uses a structured error system with **9 error categories** and **53 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
99

1010
<Callout type="info">
1111
**Source:** `packages/spec/src/api/errors.zod.ts`
@@ -14,6 +14,8 @@ ObjectStack uses a structured error system with **9 error categories** and **51
1414

1515
<Callout type="warn">
1616
**Spec vs. wire format:** The codes below are `StandardErrorCode` — the standardized, lowercase snake_case contract that plugin/hook authors should throw with (see [Server-Side Error Handling](/docs/api/error-handling-server)). The kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` today emits a flatter envelope with SCREAMING_SNAKE_CASE codes instead — e.g. `VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE` — not the codes in this catalog. See the [API Overview](/docs/api#error-handling) for both wire formats in use and [Wire Format](/docs/api/wire-format#7-error-response-format) for JSON examples.
17+
18+
Reconciling the two vocabularies is [#3841](https://github.com/objectstack-ai/objectstack/issues/3841), still open. In the meantime the runtime dispatcher emits whichever code its producer already used (`PERMISSION_DENIED`, `VALIDATION_FAILED`, `unauthenticated`, …) and falls back to a `StandardErrorCode` from this catalog when a branch has no code of its own — `HttpStatusErrorCodeMap` in `@objectstack/spec/api` is the single place that mapping is spelled.
1719
</Callout>
1820

1921
---
@@ -263,6 +265,25 @@ ObjectStack uses a structured error system with **9 error categories** and **51
263265

264266
---
265267

268+
## Request Errors (405/428)
269+
270+
Added in #3842 so `standardErrorCodeForHttpStatus` can name every status the
271+
runtime actually returns. Without them a `405` would fall into the generic 4xx
272+
bucket and be reported as a validation failure.
273+
274+
### `method_not_allowed`
275+
**Cause:** The route exists but does not serve this HTTP method.
276+
**Fix:** Use the method named in the response's `Allow` header.
277+
**Retry:** `no_retry`
278+
279+
### `precondition_required`
280+
**Cause:** The request is missing a precondition the route requires — most often
281+
an environment scope (no `X-Environment-Id` header and no hostname mapping).
282+
**Fix:** Send the missing header, or address the environment-scoped URL form.
283+
**Retry:** `no_retry`
284+
285+
---
286+
266287
## Rate Limit Errors (429)
267288

268289
### `rate_limit_exceeded`
@@ -447,10 +468,9 @@ Two naming details matter here:
447468
`if (apiError.fields)` tests "this failure is field-anchored".
448469
- `apiError.code` is always the semantic code as a **string**. The numeric HTTP
449470
status is on `apiError.httpStatus` and nowhere else. This holds for both wire
450-
formats: the flat REST envelope carries the code at the top level, the runtime
451-
dispatcher's wrapped envelope carries it in `error.details.code` (its
452-
`error.code` is the HTTP status), and the client normalizes both before
453-
throwing.
471+
formats: the flat REST envelope carries the code at the top level and the
472+
runtime dispatcher's wrapped envelope carries it in `error.code`, with the
473+
status alongside it in `error.httpStatus`.
454474
</Callout>
455475

456476
### TypeScript Example

content/docs/api/index.mdx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,24 @@ Validation failures additionally include a `fields` array (one entry per invalid
165165
| `RECORD_NOT_FOUND` | 404 | Resource does not exist |
166166
| `CONCURRENT_UPDATE` | 409 | Record was modified by another user |
167167

168-
**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope, where `code` is the numeric HTTP status:
168+
**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope declared by `ApiErrorSchema`. `code` is the semantic string; the numeric HTTP status is on `httpStatus`, and `details` carries structured context only:
169169

170170
```json
171171
{
172172
"success": false,
173173
"error": {
174+
"code": "RECORD_NOT_FOUND",
174175
"message": "Record not found: account/123",
175-
"code": 404,
176-
"details": {}
176+
"httpStatus": 404
177177
}
178178
}
179179
```
180180

181+
Branch on `error.code`, never on `error.httpStatus` — the status answers "what
182+
class of failure" and the code answers "which failure". A branch that has no
183+
code of its own is served a `StandardErrorCode` derived from the status
184+
(`403``permission_denied`, `503``service_unavailable`, …).
185+
181186
<Callout type="info">
182187
The richer `ErrorResponseSchema` in `@objectstack/spec/api` (with `category`, `retryable`, etc.) is the aspirational spec envelope, not the current wire format. Its `category` values are drawn from the `ErrorCategory` enum: `validation`, `authentication`, `authorization`, `not_found`, `conflict`, `rate_limit`, `server`, `external`, `maintenance`.
183188
</Callout>

content/docs/api/wire-format.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,44 @@ Read `body.error.code`, not `body.code`, on these routes. `ObjectStackClient`
427427
normalizes both — `error.code` and `error.message` are populated whichever
428428
envelope the server used — so SDK callers do not need to branch.
429429

430+
### Dispatcher routes use it too
431+
432+
Everything the runtime dispatcher serves — `/api/v1/meta/*`, `/actions/*`,
433+
`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, … — answers in the same
434+
declared envelope, plus `httpStatus`:
435+
436+
```json
437+
{
438+
"success": false,
439+
"error": {
440+
"code": "PROJECT_MEMBERSHIP_REQUIRED",
441+
"message": "Forbidden: user usr_01H… is not a member of project env_prod",
442+
"httpStatus": 403,
443+
"details": { "environmentId": "env_prod", "userId": "usr_01H…" }
444+
}
445+
}
446+
```
447+
448+
| Field | Meaning |
449+
|:---|:---|
450+
| `code` | The semantic code — the field to branch on. Never a number. |
451+
| `message` | Human-readable text, safe to show. 5xx messages are sanitised. |
452+
| `httpStatus` | The response status, mirrored. Redundant with the response line by design. |
453+
| `details` | Structured context only (`fields[]`, `issues[]`, ids). Never a parked code. |
454+
455+
A route-resolution failure spells `code` from `DispatcherErrorCode`
456+
(`ROUTE_NOT_FOUND`, …) and adds `route` / `hint` / `service` as siblings. A
457+
branch with no code of its own gets a `StandardErrorCode` derived from the
458+
status.
459+
460+
<Callout type="info">
461+
Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this
462+
envelope put the HTTP status in `error.code` and the real code in
463+
`error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient`
464+
reads all of them, so an SDK newer than the server it talks to is unaffected;
465+
code reading these bodies directly should move to `error.code`.
466+
</Callout>
467+
430468
---
431469

432470
## 8. Batch Operations

content/docs/references/api/analytics.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const result = AnalyticsEndpoint.parse(data);
4545
| Property | Type | Required | Description |
4646
| :--- | :--- | :--- | :--- |
4747
| **success** | `boolean` || Operation success status |
48-
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
48+
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
4949
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
5050
| **data** | `{ cubes: { name: string; title?: string; description?: string; sql: string; … }[] }` || |
5151

@@ -72,7 +72,7 @@ const result = AnalyticsEndpoint.parse(data);
7272
| Property | Type | Required | Description |
7373
| :--- | :--- | :--- | :--- |
7474
| **success** | `boolean` || Operation success status |
75-
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
75+
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
7676
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
7777
| **data** | `{ rows: Record<string, any>[]; fields: { name: string; type: string }[]; sql?: string }` || |
7878

@@ -86,7 +86,7 @@ const result = AnalyticsEndpoint.parse(data);
8686
| Property | Type | Required | Description |
8787
| :--- | :--- | :--- | :--- |
8888
| **success** | `boolean` || Operation success status |
89-
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
89+
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
9090
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
9191
| **data** | `{ sql: string; params: any[] }` || |
9292

content/docs/references/api/auth.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const result = AuthProvider.parse(data);
102102
| Property | Type | Required | Description |
103103
| :--- | :--- | :--- | :--- |
104104
| **success** | `boolean` || Operation success status |
105-
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
105+
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
106106
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
107107
| **data** | `{ session: object; user: object; token?: string }` || |
108108

@@ -138,7 +138,7 @@ const result = AuthProvider.parse(data);
138138
| Property | Type | Required | Description |
139139
| :--- | :--- | :--- | :--- |
140140
| **success** | `boolean` || Operation success status |
141-
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
141+
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
142142
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
143143
| **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` || |
144144

0 commit comments

Comments
 (0)