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_VIOLATIONis built by the/datadoor's classification arms, so it is always flat.RATE_LIMIT_EXCEEDEDis 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_UNAVAILABLEandINTERNAL_ERRORare all thrown by shared guards and reach clients in either envelope depending on the route. So areDELETE_RESTRICTED,INVALID_FIELDandOBJECT_NOT_FOUND, which the engine and the protocol throw: the@objectstack/rest/datadoor answers them flat, and a Hono door answers them nested, with their structured fields undererror.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/sendErrorinpackages/types/src/response-envelope.ts— the shared pair every converted route module writes through.buildApiError/apiErrorResponseinpackages/runtime/src/error-envelope.ts— the runtime HTTP dispatcher's door, and the inbound rate limiter.declaredEnvelopeForThrowin@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— alwaysfalsefor errors.error.code— the machine-readable code. Closed vocabulary: the parameter is typed toErrorCode, 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; nosendErrorcall 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 owncodewhen it is not a member of the closed vocabulary, demoted to this sibling rather than overwritingcode.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 witherroralone. Branch oncodeonly 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 family | Envelope | Writer |
|---|---|---|
{base}/data/* on the @objectstack/rest door — CRUD, query, batch, createMany / updateMany / deleteMany, clone, import / export, reports, approvals, sharing rules | flat | packages/rest/src/error-response.ts |
{base}/meta/*, {base}/ui/*, discovery and search on the @objectstack/rest door, for anything thrown out of a handler | flat | the same door, via handleRouteError |
/api/v1/storage/* | nested | storage-routes.ts → sendError |
/api/settings/* | nested | settings-routes.ts → sendError |
/api/v1/share-links/* | nested | share-link-routes.ts → sendError |
/api/v1/packages/* | nested | package-routes.ts → sendError |
/api/v1/datasources/* | nested | admin-routes.ts, external-datasource-routes.ts → sendError |
/api/v1/i18n/* | nested | i18n-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) | nested | buildApiError / apiErrorResponse |
| the inbound rate limiter, ahead of every route | nested | inbound-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 HonoHttpServer | nested | declaredEnvelopeForThrow — 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
}
}
}| Code | In error.details on a Hono door |
|---|---|
DELETE_RESTRICTED | developerMessage, dependentObject, and dependentCount when the flat door would send it |
CONCURRENT_UPDATE | currentVersion, currentRecord (the row as the caller reads it) |
INVALID_FIELD | field, object |
OBJECT_NOT_FOUND | object |
VALIDATION_FAILED | fields |
FEEDS_DISABLED, FILES_DISABLED, ATTACHMENT_PARENT_ACCESS, ATTACHMENT_DELETE_DENIED, RECORD_NOT_ACCESSIBLE | object |
⛔ 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:
| Status | Meaning | When Used |
|---|---|---|
| 400 | Bad Request | Invalid input, validation failure |
| 401 | Unauthorized | Authentication required or failed |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists or version mismatch |
| 413 | Content Too Large | An upload over the storage settings' max_upload_mb, or an import over its row ceiling — answered as PAYLOAD_TOO_LARGE |
| 422 | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation); also an absent controlled_by_parent master reference — see MISSING_REQUIRED_FIELD |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
| 503 | Service Unavailable | Server 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
Authorizationheader - 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
audclaim) - 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_ERRORis whatHttpStatusErrorCodeMap(packages/spec/src/api/errors.zod.ts) maps HTTP 400 to. A door handed a refusal that names a 400 and no code labels itVALIDATION_ERRORfrom 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.
refuseUnknownQueryParamsandrefuseRepeatedQueryParamsanswer 400VALIDATION_ERRORfor 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 as | Refused by | Status | code | fields |
|---|---|---|---|---|
master_detail + required, not readonly/system | required-field validation | 400 | VALIDATION_FAILED | yes |
master_detail + required + readonly | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
master_detail + required + system | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
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: 45How 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.codeis the field to branch on.error.details.resetAtis an ISO-8601 timestamp: the moment the cap lifts. It is the only recovery time the response carries. The route sets noRetry-Afterheader and sends noretryAfterSeconds.error.categoryisrate_limit.error.messageis 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-Idvalue 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: 300Error 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 oneGood:
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 45msUse 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_ERRORrate > 0.1%SERVICE_UNAVAILABLE> 0- Database connection failures
Warning alerts:
RATE_LIMIT_EXCEEDEDspike (may indicate DDoS or integration bug)UNAUTHENTICATEDspike (credential leakage?)VALIDATION_FAILEDspike 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 leftWhen 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" }
}
}