| title | Error handling — the response envelope |
|---|---|
| navTitle | Error Handling |
| description | Global error codes, response formats, and debugging strategies for ObjectStack APIs |
import { AlertCircle, Bug, Shield, Info, AlertTriangle, XCircle, Radio, Zap } from 'lucide-react';
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).
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).
ObjectStack has two live error envelopes. There is no third, and there is no universal one.
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.
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 areUNIQUE_VIOLATION,DELETE_RESTRICTED,INVALID_FIELDandOBJECT_NOT_FOUND, which the engine and the protocol throw (UNIQUE_VIOLATIONis the wire spelling of the engine'sDuplicateRecordError, translated by both doors): 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;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.
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.
| 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 |
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 |
|---|---|
UNIQUE_VIOLATION |
developerMessage, object, and field when the driver named the column |
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 |
UNIQUE_VIOLATION is not the code the engine throws: its DuplicateRecordError, whose
in-process code is DUPLICATE_RECORD, is answered as UNIQUE_VIOLATION here exactly
as on the flat door. A refusal that only speaks
DUPLICATE_RECORD — a hook throwing the registered code without being the engine's
class — keeps its own code.
⛔ 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, and a repeated
unique value reads the engine's Duplicate record refused on 'Account': … (the object's label) here, the same
sentence details.developerMessage carries, where the flat door's error reads
A record with this email already exists.
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.
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.
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
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
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.
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"
}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
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);
}
}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."
}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)
/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.
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, search, searchFields, groupBy, aggregations[].field) and the
off-request backfillSummaryNulls case.
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.
{
"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
HTTP Status: 409
Meaning: The write collides with a unique constraint — a record already holds that value
Envelope: flat on the @objectstack/rest /data door, built by its duplicate-record
arm. On a Hono door it is nested: developerMessage, object and field
ride in error.details
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 and the Hono doors translate that
envelope at the boundary, so every route answers the wire code UNIQUE_VIOLATION and
the in-process spelling never crosses HTTP. The REST door emits the flat body shown
above, and field is best-effort on either door — 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
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)
HTTP Status: 429
Meaning: Too many requests, rate limit exceeded
Envelope: nested from the inbound rate limiter, which sits ahead of every
route, so its refusal is never flat whichever family you were calling. The phone-OTP
send walls answer this code too, flat (see the end of QUOTA_EXCEEDED below)
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');
}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 a code other than this one. The phone-OTP send
walls (/auth/phone-number/send-otp and /auth/phone-number/request-password-reset), a
phone number's own limit and the deployment's daily SMS quota, both answer 429
RATE_LIMIT_EXCEEDED: one code for both, so a caller cannot tell which budget it hit.
That body is better-auth's flat code and message, with no Retry-After header and no
details. At the inbound rate limiter, the code on the wire is RATE_LIMIT_EXCEEDED
above, whose Retry-After header and retryAfterSeconds / resetAt details are what a
client acts on.
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
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"
}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: 300Each example below is labelled with the envelope it is in, because the envelope is a property of the door — see Error Response Envelopes.
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"
}X-Request-Id response header.
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"
}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.
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.
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"
}
}
}/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.
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,
};
}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
}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');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);
}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;
}⛔ 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 } });
}⛔ 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');
}
}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": { "...": "..." } }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
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%)
}
}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)
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
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.
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.
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.
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" }
}
}