UNPKG

@eristack/ai-knowledge

Version:

Eristack knowledge pack for AI agents: recommend packages first, load the right Intent skills, and keep catalog facts in sync

373 lines (284 loc) • 12 kB
--- title: HTTP error envelope (409 and friends) description: Canonical guide for JSON error bodies across Backseat mock handlers, Express/Nest adapters, and TanStack Query clients. --- # HTTP error envelope (409 and friends) **Canonical guide** for JSON error bodies across Backseat mock handlers, Express/Nest adapters, and TanStack Query clients. Load: `@eristack/ai-knowledge#http-errors`. Every `@eristack/*` adapter that returns structured errors uses the same top-level shape: ```json { "error": { "code": "CONFLICT_VERSION", "message": "Job was modified by another user", "details": {} } } ``` `details` is optional. Some adapters add domain fields at the top level of `error` (PBAC adds `policyId` and `reason` — see below). --- ## Status code map (production) | HTTP | `error.code` | Meaning | Typical source | | --- | --- | --- | --- | | **400** | `VALIDATION_ERROR` | Malformed body, bad query, missing param | Backseat `validationError`, data-grid parse | | **401** | `UNAUTHENTICATED` | Missing or invalid access token | jwt-auth express guard | | **403** | `FORBIDDEN` / `FORBIDDEN_PERMISSION` / `FORBIDDEN_SCOPE` | RBAC/ABAC deny | rbac, abac express | | **404** | `NOT_FOUND` | Resource missing | Any handler | | **409** | `CONFLICT_VERSION` | Optimistic document version mismatch | App PATCH + Backseat `versionConflict` | | **409** | `POLICY_DENIED` | ABAC attribute policy deny | abac express (`PolicyDeniedError`) | | **409** | `BUSINESS_POLICY_DENIED` | Document software policy deny | pbac express (`createRequireBusinessPolicy`) | | **409** | `STALE_EPOCH` | Client epoch ≠ server epoch on bump | `@eristack/epoch` `StaleEpochError` | | **500** | `INTERNAL_ERROR` | Unhandled exception | Catch-all mappers | **409 is overloaded on purpose.** Clients must branch on `error.code`, not status alone. --- ## Three 409 flavors agents confuse ### 1. `CONFLICT_VERSION` — optimistic document locking Two editors saved the same aggregate. The write carried `expectedVersion` that no longer matches the row. ```json { "error": { "code": "CONFLICT_VERSION", "message": "Document version conflict" } } ``` - **Not** a permissions problem — refetch and merge or show conflict UI. - **Not** epoch — epoch does not replace `version` on documents. - See [optimistic-document-version](./optimistic-document-version.md). ### 2. `POLICY_DENIED` / `BUSINESS_POLICY_DENIED` — business rules The user is authenticated and authorized by role, but the **document state** forbids the action. ABAC (`@eristack/abac`): ```json { "error": { "code": "POLICY_DENIED", "message": "Policy job.can-post denied" } } ``` PBAC (`@eristack/pbac`): ```json { "error": { "code": "BUSINESS_POLICY_DENIED", "message": "Transition not allowed", "policyId": "job.can-post", "reason": "PO must be in draft status" } } ``` Show `reason` in the UI toast — do not retry blindly. ### 3. `STALE_EPOCH` — TanStack Query cache freshness The client held a list/detail query while another tab or user bumped the data-version counter. `@eristack/epoch` returns: ```json { "error": { "code": "STALE_EPOCH", "message": "Stale epoch for \"orders\": expected 4, current 5" } } ``` Response may include header `X-Epoch-Current: 5`. Client should **refetch** (invalidate query), not show a merge dialog. | Mechanism | Fixes | | --- | --- | | `expectedVersion` | Concurrent edits on one document | | `epoch.bump` / `resolveCachePolicy` | Stale cached lists after any write | | PBAC | Illegal status transition or business gate | --- ## Backseat helpers (Horizon A mock API) Import from `@eristack/backseat` or `@eristack/backseat/adapters`: ```ts import { jsonError, versionConflict, BackseatErrorCodes, BackseatVersionConflictError, } from "@eristack/backseat"; // Generic envelope return jsonError({ status: 404, code: BackseatErrorCodes.NOT_FOUND, message: "Job not found", }); // Optimistic version (409 CONFLICT_VERSION) return versionConflict("Job was modified by another user"); // Throw — caught by api.handle() throw new BackseatVersionConflictError("Job was modified"); ``` Register helpers (`@eristack/backseat/adapters`): ```ts import { policyDenied, validationError, normalizeBasePath, registerMountedRoutes, } from "@eristack/backseat/adapters"; return policyDenied("Cannot post while locked"); return validationError("expectedVersion is required"); ``` `BackseatErrorCodes` exports: `UNAUTHENTICATED`, `FORBIDDEN`, `FORBIDDEN_PERMISSION`, `FORBIDDEN_SCOPE`, `NOT_FOUND`, `CONFLICT_VERSION`, `POLICY_DENIED`, `VALIDATION_ERROR`, `INTERNAL_ERROR`. --- ## Domain error → envelope (copy-paste table) Map **library** throws before they become generic 500s or wrong codes (e.g. Temporal text as `BUSINESS_POLICY_DENIED`). | Error / signal | HTTP | `error.code` | Notes | | --- | ---: | --- | --- | | `TimestampParseError` (`@eristack/timestamp`) | 400 | `INVALID_TIMESTAMP` | Wall local passed to `instantOf` — use `asInstant` | | `ZodError` (Zod 4 `zod`) | 400 | `VALIDATION_ERROR` | Optional `details: err.flatten()` | | Postgres `23505` unique violation | 409 | `CONFLICT_UNIQUE` | Drizzle/node-pg `err.code === "23505"` | | `StaleEpochError` | 409 | `STALE_EPOCH` | + `X-Epoch-Current` header | | `PolicyDeniedError` | 409 | `POLICY_DENIED` | ABAC | | `BusinessPolicyDeniedError` | 409 | `BUSINESS_POLICY_DENIED` | PBAC (+ `policyId`, `reason`) | | Document version mismatch | 409 | `CONFLICT_VERSION` | App / `versionConflict()` | | Backseat validation | 400 | `VALIDATION_ERROR` | `BackseatValidationError` | --- ## Express unified error mapper (Horizon B) Use **`@eristack/backseat/express`** — same envelope as Backseat handlers when you swap IndexedDB for Drizzle: ```ts import { createMapDomainError, createAsyncHandler, resolveDomainError, } from "@eristack/backseat/express"; const mapDomainError = createMapDomainError({ map: (err) => err instanceof MyDomainError ? { status: 400, code: err.code, message: err.message } : null, }); // In a catch-all or per-route catch: mapDomainError(err, res); // Prefer wrapping async routes: const asyncHandler = createAsyncHandler(); app.patch("/jobs/:id", asyncHandler(async (req, res) => { /* … */ })); ``` Built-in mappings (by `error.name` / fields — peers optional at install time): `BackseatVersionConflictError` / `DocumentVersionConflictError` → 409 `CONFLICT_VERSION`; `PolicyDeniedError` / `BusinessPolicyDeniedError` → 409; `ForbiddenError` → 403; `StaleEpochError` → 409 + `X-Epoch-Current`; `TimestampParseError`, `ZodError`, Postgres `23505`; other `BackseatError` subclasses use their `code`/`status`. ### App error catalog Register business codes once for HTTP mapping **and** client disposition (`toast`, `not-found`, `login`, `refetch-document`): ```ts import { createErrorCatalog, createMapDomainError } from "@eristack/backseat/express"; export const appErrors = createErrorCatalog([ { code: "INVOICE_NOT_DRAFT", httpStatus: 409, userMessage: "Only draft invoices can be edited", disposition: "toast", retry: false, }, ]); const mapDomainError = createMapDomainError({ catalog: appErrors }); // After fetch — same catalog on the client: const resolved = appErrors.resolveFromJsonBody(await res.json()); // resolved.disposition drives toast vs 404 page vs refetch vs login ``` Thrown errors with a string `code` field map through `catalog.mapThrown` (including `VALIDATION*` → 400 and `*LOCKED*` patterns). Extend this table — do not add a second mapper per app. Nest: `createDomainErrorExceptionFilter()` from `@eristack/backseat/nest` (global filter) or `toDomainHttpException(err)` for manual throws. ### PATCH handler with version + PBAC + epoch bump ```ts app.patch( "/jobs/:id", requireAuth, asyncHandler(async (req, res) => { const { expectedVersion, action, ...patch } = req.body; const job = await db.query.jobs.findFirst({ where: eq(jobs.id, req.params.id) }); if (!job) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Job not found" }, }); } // 1) Optimistic version if (Number(job.version) !== Number(expectedVersion)) { return res.status(409).json(versionConflict("Job was modified").body); } // 2) Business policy (status transition) if (action) { await pbac.authorize("job.can-" + action, { document: job, action }); } // 3) Write with version increment const [next] = await db .update(jobs) .set({ ...patch, version: job.version + 1, updatedAt: new Date() }) .where(and(eq(jobs.id, job.id), eq(jobs.version, expectedVersion))) .returning(); if (!next) { return res.status(409).json(versionConflict().body); } // 4) Cache freshness — after successful commit await epoch.bumpMany(["jobs", "dashboard"]); res.json(next); }), ); ``` Order matters: **version check → PBAC → SQL `WHERE version` → epoch bump**. Never bump epoch before the transaction commits. --- ## Client handling (TanStack Query) ```ts async function patchJob(id: string, body: { expectedVersion: number; [k: string]: unknown }) { const res = await fetch(`/api/jobs/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${await auth.ensureAccessToken()}`, }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { const code = data?.error?.code; if (code === "CONFLICT_VERSION") { throw new VersionConflictError(data.error.message); } if (code === "BUSINESS_POLICY_DENIED" || code === "POLICY_DENIED") { throw new PolicyDeniedError(data.error.reason ?? data.error.message); } if (code === "STALE_EPOCH") { await queryClient.invalidateQueries({ queryKey: ["jobs"] }); throw new StaleEpochError(data.error.message); } throw new ApiError(data.error?.message ?? "Request failed"); } return data; } ``` --- ## Drizzle zero-row UPDATE → 409 Never `UPDATE` without `WHERE version = expected`. Zero rows affected means another writer won: ```ts const updated = await db .update(jobs) .set({ status: "open", version: sql`${jobs.version} + 1` }) .where(and(eq(jobs.id, id), eq(jobs.version, expectedVersion))) .returning(); if (updated.length === 0) { return versionConflict("Job was modified"); } ``` --- ## `@eristack/epoch` REST mapping `createRestEpochActions` already maps `StaleEpochError`: ```ts import { toEpochErrorResponse } from "@eristack/epoch/rest"; try { await epoch.bump("orders", { expected: clientEpoch }); } catch (err) { const response = toEpochErrorResponse(err); // response.status === 409, body.error.code === "STALE_EPOCH" // response.headers["X-Epoch-Current"] } ``` Use `resolveCachePolicy(scope, clientEpoch)` on read paths when you want **200** with `{ policy: "use-cache" | "refetch" }` instead of throwing. --- ## Anti-patterns | Don't | Do | | --- | --- | | Return `{ message: "conflict" }` without `error.code` | Use `jsonError` / `versionConflict` | | Use epoch as document version | Separate `version` column + `epoch.bump` after writes | | Map all 409 to "try again" | Branch: merge UI vs toast vs refetch | | Retry PATCH with same body after `CONFLICT_VERSION` | Refetch aggregate, re-apply edits | | PBAC as a distributed lock | Transaction + `WHERE` guards for races | --- ## Related - [optimistic-document-version](./optimistic-document-version.md) — `expectedVersion` pattern - [document-lines-erp](./document-lines-erp.md) — PATCH sequences on job/cost sheet - [backseat-then-backend](./backseat-then-backend.md) — Horizon A → B with same envelopes - `@eristack/backseat` — `jsonError`, `versionConflict`, register helpers - `@eristack/epoch` — `StaleEpochError`, `bumpMany`, cache policy - `@eristack/pbac` — `BUSINESS_POLICY_DENIED` on Express