Skip to content

Commit cbc08eb

Browse files
authored
fix(client): normalize both server error envelopes so err.code / err.fields mean one thing (#3918 follow-up) (#3927)
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`. Docs updated in the same change: `api/client-sdk.mdx` gains the `fields` contract and the code-vs-status rule, and `api/error-catalog.mdx`'s client-side section is corrected — it claimed the SDK attaches no field list while its own example called `apiError.fieldErrors`, which could never have worked. Covered by 8 cases in client.test.ts; the 4 targeting the wrapped envelope fail on the pre-fix source.
1 parent f752ee3 commit cbc08eb

5 files changed

Lines changed: 251 additions & 13 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/client": patch
3+
---
4+
5+
fix(client): normalize both server error envelopes so `err.code` / `err.fields` mean one thing (#3918 follow-up)
6+
7+
Two envelopes are in play and they disagree about where the semantic code and
8+
the per-field list live:
9+
10+
```
11+
@objectstack/rest, flat:
12+
{ error, code: 'VALIDATION_FAILED', fields: [...] }
13+
14+
runtime dispatcher, wrapped:
15+
{ success: false, error: { message, code: 400,
16+
details: { code: 'VALIDATION_FAILED', fields: [...] } } }
17+
```
18+
19+
`error.code` in the **wrapped** form is the HTTP status, not a semantic code.
20+
The client read it straight through, so `err.code` was the **number 400** where
21+
the flat envelope gave `'VALIDATION_FAILED'` — meaning the branch our own docs
22+
teach,
23+
24+
```js
25+
if (err.code === 'VALIDATION_FAILED') err.fields.forEach(…)
26+
```
27+
28+
never matched on a dispatcher-served surface, and the field list (put on the
29+
wire for those routes by #3918) was unreachable at `err.details.error.details.fields`.
30+
31+
Now normalized at the throw site:
32+
33+
- **`err.code` is always the semantic string.** It is read from the flat
34+
`code`, else the wrapped `error.details.code`, else a *string* `error.code`
35+
a numeric value is never reported as a code. The HTTP status is on
36+
`err.httpStatus`, where it always was.
37+
- **`err.fields` is the per-field list** whenever the server sent one, from
38+
either envelope. It is left **unset** (not `[]`) when there is none, so
39+
`if (err.fields)` is a safe test for "this failure is field-anchored".
40+
- **`err.details`** prefers a top-level `details` (unchanged), then the wrapped
41+
envelope's own `details`, then the whole body. The flat envelope has no
42+
top-level `details` and so keeps falling through to the whole body exactly as
43+
before — only the wrapped shape changes, and only from "the entire response"
44+
to the structured object it actually carries.
45+
46+
**Behaviour change worth noting:** code that read `err.code` from a
47+
dispatcher-served route previously got a number and now gets a string (or
48+
`undefined` where the server sent no semantic code). Nothing in this repo did —
49+
`err.httpStatus` was always the correct source for the status, and remains
50+
untouched — but a consumer that branched on `err.code === 400` should move to
51+
`err.httpStatus === 400`.

content/docs/api/client-sdk.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,34 @@ try {
517517
}
518518
```
519519

520+
`error.code` is always the **semantic** code as a string — the numeric HTTP
521+
status lives on `error.httpStatus` and nowhere else. This holds regardless of
522+
which server surface answered: the REST server replies with a flat
523+
`{ error, code, fields }` body while the runtime dispatcher replies with a
524+
wrapped `{ success, error: { message, code, details } }` body whose `error.code`
525+
is the HTTP status, and the client normalizes both before throwing.
526+
527+
### Per-field validation errors
528+
529+
A validation failure additionally carries `error.fields` — one entry per
530+
offending field, ready to attach to the inputs that produced them:
531+
532+
```typescript
533+
try {
534+
await client.data.create('contact', { email: 'not-an-email' });
535+
} catch (error) {
536+
if (error.code === 'VALIDATION_FAILED') {
537+
for (const f of error.fields ?? []) {
538+
showFieldError(f.field, f.message); // 'email', 'email must be a valid email address'
539+
}
540+
}
541+
}
542+
```
543+
544+
`error.fields` is left **unset** (not `[]`) when the server reported no
545+
per-field detail, so `if (error.fields)` is a safe test for "this failure is
546+
field-anchored".
547+
520548
### Error Codes
521549

522550
| Code | HTTP | Category | Retryable | Description |

content/docs/api/error-catalog.mdx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -389,30 +389,49 @@ interface FieldError {
389389
## Client-Side Error Handling
390390

391391
<Callout type="warn">
392-
The `@objectstack/client` SDK's built-in fetch error handling attaches only
393-
`code`, `category`, `httpStatus`, `retryable`, and `details` to the thrown
394-
error — not `fieldErrors`, `retryAfter`, or `requestId` directly. If your
395-
server populates those on the response body, read them from
396-
`apiError.details` (e.g. `apiError.details?.fieldErrors`) until the client
397-
surfaces them at the top level.
392+
The `@objectstack/client` SDK's built-in fetch error handling attaches
393+
`code`, `category`, `httpStatus`, `retryable`, `details`, and — for validation
394+
failures — `fields`. It does **not** attach `retryAfter` or `requestId`
395+
directly; if your server populates those on the response body, read them from
396+
`apiError.details` until the client surfaces them at the top level.
397+
398+
Two naming details matter here:
399+
400+
- The per-field list is `apiError.fields`, matching the wire (`fields[]`), not
401+
the spec contract's `fieldErrors`. Each entry is `{ field, code, message }`.
402+
It is left **unset** when the server reported no per-field detail, so
403+
`if (apiError.fields)` tests "this failure is field-anchored".
404+
- `apiError.code` is always the semantic code as a **string**. The numeric HTTP
405+
status is on `apiError.httpStatus` and nowhere else. This holds for both wire
406+
formats: the flat REST envelope carries the code at the top level, the runtime
407+
dispatcher's wrapped envelope carries it in `error.details.code` (its
408+
`error.code` is the HTTP status), and the client normalizes both before
409+
throwing.
398410
</Callout>
399411

400412
### TypeScript Example
401413

402414
```typescript
403-
import type { EnhancedApiError } from '@objectstack/spec/api';
415+
import type { EnhancedApiError, FieldError } from '@objectstack/spec/api';
416+
417+
// The spec type names the per-field list `fieldErrors`; what the client
418+
// actually attaches at runtime is `fields` (the wire name). Widen the cast
419+
// until the two names converge.
420+
type ThrownApiError = EnhancedApiError & { fields?: FieldError[] };
404421

405422
async function handleApiCall() {
406423
try {
407424
const result = await client.data.create('task', { title: 'New Task' });
408425
return result;
409426
} catch (error) {
410-
const apiError = error as EnhancedApiError;
427+
const apiError = error as ThrownApiError;
411428

412429
switch (apiError.category) {
413430
case 'validation':
414-
// Show field-level errors to the user
415-
apiError.fieldErrors?.forEach(fe => {
431+
// Show field-level errors to the user. `fields` is what the client
432+
// attaches (the wire name); the spec type calls the same idea
433+
// `fieldErrors`.
434+
apiError.fields?.forEach(fe => {
416435
showFieldError(fe.field, fe.message);
417436
});
418437
break;

packages/client/src/client.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,6 +1458,111 @@ describe('HTTP error shaping', () => {
14581458
});
14591459
});
14601460

1461+
// ---------------------------------------------------------------------------
1462+
// #3918 follow-up — one error shape across both server envelopes.
1463+
//
1464+
// `@objectstack/rest` answers flat (`{ error, code, fields }`); the runtime
1465+
// dispatcher answers wrapped (`{ success, error: { message, code, details } }`)
1466+
// where `error.code` is the HTTP STATUS and the semantic code lives in
1467+
// `details.code`. Reading the wrapped `error.code` straight through handed
1468+
// callers the number 400 where the flat form handed them 'VALIDATION_FAILED',
1469+
// so the branch our own docs teach never matched on a dispatcher-served
1470+
// surface. These pin the normalisation that makes `err.code` / `err.fields`
1471+
// mean the same thing whichever surface answered.
1472+
// ---------------------------------------------------------------------------
1473+
describe('HTTP error shaping — envelope normalisation', () => {
1474+
const FIELDS = [
1475+
{ field: 'email', code: 'invalid_email', message: 'email must be a valid email address' },
1476+
];
1477+
1478+
/** What @objectstack/rest's `mapDataError` puts on the wire. */
1479+
const FLAT = { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: FIELDS };
1480+
1481+
/** What the runtime dispatcher puts on the wire since #3918. */
1482+
const WRAPPED = {
1483+
success: false,
1484+
error: {
1485+
message: 'Validation failed',
1486+
code: 400,
1487+
details: { code: 'VALIDATION_FAILED', fields: FIELDS },
1488+
},
1489+
};
1490+
1491+
it('exposes the SEMANTIC code from the flat envelope', async () => {
1492+
const { client } = createMockClient(FLAT, 400);
1493+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1494+
1495+
expect(caught.code).toBe('VALIDATION_FAILED');
1496+
expect(caught.httpStatus).toBe(400);
1497+
});
1498+
1499+
it('exposes the SEMANTIC code from the wrapped envelope, not the HTTP status', async () => {
1500+
// The regression this guards: `caught.code` used to be the number 400.
1501+
const { client } = createMockClient(WRAPPED, 400);
1502+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1503+
1504+
expect(caught.code).toBe('VALIDATION_FAILED');
1505+
expect(caught.code).not.toBe(400);
1506+
// The status is still available — it just isn't `code`.
1507+
expect(caught.httpStatus).toBe(400);
1508+
});
1509+
1510+
it('exposes `fields[]` at the same place for BOTH envelopes', async () => {
1511+
const flat: any = await createMockClient(FLAT, 400).client.data
1512+
.delete('pm_base', 'rec_1').catch((e) => e);
1513+
const wrapped: any = await createMockClient(WRAPPED, 400).client.data
1514+
.delete('pm_base', 'rec_1').catch((e) => e);
1515+
1516+
expect(flat.fields).toEqual(FIELDS);
1517+
expect(wrapped.fields).toEqual(FIELDS);
1518+
});
1519+
1520+
it('leaves `fields` unset when the server sent none', async () => {
1521+
// Callers branch on presence; an empty array would be a lie about a
1522+
// failure that had nothing to do with per-field validation.
1523+
const { client } = createMockClient({ error: 'nope', code: 'PERMISSION_DENIED' }, 403);
1524+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1525+
1526+
expect(caught.fields).toBeUndefined();
1527+
expect(caught.code).toBe('PERMISSION_DENIED');
1528+
});
1529+
1530+
it('never reports a numeric code, even with no details to fall back on', async () => {
1531+
// A pre-#3918 dispatcher body: wrapped, but no `details` at all.
1532+
const { client } = createMockClient(
1533+
{ success: false, error: { message: 'boom', code: 500 } },
1534+
500,
1535+
);
1536+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1537+
1538+
expect(caught.code).toBeUndefined();
1539+
expect(caught.message).toBe('boom');
1540+
expect(caught.httpStatus).toBe(500);
1541+
});
1542+
1543+
it('keeps `details` = the whole body for the flat envelope (unchanged)', async () => {
1544+
const { client } = createMockClient(FLAT, 400);
1545+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1546+
1547+
expect(caught.details).toEqual(FLAT);
1548+
});
1549+
1550+
it('points `details` at the structured object for the wrapped envelope', async () => {
1551+
const { client } = createMockClient(WRAPPED, 400);
1552+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1553+
1554+
expect(caught.details).toEqual({ code: 'VALIDATION_FAILED', fields: FIELDS });
1555+
});
1556+
1557+
it('still honours a top-level `details` when the server sends one', async () => {
1558+
const body = { message: 'bad', code: 'X', details: { limit: 1000 } };
1559+
const { client } = createMockClient(body, 400);
1560+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1561+
1562+
expect(caught.details).toEqual({ limit: 1000 });
1563+
});
1564+
});
1565+
14611566
describe('packages.install', () => {
14621567
const MANIFEST = { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' };
14631568

packages/client/src/index.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4378,20 +4378,55 @@ export class ObjectStackClient {
43784378
?? errorBody?.error?.message
43794379
?? (typeof errorBody?.error === 'string' ? errorBody.error : undefined)
43804380
?? res.statusText;
4381-
const errorCode = errorBody?.code || errorBody?.error?.code;
4381+
// Two server envelopes are in play, and they disagree about where the
4382+
// SEMANTIC code and the per-field list live:
4383+
//
4384+
// @objectstack/rest, flat:
4385+
// { error, code: 'VALIDATION_FAILED', fields: [...] }
4386+
// runtime dispatcher, wrapped:
4387+
// { success: false, error: { message, code: 400,
4388+
// details: { code: 'VALIDATION_FAILED', fields: [...] } } }
4389+
//
4390+
// Note `error.code` in the WRAPPED form is the HTTP status, not a
4391+
// semantic code. Reading it straight into `err.code` handed callers the
4392+
// number 400 where the flat form handed them 'VALIDATION_FAILED', so the
4393+
// branch our own docs teach —
4394+
// `if (err.code === 'VALIDATION_FAILED') err.fields.forEach(…)`
4395+
// — simply never matched on a dispatcher-served surface. #3918 put the
4396+
// field list on the wire for those routes; this is what lets a caller
4397+
// reach it without knowing which surface answered.
4398+
//
4399+
// So: `err.code` is always the semantic STRING (the numeric status is on
4400+
// `err.httpStatus`, where it always was), and `err.fields` is always the
4401+
// per-field list when the server sent one.
4402+
const asSemanticCode = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
4403+
const errorCode =
4404+
asSemanticCode(errorBody?.code)
4405+
?? asSemanticCode(errorBody?.error?.details?.code)
4406+
?? asSemanticCode(errorBody?.error?.code);
4407+
const fieldErrors =
4408+
Array.isArray(errorBody?.fields) ? errorBody.fields
4409+
: Array.isArray(errorBody?.error?.details?.fields) ? errorBody.error.details.fields
4410+
: undefined;
43824411
// `.message` is what UIs (e.g. the console's error toast) show to end
43834412
// users verbatim, so keep it to the server's human-readable message —
43844413
// no `[ObjectStack]` branding and no `CODE:` prefix. The code stays
43854414
// available programmatically via `error.code`, and the full response
43864415
// body was already logged above for debugging.
43874416
const error = new Error(errorMessage) as any;
4388-
4417+
43894418
// Attach error details for programmatic access
43904419
error.code = errorCode;
43914420
error.category = errorBody?.category;
43924421
error.httpStatus = res.status;
43934422
error.retryable = errorBody?.retryable;
4394-
error.details = errorBody?.details || errorBody;
4423+
// Prefer the wrapped envelope's own `details` over the whole body. The
4424+
// flat envelope has no top-level `details`, so it keeps falling through
4425+
// to `errorBody` exactly as before — only the wrapped shape changes,
4426+
// and only from "the entire response" to the structured object it
4427+
// actually carries.
4428+
error.details = errorBody?.details ?? errorBody?.error?.details ?? errorBody;
4429+
if (fieldErrors) error.fields = fieldErrors;
43954430

43964431
throw error;
43974432
}

0 commit comments

Comments
 (0)