Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .changeset/dispatcher-error-code-is-semantic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
"@objectstack/spec": minor
"@objectstack/runtime": minor
"@objectstack/client": patch
---

fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842)

`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
straight 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 is meant to branch on. The real code
then had to go somewhere else, and did, three somewhere-elses: `details.code`
(auth gate, permission denial, anonymous deny), `details.type`
(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three
parking spots, because the declared one was full.

**FROM → TO on the wire.** A dispatcher error body

```json
{ "success": false,
"error": { "message": "…", "code": 403, "details": { "code": "PERMISSION_DENIED" } } }
```

is now

```json
{ "success": false,
"error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } }
```

| Reading | Was | Now |
|---|---|---|
| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` |
| HTTP status | `error.code` | `error.httpStatus` (or the response status) |
| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) |

**One-line fix for a direct reader:** replace `body.error.details?.code ??
body.error.type` with `body.error.code`, and `body.error.code` with
`body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient`
already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still
reads the old shape, so a client newer than its server is unaffected.

Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`,
`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`,
`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not
rename anything. Reconciling the repo's two code vocabularies is #3841, and this
leaves it exactly one map and one enum to sweep instead of four parking spots.

A branch with no code of its own is served one derived from the status, via the
single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`
in `@objectstack/spec/api` (`403` → `permission_denied`, `503` →
`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code`
is required; drawing it from `StandardErrorCode` keeps a derived code a
catalogued one rather than an invented string.

**Spec changes:**

- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is
`EnhancedApiErrorSchema.httpStatus`. Additive.
- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`,
the two statuses the runtime returns that the enum could not name. Additive.
- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string
spellings of HTTP statuses, for matching against the numeric `error.code`). It
is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' |
'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum
declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO
`DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read
`error.httpStatus`. TypeScript flags every call site.
- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()`
(was `z.number().int()`), `error.type` is **removed** (folded into `code`), and
`error.httpStatus` / `error.details` are declared. This schema is what
legitimised the deviation — it declared the opposite of `ApiErrorSchema` for
the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO
`{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`.

**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s
`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405.
`errorResponseBase` previously discarded a thrown error's `.code` outright — it
had nowhere to put it — so the two exits of one surface disagreed about what a
caller would see; they now agree. Every body on this surface is built by one
helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by
`error-envelope.conformance.test.ts`: each branch driven and parsed against the
schema imported from `packages/spec`, plus a source scan so a new branch cannot
quietly reintroduce a numeric `code` or a `type`-as-code sibling.

This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be
deleted rather than updated once the dispatcher was fixed.
9 changes: 6 additions & 3 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -522,9 +522,12 @@ try {
`error.code` is always the **semantic** code as a string — the numeric HTTP
status lives on `error.httpStatus` and nowhere else. This holds regardless of
which server surface answered: the REST server replies with a flat
`{ error, code, fields }` body while the runtime dispatcher replies with a
wrapped `{ success, error: { message, code, details } }` body whose `error.code`
is the HTTP status, and the client normalizes both before throwing.
`{ error, code, fields }` body and the runtime dispatcher replies with a wrapped
`{ success, error: { code, message, httpStatus, details } }` body, and the
client normalizes both before throwing. (Dispatcher bodies from servers older
than #3842 put the status in `error.code` and the semantic code in
`error.details.code`; the client still reads those, so an SDK newer than the
server it talks to behaves the same.)

### Per-field validation errors

Expand Down
30 changes: 25 additions & 5 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Complete reference for all ObjectStack error codes with causes, fix

# Error Code Catalog

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

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

<Callout type="warn">
**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.

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

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

---

## Request Errors (405/428)

Added in #3842 so `standardErrorCodeForHttpStatus` can name every status the
runtime actually returns. Without them a `405` would fall into the generic 4xx
bucket and be reported as a validation failure.

### `method_not_allowed`
**Cause:** The route exists but does not serve this HTTP method.
**Fix:** Use the method named in the response's `Allow` header.
**Retry:** `no_retry`

### `precondition_required`
**Cause:** The request is missing a precondition the route requires — most often
an environment scope (no `X-Environment-Id` header and no hostname mapping).
**Fix:** Send the missing header, or address the environment-scoped URL form.
**Retry:** `no_retry`

---

## Rate Limit Errors (429)

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

### TypeScript Example
Expand Down
11 changes: 8 additions & 3 deletions content/docs/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,24 @@ Validation failures additionally include a `fields` array (one entry per invalid
| `RECORD_NOT_FOUND` | 404 | Resource does not exist |
| `CONCURRENT_UPDATE` | 409 | Record was modified by another user |

**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope, where `code` is the numeric HTTP status:
**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:

```json
{
"success": false,
"error": {
"code": "RECORD_NOT_FOUND",
"message": "Record not found: account/123",
"code": 404,
"details": {}
"httpStatus": 404
}
}
```

Branch on `error.code`, never on `error.httpStatus` — the status answers "what
class of failure" and the code answers "which failure". A branch that has no
code of its own is served a `StandardErrorCode` derived from the status
(`403` → `permission_denied`, `503` → `service_unavailable`, …).

<Callout type="info">
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`.
</Callout>
Expand Down
38 changes: 38 additions & 0 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,44 @@ Read `body.error.code`, not `body.code`, on these routes. `ObjectStackClient`
normalizes both — `error.code` and `error.message` are populated whichever
envelope the server used — so SDK callers do not need to branch.

### Dispatcher routes use it too

Everything the runtime dispatcher serves — `/api/v1/meta/*`, `/actions/*`,
`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, … — answers in the same
declared envelope, plus `httpStatus`:

```json
{
"success": false,
"error": {
"code": "PROJECT_MEMBERSHIP_REQUIRED",
"message": "Forbidden: user usr_01H… is not a member of project env_prod",
"httpStatus": 403,
"details": { "environmentId": "env_prod", "userId": "usr_01H…" }
}
}
```

| Field | Meaning |
|:---|:---|
| `code` | The semantic code — the field to branch on. Never a number. |
| `message` | Human-readable text, safe to show. 5xx messages are sanitised. |
| `httpStatus` | The response status, mirrored. Redundant with the response line by design. |
| `details` | Structured context only (`fields[]`, `issues[]`, ids). Never a parked code. |

A route-resolution failure spells `code` from `DispatcherErrorCode`
(`ROUTE_NOT_FOUND`, …) and adds `route` / `hint` / `service` as siblings. A
branch with no code of its own gets a `StandardErrorCode` derived from the
status.

<Callout type="info">
Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this
envelope put the HTTP status in `error.code` and the real code in
`error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient`
reads all of them, so an SDK newer than the server it talks to is unaffected;
code reading these bodies directly should move to `error.code`.
</Callout>

---

## 8. Batch Operations
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ cubes: { name: string; title?: string; description?: string; sql: string; … }[] }` | ✅ | |

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

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

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const result = AuthProvider.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ session: object; user: object; token?: string }` | ✅ | |

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

Expand Down
Loading
Loading