ObjectStackObjectStack

Error handling — the response envelope

Global error codes, response formats, and debugging strategies for ObjectStack APIs

The Error Handling Protocol defines the standardized error codes, the two response envelopes that carry them, and debugging strategies across the ObjectStack APIs (HTTP, WebSocket).

Why Standardized Errors Matter

Problem: Traditional APIs return errors inconsistently:

// Different error structures across endpoints 😱
API 1: { error: "Not found" }
API 2: { errors: [{ message: "Not found" }] }
API 3: { success: false, msg: "Not found", code: 404 }
API 4: HTTP 200 OK with { status: "error", ... }

Developer pain:

  • Every endpoint requires custom error handling logic
  • Difficult to show user-friendly messages
  • Debugging is nightmare (where did this error originate?)
  • Monitoring/alerting is inconsistent

Solution: ObjectStack answers every error with a machine-readable code, a human-readable message and context for debugging, drawn from one closed code vocabulary (ADR-0112).

⚠️ What it does not do is put them all in one envelope. The platform writes two error envelopes, and which one you receive is a property of the door that answered — see Error Response Envelopes below before you write an unwrapping helper.

Business Value Delivered

Better User Experience

Consistent error messages guide users to resolution. No generic 'Something went wrong' nonsense.

Faster Debugging

Error codes + the X-Request-Id response header = find root cause in seconds. Save hours of log hunting.

Automated Monitoring

Alert on specific error codes (e.g., RATE_LIMIT_EXCEEDED spikes = upgrade prompts working).

Security Hardening

Never leak sensitive info in errors. Attackers can't probe your system via error messages.

Error Response Envelopes

ObjectStack has two live error envelopes. There is no third, and there is no universal one.

The rule

The envelope is decided by the door that wrote the body — never by the error code and never by the HTTP status.

Two corollaries, and both of them bite:

  • A refusal a writer builds always carries that writer's envelope. UNIQUE_VIOLATION is built by the /data door's classification arms, so it is always flat. RATE_LIMIT_EXCEEDED is built by the inbound rate limiter, so it is always nested.
  • A refusal that is thrown and classified at the boundary carries the envelope of whichever door caught it. PERMISSION_DENIED, RESOURCE_NOT_FOUND, SERVICE_UNAVAILABLE and INTERNAL_ERROR are all thrown by shared guards and reach clients in either envelope depending on the route. So are DELETE_RESTRICTED, INVALID_FIELD and OBJECT_NOT_FOUND, which the engine and the protocol throw: the @objectstack/rest /data door answers them flat, and a Hono door answers them nested, with their structured fields under error.details.

So do not derive the envelope from the code. Derive it from the body you are holding:

// The one discriminator that cannot go stale: the top-level `success` flag.
const isNested = body !== null && typeof body === 'object' && 'success' in body;

const code    = isNested ? body.error?.code    : body.code;
const message = isNested ? body.error?.message : body.error;

error means two different things. In the nested envelope error is an object holding code and message. In the flat envelope error is the message string itself. Reading body.error as the same thing in both is the single most expensive mistake on this page — body.error.code is undefined against a flat body, and String(body.error) is [object Object] against a nested one.

Nested envelope — { success, error: { … } }

The envelope BaseResponseSchema declares. Written by three writers:

  • sendOk / sendError in packages/types/src/response-envelope.ts — the shared pair every converted route module writes through.
  • buildApiError / apiErrorResponse in packages/runtime/src/error-envelope.ts — the runtime HTTP dispatcher's door, and the inbound rate limiter.
  • declaredEnvelopeForThrow in @objectstack/plugin-hono-server — the Hono doors' renderer for a declared refusal that escapes a handler.
{
  "success": false,
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Sign in to create share links"
  }
}

Fields:

  • success — always false for errors.
  • error.code — the machine-readable code. Closed vocabulary: the parameter is typed to ErrorCode, so an unregistered spelling cannot reach this field.
  • error.message — the human-readable message.
  • error.httpStatus — the status, repeated in the body. Written by the dispatcher writer on every body it builds; no sendError call site passes one today, so it is absent from the route-module bodies, and the Hono doors' renderer never writes it.
  • error.details — optional structured context, present only where the writer has some. The route modules pass one on a handful of refusals (rate limiting, settings validation, partial-delete reports). The Hono doors put there the per-condition siblings their table lists.
  • error.declaredCode — optional. An author's own code when it is not a member of the closed vocabulary, demoted to this sibling rather than overwriting code.
  • error.userMessage — optional. Text a producer marked, at throw time, as addressed to the end user; render it verbatim when present.

⛔ No requestId and no timestamp. Neither field is written into any error body by either writer. The request id travels as a response header — see Debugging with Request IDs.

Flat envelope — the ADR-0112 /data door

Written by packages/rest/src/error-response.ts (structuredCodeAnswer classifies the condition, resolveErrorResponse / mapDataError answer it). The structured context the nested envelope would nest under details rides as top-level siblings here.

{
  "error": "A record with this email already exists",
  "code": "UNIQUE_VIOLATION",
  "field": "email",
  "object": "account"
}

Fields:

  • error — the human-readable message, a string.
  • code — the machine-readable code. Optional: ADR-0112 invents nothing for the half a producer did not name, so a refusal that declared a status but no code arrives with error alone. Branch on code only after checking it is there.
  • object — the object the refusal is about, when the door knows it.
  • declaredCode / userMessage — the same two optional channels as above, as top-level siblings.
  • Per-condition siblings, present only on the arm that builds them: field (UNIQUE_VIOLATION, INVALID_FIELD), fields (VALIDATION_FAILED), developerMessage + dependentObject + dependentCount (DELETE_RESTRICTED), currentVersion + currentRecord (CONCURRENT_UPDATE), datasource + reason (ERR_DATASOURCE_UNAVAILABLE), issues (a spec-validation throw).

⛔ No success, no details, no requestId, no timestamp. The flat door writes none of them.

Which route families answer which

Route familyEnvelopeWriter
{base}/data/* on the @objectstack/rest door — CRUD, query, batch, createMany / updateMany / deleteMany, clone, import / export, reports, approvals, sharing rulesflatpackages/rest/src/error-response.ts
{base}/meta/*, {base}/ui/*, discovery and search on the @objectstack/rest door, for anything thrown out of a handlerflatthe same door, via handleRouteError
/api/v1/storage/*nestedstorage-routes.ts → sendError
/api/settings/*nestedsettings-routes.ts → sendError
/api/v1/share-links/*nestedshare-link-routes.ts → sendError
/api/v1/packages/*nestedpackage-routes.ts → sendError
/api/v1/datasources/*nestedadmin-routes.ts, external-datasource-routes.ts → sendError
/api/v1/i18n/*nestedi18n-service-plugin.ts → sendError
every route on a host served by the runtime HTTP dispatcher (/meta, /auth, /ai, /automation, /packages, /ui, /security, /notifications, /analytics, /mcp, /keys, /actions, /share-links, /i18n)nestedbuildApiError / apiErrorResponse
the inbound rate limiter, ahead of every routenestedinbound-rate-limit.ts
a declared refusal thrown out of the dispatcher on a createHonoApp host — /data included only where no @objectstack/rest route is registered ahead of its catch-all (see the callout below) — or out of any route mounted on HonoHttpServernesteddeclaredEnvelopeForThrow — see Hono doors

The /meta and /data families are split by door, not by path. A host that serves /meta through the runtime dispatcher answers nested — that is the shape the error catalog's /meta examples publish — and so does one that serves /data through it (see Hono doors). A host that serves either through @objectstack/rest answers the flat body for the same refusal, because the same thrown error is classified by a different door. A createHonoApp host can be either. One that registers @objectstack/rest's routes on the same Hono app before it mounts createHonoApp — the hosted composition — answers those paths on the REST door, flat, because the routes registered first win the match. One with no @objectstack/rest route ahead of the catch-all answers them through the dispatcher, nested. Read the body, not the path.

Two envelopes is the measured state, not the target state. Some older registrars on the @objectstack/rest door still emit bodies that are neither — an error string with no code, or a code sibling with no success above it. That drift is tracked and shrink-only: pnpm check:route-envelope is the authority on which files carry it and how much is left. Write clients against the discriminator above, which is correct for the conforming bodies and fails loudly rather than silently on the rest.

Hono doors

Two hosts render a refusal that escapes the handler themselves: @objectstack/hono's createHonoApp, for a throw that leaves the runtime dispatcher (the /data routes' refusals do, on a host whose catch-all serves /data; a host that registers @objectstack/rest's routes ahead of it answers those flat — see Which route families answer which), and @objectstack/plugin-hono-server's HonoHttpServer, for a throw that escapes a route mounted on it. When the throw declares an ADR-0112 status and a registered code, both answer it in the nested envelope, written by declaredEnvelopeForThrow. The per-condition siblings the flat /data door carries for the codes below ride in error.details instead.

Example — DELETE /api/v1/data/account/acc_123 on a createHonoApp host whose catch-all serves /data:

{
  "success": false,
  "error": {
    "code": "DELETE_RESTRICTED",
    "message": "This Account is still referenced by 1 Contact record(s) through “Account”. Delete or reassign them first.",
    "details": {
      "developerMessage": "Cannot delete account (acc_123): 1 dependent contact record(s) reference it via account. Delete or reassign them first, or set deleteBehavior:'cascade' on contact.account.",
      "dependentObject": "contact",
      "dependentCount": 1
    }
  }
}
CodeIn error.details on a Hono door
DELETE_RESTRICTEDdeveloperMessage, dependentObject, and dependentCount when the flat door would send it
CONCURRENT_UPDATEcurrentVersion, currentRecord (the row as the caller reads it)
INVALID_FIELDfield, object
OBJECT_NOT_FOUNDobject
VALIDATION_FAILEDfields
FEEDS_DISABLED, FILES_DISABLED, ATTACHMENT_PARENT_ACCESS, ATTACHMENT_DELETE_DENIED, RECORD_NOT_ACCESSIBLEobject

⛔ What a Hono door never sends: httpStatus, and an object the flat door takes from the request's route (on DELETE_RESTRICTED, CONCURRENT_UPDATE and VALIDATION_FAILED). The message is the thrown one and can differ from the flat door's error: an unregistered object reads Object 'ghost' not found here and Object 'ghost' is not registered there.

HTTP Status Codes

ObjectStack uses standard HTTP status codes:

StatusMeaningWhen Used
400Bad RequestInvalid input, validation failure
401UnauthorizedAuthentication required or failed
403ForbiddenAuthenticated but insufficient permissions
404Not FoundResource doesn't exist
409ConflictResource already exists or version mismatch
413Content Too LargeAn upload over the storage settings' max_upload_mb, or an import over its row ceiling — answered as PAYLOAD_TOO_LARGE
422Unprocessable EntitySemantic validation failed (e.g. metadata spec validation); also an absent controlled_by_parent master reference — see MISSING_REQUIRED_FIELD
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer-side error
503Service UnavailableServer overloaded or maintenance

Important: an error response always carries a JSON body naming the condition — but in one of two envelopes. Read Error Response Envelopes before branching on it.

Error Codes

Every entry below names the envelope its example is written in, and which door emits it. A code that reaches clients in both envelopes says so and shows both bodies — that is the rule from Error Response Envelopes applied per code, not an inconsistency.

Authentication & Authorization

UNAUTHENTICATED

HTTP Status: 401
Meaning: No authentication credentials provided or invalid credentials
Envelope: both — thrown by shared guards, so the answering door decides

Nested (a sendError route module — here POST /api/v1/share-links):

{
  "success": false,
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Sign in to create share links"
  }
}

Flat (the @objectstack/rest door — here the sign-in gate on the generated API docs):

{
  "error": "This documentation requires sign-in",
  "code": "UNAUTHENTICATED"
}

⛔ No details bag rides either body: no emitting site passes one for this code.

How to fix:

  • Include valid JWT token in Authorization header
  • Refresh expired tokens
  • Re-authenticate user

INVALID_TOKEN

HTTP Status: 401
Meaning: Token is malformed or invalid
Envelope: nested — emitted only by the storage routes, on a rejected upload/download token

Example:

{
  "success": false,
  "error": {
    "code": "INVALID_TOKEN",
    "message": "Invalid or expired token"
  }
}

⛔ No details.reason: the emitting site deliberately does not disclose why the token failed.

How to fix:

  • Check token hasn't been tampered with
  • Verify token is meant for this API (check aud claim)
  • Ensure server secret key is correct

EXPIRED_TOKEN

HTTP Status: 401
Meaning: Authentication token expired
Envelope: none — no producer emits it

Not emitted today. EXPIRED_TOKEN is a registered member of the standard error-code catalog (StandardErrorCode, packages/spec/src/api/errors.zod.ts), and that registration is its only occurrence outside tests — no ObjectStack producer writes it to a response. Because nothing emits it, it has no envelope and no example: ⛔ do not write a client branch against it.

An expired credential surfaces as INVALID_TOKEN above (the storage token gate does not distinguish expiry from malformation) or as UNAUTHENTICATED from the session guards.

PERMISSION_DENIED

HTTP Status: 403
Meaning: Authenticated but insufficient permissions
Envelope: both — the identity and last-admin guards throw it, so the answering door decides

Flat (a /data write refused by the ADR-0092 identity-write guard):

{
  "error": "Identity table 'sys_user' is managed by better-auth (ADR-0092): direct update via the data API is disabled — use the dedicated auth surface instead (invite / create-user / admin endpoints, or the better-auth API).",
  "code": "PERMISSION_DENIED",
  "object": "sys_user"
}

⚠️ The guard throws PERMISSION_DENIED: <message>, and the door strips that prefix before writing the body: the code already rides the code axis, so restating it inside the sentence would ship one fact twice. ⛔ Do not match on a leading CODE: — it is removed.

Nested (a sendError route module — here the datasource admin routes):

{
  "success": false,
  "error": {
    "code": "PERMISSION_DENIED",
    "message": "Managing datasources requires the `manage_platform_settings` capability."
  }
}

⛔ Neither body enumerates the required or held permissions. Nothing emits a required_permission / user_permissions bag — telling an unauthorized caller exactly which grant would have worked is the disclosure the Security Considerations below rule out.

How to fix:

  • Request permission from administrator
  • Check row-level security rules
  • Verify user role assignments

Validation Errors

VALIDATION_FAILED

HTTP Status: 400
Meaning: Per-field record validation failed on a write
Envelope: flat on the @objectstack/rest /data door, built by its validation arm; nested on a Hono door

This is the code that carries per-field detail. On the flat door the fields array is a top-level sibling of code; on a Hono door the same array is error.details.fields.

Example — POST /api/v1/data/account with a bad email and an out-of-range number:

{
  "error": "Invalid email format; Revenue must be at least 0",
  "code": "VALIDATION_FAILED",
  "fields": [
    {
      "field": "email",
      "code": "invalid_email",
      "message": "Invalid email format",
      "label": "Email",
      "value": "not-an-email"
    },
    {
      "field": "revenue",
      "code": "min_value",
      "message": "Revenue must be at least 0",
      "label": "Revenue",
      "constraint": { "min": 0 }
    }
  ],
  "object": "account"
}

Field entry shape: field (the API name, so a form can focus the input), code (a FieldErrorCode — required, invalid_email, min_value, max_length, invalid_option, rule_violation, … — the closed per-field catalog in packages/spec/src/api/errors.zod.ts), message (rendered in the caller's locale), and optionally label, constraint, value, options.

⚠️ fields is always present on this arm, and is [] when the thrown error carried none. The top-level error string is the field messages joined with ; — show it verbatim in a toast, and use fields to annotate inputs.

How to fix:

  • Check field constraints in the object schema (GET /api/v1/meta/object/{object})
  • Validate input client-side before submission
  • Show field-specific errors in the UI

Client-side handling:

if (body.code === 'VALIDATION_FAILED') {
  for (const { field, message } of body.fields ?? []) {
    showFieldError(field, message);
  }
}

VALIDATION_ERROR

HTTP Status: 400
Meaning: The request is malformed — the generic "you sent something wrong" code
Envelope: both, and one emitter writes neither (see the warning below)

⛔ Not the per-field code. VALIDATION_ERROR never carries a fields array and never carries a details.fields bag. For per-field write validation, read VALIDATION_FAILED above.

Where it comes from:

  • Derived from the status. VALIDATION_ERROR is what HttpStatusErrorCodeMap (packages/spec/src/api/errors.zod.ts) maps HTTP 400 to. A door handed a refusal that names a 400 and no code labels it VALIDATION_ERROR from the status alone — so it is the code you see for the whole family of malformed requests that named nothing more specific.
  • Query-parameter refusals. refuseUnknownQueryParams and refuseRepeatedQueryParams answer 400 VALIDATION_ERROR for an unrecognised or repeated query parameter.

Not from a request too large for its door: an upload over the File Storage settings' max_upload_mb answers 413 with the registered code PAYLOAD_TOO_LARGE, not this one.

Nested (the dispatcher door, status-derived — GET /api/v1/packages/:id?version=1&version=2):

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The \"version\" query parameter was supplied 2 times. Supply it at most once — this endpoint will not choose between conflicting values.",
    "httpStatus": 400
  }
}

Flat (the @objectstack/rest form-submit door):

{
  "code": "VALIDATION_ERROR",
  "error": "This form declares no fields, so it cannot accept a submission. Wire the fields it collects into the form's sections and publish it again."
}

The two query-parameter helpers write a third shape. On the @objectstack/rest door, refuseUnknownQueryParams and refuseRepeatedQueryParams write { "error": { "code": "VALIDATION_ERROR", "message": … } } — nested per ADR-0112, but with no success flag above it, so the discriminator in Error Response Envelopes reads them as flat and body.code comes back undefined. That is tracked, shrink-only drift, not a third contract: pnpm check:route-envelope pins it. Until it converges, a client that must survive it should fall back to body.error?.code when body.code is absent.

MISSING_REQUIRED_FIELD

HTTP Status: 400 — with one documented exception, which answers 422 (see below)
Meaning: Required field is missing
Envelope: both — the 422 master-reference refusal is thrown at the /data door (flat); the 400 form is written by a sendError route module (nested)

⚠️ On a /data write, an ordinary missing required field does not answer this code at all — it answers 400 VALIDATION_FAILED with the field named in fields, above. This code is the one the master-access gate and the package-publish route use.

Flat (the 422 exception, on the /data door):

{
  "error": "[Security] Missing master reference: insert on 'invoice_line' did not supply 'invoice_id'. A controlled_by_parent detail derives its access from its master, so 'invoice_id' must carry a master record id on every write.",
  "code": "MISSING_REQUIRED_FIELD",
  "object": "invoice_line"
}

Nested (the 400, on POST /api/v1/packages/publish):

{
  "success": false,
  "error": {
    "code": "MISSING_REQUIRED_FIELD",
    "message": "Missing required fields: manifest, metadata"
  }
}

⛔ Neither body carries a details bag. On the flat 422 there is no fields array either — see the table below for why.

Exception — an absent controlled_by_parent master reference answers 422 with no fields. An object whose sharingModel is controlled_by_parent derives its access from a master record, so the gate that authorizes writes to it must resolve that master before the executor runs — and the executor is where required-field validation lives. When the master reference is absent, whichever of the two refuses first decides the envelope, and that depends on how the reference is declared:

Master reference declared asRefused byStatuscodefields
master_detail + required, not readonly/systemrequired-field validation400VALIDATION_FAILEDyes
master_detail + required + readonlythe master-access gate422MISSING_REQUIRED_FIELDabsent
master_detail + required + systemthe master-access gate422MISSING_REQUIRED_FIELDabsent

Only the first row is the documented 400 shape. On the other two, messages are prefixed [Security] Missing master reference: and name the object and the field, but no fields array rides along.

Two declarations are not rows of this table, because they name no master at all. A controlled_by_parent object derives its master from exactly one master_detail field declared required: true (ADR-0055 §1), so an object whose only relation is a master_detail without required, or a required lookup, derives none — and neither does one with two required master_detail fields. ObjectSchema refuses such an object at parse, naming it; one that reaches the runtime unparsed has every read denied and every write refused with 422 INVALID_METADATA, whatever the request carries.

The same 422 MISSING_REQUIRED_FIELD also answers an update or delete by id whose stored master reference is null, whatever the declaration. That is a different path: the caller supplied no such field, so there is no request field to name in fields and no payload that would fix it.

Why the gate refuses rather than handing over. Required-field validation skips provenance-flagged fields before its required check is reached (system and readonly fields are skipped outright). On those two shapes the gate is the only thing refusing the write, and letting it through was measured to create a detail record whose master reference is null — a record the controlled_by_parent read filter (fk IN (readable masters)) can never match, so it is readable by nobody and answers 422 on every later write by id. The refusal is correct; only its status departs from the rule above.

These shapes are authorable today. os lint refuses both at error (relationship/master-detail-required), but that rule is not part of the publish gate — os build, os validate and the metadata save door do not run it. So a stack can publish clean and still reach the 422. Branch on code, and read the status off the response rather than deriving it from this page.

INVALID_FIELD

HTTP Status: 400
Meaning: A field name in the request does not exist on the target object — a name that resolves to nothing, not a value of the wrong type. On a list read it also covers an unreserved query parameter, which GET /data/:object reads as a field filter.

Envelope: flat on the @objectstack/rest /data door, built by its INVALID_FIELD arm, with field and object as top-level siblings. On a Hono door it is nested, and the same two fields are error.details.field and error.details.object.

Example:

{
  "error": "Unknown field 'age' on object 'contact'",
  "code": "INVALID_FIELD",
  "field": "age",
  "object": "contact"
}

⚠️ field is present only when the refusal named one; object is present when the door knows which object was addressed.

The error catalog's INVALID_FIELD entry carries the authoritative cause text — it enumerates every read axis this one code answers on (select, expand, searchFields, groupBy, aggregations[].field) and the off-request backfillSummaryNulls case.

Resource Errors

RESOURCE_NOT_FOUND

HTTP Status: 404
Meaning: Requested resource doesn't exist
Envelope: nested — this is the code the sendError route modules and the status-derived dispatcher door answer 404 with

Example — GET /api/v1/datasources/warehouse:

{
  "success": false,
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "Datasource \"warehouse\" does not exist."
  }
}

⛔ No details.resource / details.resource_id bag: no emitting site writes one. The resource is named in message.

The /data door answers a different code. A record missing from a CRUD route is RECORD_NOT_FOUND in the flat envelope, with the object as a top-level sibling — see HTTP API:

{
  "error": "Record acc_999 not found in account",
  "code": "RECORD_NOT_FOUND",
  "object": "account"
}

A registered object that does not exist is OBJECT_NOT_FOUND, also flat on this door; a Hono door answers it nested, with object in error.details. Branch on all three if you handle both families.

How to fix:

  • Verify the resource id is correct
  • Check the user has permission to see the resource (row-level security)
  • The resource may have been deleted

UNIQUE_VIOLATION

HTTP Status: 409
Meaning: The write collides with a unique constraint — a record already holds that value
Envelope: flat — built by the /data door's duplicate-record arm, so it is never nested

Example:

{
  "error": "A record with this email already exists",
  "code": "UNIQUE_VIOLATION",
  "field": "email",
  "object": "account"
}

The engine throws DuplicateRecordError, whose in-process code is DUPLICATE_RECORD; the REST door translates that envelope at the boundary, so every route answers the wire code UNIQUE_VIOLATION and the in-process spelling never crosses HTTP. The refusal is emitted as the flat body shown above, and field is best-effort — see HTTP API for the degraded shape.

How to fix:

  • Check for existing resource before creating
  • Update existing resource instead of creating new one
  • Use different value for unique field

DELETE_RESTRICTED

HTTP Status: 409
Meaning: The record is still referenced by dependent records that cannot be cleared
Envelope: flat on the @objectstack/rest /data door, built by its DELETE_RESTRICTED arm. On a Hono door it is nested: developerMessage, dependentObject and dependentCount ride in error.details, and there is no object

Example — DELETE /api/v1/data/account/acc_123:

{
  "error": "This Account is still referenced by 5 Opportunity record(s) through “Account”. Delete or reassign them first.",
  "code": "DELETE_RESTRICTED",
  "developerMessage": "Cannot delete account (acc_123): 5 dependent opportunity record(s) reference it via account_id. Delete or reassign them first, or set deleteBehavior:'cascade' on opportunity.account_id.",
  "dependentObject": "opportunity",
  "dependentCount": 5,
  "object": "account"
}

Two sentences, deliberately. error is the end-user half — localized, display labels only, rendered verbatim in a toast. developerMessage is the builder half — API names and the deleteBehavior:'cascade' remedy — in a field no user-facing surface reads.

⚠️ dependentCount is absent, not zero, when the caller's own permissions would not have let them count the dependents; dependentObject is named either way. Treat a missing dependentCount as "withheld", never as "none".

How to fix:

  • Delete or reassign the related records first
  • Set deleteBehavior: 'cascade' on the referencing field
  • Archive instead of deleting (soft delete)

Rate Limiting

RATE_LIMIT_EXCEEDED

HTTP Status: 429
Meaning: Too many requests, rate limit exceeded
Envelope: nested — built by the inbound rate limiter, which sits ahead of every route, so it is never flat whichever family you were calling

Example:

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
    "httpStatus": 429,
    "details": {
      "retryAfterSeconds": 45,
      "resetAt": "2026-08-03T12:00:45.000Z"
    }
  }
}

This is one of the few refusals that genuinely carries a details bag — the limiter passes one. The code itself is derived from the 429 status.

HTTP Headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 45

How to fix:

  • Implement exponential backoff
  • Batch requests to reduce call count
  • Upgrade to higher tier for increased limits
  • Cache responses to avoid repeated calls

Client-side handling:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const response = await fetch(url, options);
    
    if (response.status !== 429) {
      return response;
    }
    
    const retryAfter = response.headers.get('Retry-After');
    await sleep(retryAfter * 1000);
  }
  
  throw new Error('Max retries exceeded');
}

QUOTA_EXCEEDED

HTTP Status: 429
Meaning: A user's daily AI chat-turn cap is spent
Envelope: nested — emitted by one route only, the ObjectOS agent chat route, and only to a JSON-mode request

One producer. POST /api/v1/ai/agents/:agentName/chat answers QUOTA_EXCEEDED when the deployment has switched on its per-user daily chat-turn cap and the calling user has spent that cap. The route belongs to the in-product chat runtime, which ships in ObjectOS rather than in the open edition (see AI). No other route emits this code.

JSON mode only. The route streams by default. A request whose stream flag is absent or true gets the refusal as an ordinary assistant text message on HTTP 200, and no error code reaches it. Only a request with stream: false gets the 429 body below. client.ai.agents.chat() sends stream: false and throws an error carrying this body's code and details. client.ai.agents.chatStream() streams and receives the text.

Example:

{
  "success": false,
  "error": {
    "code": "QUOTA_EXCEEDED",
    "message": "Daily AI assistant limit reached (50 messages/day). It resets at 2026-09-28T00:00:00.000Z; to continue now, contact your administrator or upgrade your plan.",
    "httpStatus": 429,
    "details": {
      "resetAt": "2026-09-28T00:00:00.000Z"
    },
    "category": "rate_limit"
  }
}
  • error.code is the field to branch on.
  • error.details.resetAt is an ISO-8601 timestamp: the moment the cap lifts. It is the only recovery time the response carries. The route sets no Retry-After header and sends no retryAfterSeconds.
  • error.category is rate_limit.
  • error.message is the refusal copy written for the end user. Show it to the user; don't parse it.

Don't feed this 429 to a retry loop that only checks the status, such as the RATE_LIMIT_EXCEEDED example above. It has no Retry-After to wait on, and every retry before resetAt is refused again.

Other quota enforcement answers with its own code rather than this one (the SMS daily quota answers TOO_MANY_REQUESTS). For request pacing, the code on the wire is RATE_LIMIT_EXCEEDED above, whose Retry-After header and retryAfterSeconds / resetAt details are what a client acts on.

Server Errors

INTERNAL_ERROR

HTTP Status: 500
Meaning: Internal server error
Envelope: both — every door has a 500 terminal, and each writes its own envelope

Flat (the /data door's terminal for an error nothing classified):

{
  "error": "Internal server error",
  "code": "INTERNAL_ERROR"
}

Nested (a sendError route module — here the settings routes):

{
  "success": false,
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "Failed to write namespace"
  }
}

Important: the message on a 5xx is withheld by construction, not by a heuristic. The flat door replaces a declared-5xx message with the fixed Internal server error string, so no phrasing a producer picks — deliberate or accidental — can carry driver text or a stack trace to a client.

⛔ No details bag and no requestId in the body. The request id is a response header — see Debugging with Request IDs.

How to fix:

  • Retry the request (it may be transient)
  • Check the server status page
  • Contact support with the X-Request-Id value from the response headers

SERVICE_UNAVAILABLE

HTTP Status: 503
Meaning: A dependency the route needs is not registered, or is unhealthy
Envelope: both — thrown by shared guards and written by sendError route modules

Nested (the datasource admin routes, when the service is not composed):

{
  "success": false,
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "The external-datasource service is not available."
  }
}

Flat (the /data door — here an object whose datasource is refused or unreachable):

{
  "error": "The datasource for this object is not available",
  "code": "ERR_DATASOURCE_UNAVAILABLE",
  "datasource": "warehouse",
  "object": "shipment"
}

⚠️ The flat family's own 503 carries the more specific ERR_DATASOURCE_UNAVAILABLE rather than the generic code, with datasource and reason as top-level siblings — it is built by an arm, so it never degrades to SERVICE_UNAVAILABLE. Handle both.

HTTP Headers:

HTTP/1.1 503 Service Unavailable
Retry-After: 300

Error Response Examples

Each example below is labelled with the envelope it is in, because the envelope is a property of the door — see Error Response Envelopes.

Validation Error (Multiple Fields) — flat, /data door

Request:

POST /api/v1/data/account
Content-Type: application/json

{
  "name": "",
  "email": "not-an-email",
  "revenue": -1000
}

Response:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Name is required; Invalid email format; Revenue must be at least 0",
  "code": "VALIDATION_FAILED",
  "fields": [
    {
      "field": "name",
      "code": "required",
      "message": "Name is required",
      "label": "Name"
    },
    {
      "field": "email",
      "code": "invalid_email",
      "message": "Invalid email format",
      "label": "Email",
      "value": "not-an-email"
    },
    {
      "field": "revenue",
      "code": "min_value",
      "message": "Revenue must be at least 0",
      "label": "Revenue",
      "constraint": { "min": 0 }
    }
  ],
  "object": "account"
}

⚠️ The request id is not in the body. Read it from the X-Request-Id response header.

Write Denied by Record Sharing — flat, /data door

Request:

DELETE /api/v1/data/account/acc_123
Authorization: Bearer <token>

Response:

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "You do not have access to change or delete this record. Contact the person who owns it, or your administrator, if you need to make changes.",
  "code": "FORBIDDEN",
  "object": "account"
}

⚠️ This is the answer for a row the caller can read. A by-id update or delete of a row the caller cannot read answers exactly what an id that names no row answers — 404 RECORD_NOT_FOUND — whichever rule hides the row, so the write door never tells "hidden" apart from "gone". A caller who can read the row but may not write it gets the 403 shown here.

⚠️ FORBIDDEN, not PERMISSION_DENIED. A by-id write the sharing rules refuse carries FORBIDDEN; PERMISSION_DENIED is what the capability and identity guards carry. Both are 403s in the same envelope on this door, so branch on either — ⛔ but do not assume one code covers every 403.

⚠️ The refusal is thrown as FORBIDDEN: <sentence> and the door strips the CODE: prefix before writing the body, because the code already rides the code axis. ⛔ Do not match on a leading code prefix in error.

⛔ The body deliberately names neither the permission you lack nor the permissions you hold — see Security Considerations. The thrown error carries a details bag internally; the flat door does not relay it.

Rate Limit Exceeded — nested, ahead of every route

Request:

GET /api/v1/data/task
Authorization: Bearer <token>

Response:

HTTP/1.1 429 Too Many Requests
Retry-After: 45
Content-Type: application/json

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
    "httpStatus": 429,
    "details": {
      "retryAfterSeconds": 45,
      "resetAt": "2026-08-03T12:00:45.000Z"
    }
  }
}

⚠️ The same route answered the two envelopes above and below: the /data refusals are flat, and the limiter's 429 in front of the very same path is nested. That is the rule at work, and it is why a client must read the body's shape rather than the route's prefix.

Error Handling Best Practices

✅ Normalise the Envelope Once, at the Edge

Write one reader, use it everywhere, and ⛔ never let an envelope check spread across your codebase. Everything below assumes this function has already run.

Good:

/** Normalise either ObjectStack error envelope into one shape. */
function readError(body) {
  const nested = body !== null && typeof body === 'object' && 'success' in body;
  const err = nested ? (body.error ?? {}) : body;
  return {
    // `code` is absent when a producer declared a status but no code — keep it
    // undefined rather than inventing one.
    code: nested ? err.code : body?.code,
    message: nested ? err.message : (typeof body?.error === 'string' ? body.error : undefined),
    // Marked by the producer as addressed to the end user; render verbatim.
    userMessage: nested ? err.userMessage : body?.userMessage,
    // Per-field detail: a top-level sibling on the flat door, `error.details.fields`
    // on a Hono door.
    fields: nested ? err.details?.fields : body?.fields,
    nested,
  };
}

✅ Use Error Codes, Not Messages

Bad:

if (error.message.includes('not found')) {
  // Brittle - breaks if message changes
}

Good:

const { code } = readError(body);
// Both families' "missing" codes — they differ by door, so handle both.
if (code === 'RESOURCE_NOT_FOUND' || code === 'RECORD_NOT_FOUND' || code === 'OBJECT_NOT_FOUND') {
  // Reliable - the code vocabulary is closed and versioned
}

✅ Show User-Friendly Messages

Bad:

alert(body.error.message);  // undefined on the flat envelope, and raw copy on the nested one

Good:

const { code, message, userMessage } = readError(body);

const generic = {
  'VALIDATION_FAILED': 'Please check your input and try again',
  'VALIDATION_ERROR': 'That request was not valid. Please check it and try again.',
  'UNAUTHENTICATED': 'Please log in to continue',
  'RATE_LIMIT_EXCEEDED': 'Too many requests. Please wait a moment.',
};

// `userMessage` is text the producer MARKED as addressed to the end user, so it
// wins over your generic substitution. `message` is the fallback of last resort.
showToast(userMessage ?? generic[code] ?? message ?? 'An error occurred');

✅ Handle Field-Specific Validation Errors

Per-field detail comes as VALIDATION_FAILED: from the flat /data door with fields as a top-level sibling, and from a Hono door as error.details.fields. readError above reads both.

Good:

async function handleSubmit(payload) {
  const response = await fetch('/api/v1/data/account', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  const body = await response.json();
  if (response.ok) return body;

  const { code, message, fields } = readError(body);
  if (code === 'VALIDATION_FAILED') {
    // Annotate the inputs…
    for (const { field, message: fieldMessage } of fields ?? []) {
      setFieldError(field, fieldMessage);
    }
    // …and show the joined sentence, which is what `error` already carries.
    showToast(message, 'error');
  } else {
    showToast(message ?? 'An error occurred', 'error');
  }
  throw new APIError(body);
}

✅ Implement Retry Logic

Good:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      
      if (!response.ok) {
        const { code } = readError(data);
        if (code === 'RATE_LIMIT_EXCEEDED') {
          // Prefer the HEADER: it is present on every 429, whereas the
          // `details` bag is a property of this one writer.
          const retryAfter = Number(response.headers.get('Retry-After'))
            || data?.error?.details?.retryAfterSeconds
            || 1;
          await sleep(retryAfter * 1000);
          continue;
        } else if (code === 'INTERNAL_ERROR' || code === 'SERVICE_UNAVAILABLE'
                   || code === 'ERR_DATASOURCE_UNAVAILABLE') {
          // Exponential backoff
          await sleep(Math.pow(2, attempt) * 1000);
          continue;
        } else {
          // Don't retry validation errors, auth errors, etc.
          throw new APIError(data);
        }
      }
      
      return data;
    } catch (error) {
      lastError = error;
    }
  }
  
  throw lastError;
}

✅ Log Request IDs for Debugging

⛔ The request id is not in the response body, in either envelope. Read it from the X-Request-Id response header, and stamp your own timestamp — no body carries one.

Good:

const response = await fetch('/api/v1/data/account', init);
if (!response.ok) {
  const body = await response.json();
  const { code, message } = readError(body);
  const requestId = response.headers.get('X-Request-Id');

  console.error('Account creation failed', {
    requestId,
    code,
    message,
    at: new Date().toISOString(),   // your clock — the body has no `timestamp`
  });

  // Send to error tracking service
  Sentry.captureException(new APIError(body), { extra: { requestId } });
}

✅ Handle Network Errors

⛔ Do not branch on data.success. It is absent from every flat body, so !data.success is true for a perfectly successful /data response and your client throws on its own happy path. Branch on response.ok — the HTTP status — which is correct for both envelopes.

Good:

try {
  const response = await fetch('/api/v1/data/task');
  const data = await response.json();

  // The STATUS decides success, not a body flag.
  if (!response.ok) throw new APIError(data);

  // Unwrap only what the nested envelope wraps; the flat door returns the
  // payload directly.
  return (data !== null && typeof data === 'object' && 'success' in data) ? data.data : data;
} catch (error) {
  if (error instanceof TypeError && error.message === 'Failed to fetch') {
    // Network error - server unreachable
    showToast('Network error. Please check your connection.', 'error');
  } else if (error instanceof APIError) {
    // API returned error
    handleAPIError(error);
  } else {
    // Unknown error
    console.error('Unexpected error:', error);
    showToast('An unexpected error occurred', 'error');
  }
}

Debugging with Request IDs

The request id travels as a response header, not as a body field. ⛔ Neither envelope carries requestId, and no writer puts one there — a client reading body.requestId or body.error.requestId gets undefined on every route.

Composition: the id is attached by the observability instrumentation wrapper. A deployment that does not compose it emits no id at all; one that does echoes a valid inbound X-Request-Id and otherwise mints req_<hex>. The header name is configurable (observability.requestIdHeader), defaulting to X-Request-Id.

Request:

POST /api/v1/data/account
Content-Type: application/json
X-Request-Id: my-custom-id-123

{ "name": "Acme Corp" }

Response:

HTTP/1.1 201 Created
X-Request-Id: my-custom-id-123
Content-Type: application/json

{ "object": "account", "id": "acc_124", "record": { "...": "..." } }

⚠️ An inbound id is validated before it is echoed: a value that is empty, over-long or outside the accepted character set is discarded and a fresh req_<hex> is minted instead. Do not assume the id you sent is the id that was logged — read it back off the response.

const requestId = response.headers.get('X-Request-Id');

Server logs:

[2024-01-16T14:30:00Z] INFO [my-custom-id-123] POST /api/v1/data/account
[2024-01-16T14:30:00Z] DEBUG [my-custom-id-123] Validating input
[2024-01-16T14:30:00Z] DEBUG [my-custom-id-123] Executing query: INSERT INTO accounts...
[2024-01-16T14:30:00Z] INFO [my-custom-id-123] Request completed in 45ms

Use request IDs to:

  • Trace request through logs
  • Debug distributed systems
  • Report issues to support
  • Correlate frontend errors with backend logs

Monitoring & Alerting

Error Rate Monitoring

Track error rates by code:

// Metrics dashboard
{
  "error_rates": {
    "VALIDATION_FAILED": 0.05,  // 5% of requests
    "UNAUTHENTICATED": 0.02,      // 2% of requests
    "RATE_LIMIT_EXCEEDED": 0.01,      // 1% of requests
    "INTERNAL_ERROR": 0.0001     // 0.01% of requests (🚨 alert if > 0.01%)
  }
}

Alert Conditions

Critical alerts:

  • INTERNAL_ERROR rate > 0.1%
  • SERVICE_UNAVAILABLE > 0
  • Database connection failures

Warning alerts:

  • RATE_LIMIT_EXCEEDED spike (may indicate DDoS or integration bug)
  • UNAUTHENTICATED spike (credential leakage?)
  • VALIDATION_FAILED spike on a new form (bad client-side validation)

Error Budgets

Set error budgets per service:

error_budget:
  target_success_rate: 99.9%
  measurement_window: 30d
  budget_remaining: 97.2%  # Still have 97.2% of error budget left

When budget exhausted:

  • Freeze feature releases
  • Focus on reliability improvements
  • Investigate root causes

Security Considerations

These are rules for writing a producer. The examples are in the nested envelope; the same rules hold verbatim on the flat one, where the structured context would ride as top-level siblings instead.

❌ Never Leak Sensitive Info

Bad:

{
  "success": false,
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Password incorrect for user john@acme.com",
    "details": {
      "attempted_password": "Password123!",
      "actual_password_hash": "bcrypt$..."
    }
  }
}

Good:

{
  "success": false,
  "error": {
    "code": "UNAUTHENTICATED",
    "message": "Invalid credentials"
  }
}

⚠️ details is omitted, not set to null. Both writers spread an absent optional away, so a key with nothing to say does not reach the wire at all.

5xx prose is withheld structurally, not by a keyword filter. On the flat door a producer-declared 500 has its message replaced with a fixed Internal server error sentence before the body is written, so there is no phrasing — deliberate or accidental — that carries driver text, SQL or a stack trace to a client. A 4xx message is addressed to the caller and is kept, truncated rather than replaced.

❌ Don't Confirm Resource Existence

Bad:

// Attacker probes: DELETE /api/v1/data/account/acc_123
{
  "error": "You don't have permission to delete this account",
  "code": "FORBIDDEN"
}
// Attacker learns: Account acc_123 exists! 🚨

Good:

// Answer the same "not found" for "doesn't exist" and "exists but not visible"
{
  "error": "Record acc_123 not found in account",
  "code": "RECORD_NOT_FOUND",
  "object": "account"
}

This is the same reasoning behind the platform's own withholding: DELETE_RESTRICTED names the blocking object unconditionally but omits dependentCount when the caller's own permissions would not have let them count those rows, and PERMISSION_DENIED never enumerates the grants you lack.

✅ Rate Limit Error Responses

Even error responses can be abused:

// Attacker tries to enumerate user emails
for (let i = 0; i < 1000000; i++) {
  await register({ email: `user${i}@example.com` });
  // Response: "UNIQUE_VIOLATION" or "VALIDATION_FAILED"
}

Solution: rate limit failed registration attempts. The inbound limiter answers ahead of every route, in the nested envelope:

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
    "httpStatus": 429,
    "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" }
  }
}

Next Steps

On this page