Error Code Catalog
Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
ObjectStack uses a structured error system with 9 error categories and 50 standardized error codes. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
Source: packages/spec/src/api/errors.zod.ts
Import: import { StandardErrorCode, ErrorCategory, ErrorResponseSchema } from '@objectstack/spec/api'
One vocabulary, two tiers (ADR-0112, settles #3841): every top-level error.code is SCREAMING_SNAKE and comes from either
- the standard catalog below (
StandardErrorCode— generic conditions with platform-wide HTTP semantics), or - the error-code ledger (
ERROR_CODE_LEDGERin@objectstack/spec/api) — service-specific codes such asVALIDATION_FAILED,AUTH_REQUIRED,ATTACHMENT_DOWNLOAD_DENIED, registered under their owning package.
ApiErrorSchema.code validates against the union, so an unregistered code fails schema parse and CI. Adding a code: if a standard code fits, throw with it; otherwise register yours in ERROR_CODE_LEDGER (see the registration notes in packages/spec/src/api/error-code-ledger.ts). A producer that has no code of its own gets one derived from the HTTP status via standardErrorCodeForHttpStatus — always a member of this catalog.
Error Categories
| Category | HTTP Status | Description |
|---|---|---|
validation | 400 | Request data failed validation |
authentication | 401 | Identity not verified |
authorization | 403 | Insufficient permissions |
not_found | 404 | Resource does not exist |
conflict | 409 | State conflict or duplicate |
rate_limit | 429 | Too many requests |
server | 500 | Internal server error |
external | 502 | External service failure |
maintenance | 503 | System under maintenance |
Retry Strategies
| Strategy | Description | When to Use |
|---|---|---|
no_retry | Do not retry the request | Validation errors, permission denied |
retry_immediate | Retry immediately | Transient network errors |
retry_backoff | Retry with exponential backoff | Rate limits, server errors |
retry_after | Wait the specified retryAfter seconds | Rate limit with explicit cooldown |
Validation Errors (400)
VALIDATION_ERROR
Cause: Generic validation failure — request body does not match expected schema.
Fix: Check the fields array for specific field-level issues.
Retry: no_retry
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"category": "validation",
"httpStatus": 400,
"retryable": false,
"fields": [
{ "field": "email", "message": "Invalid email format", "code": "invalid_format" }
]
}INVALID_FIELD
Cause: A field name in the request does not exist on the target object. On a
list read this also covers an unreserved query parameter — GET /data/:object
reads those as field filters, so one naming no field could only match zero
records and is rejected rather than answered with an empty page — plus every
other read axis that names a field: select, expand (a real field that holds
no reference gets its own message), searchFields (a real field outside the
searchable set gets its own message), groupBy, and aggregations[].field.
Fix: Check the object schema for valid field names. Use os meta get object <name> to inspect the object's fields. If the name was meant as a
parameter rather than a field, use the real one — page size is top / $top
/ limit, not pageSize / perPage; the response's error names the
substitute. See the Data API.
Retry: no_retry
MISSING_REQUIRED_FIELD
Cause: A required field was not provided in the request body.
Fix: Include the missing field. Check fields for the field name — except on the
controlled_by_parent master-reference paths below, where the status is 422 and no
fields array is sent; read the field name out of the message there.
Retry: no_retry
MISSING_REQUIRED_FIELD is 400 with one exception: an absent controlled_by_parent
master reference answers 422, without fields.
An object whose sharingModel is controlled_by_parent derives its access from a master
record, so the gate authorizing writes to it resolves that master before the executor —
and the executor is where required-field validation runs. Which of the two refuses first
depends on how the master reference is declared. A master_detail that is required and
neither readonly nor system reaches validation and answers the documented
400 VALIDATION_FAILED with fields. The other four declarable shapes never reach it and
answer 422 MISSING_REQUIRED_FIELD with no fields:
- a
master_detailwith norequired - a
master_detailthat isrequired+readonly - a
master_detailthat isrequired+system - a
requiredlookup, when the object declares nomaster_detail
The full matrix is on the protocol page:
MISSING_REQUIRED_FIELD.
An update or delete by id whose stored master reference is null answers the same
422, whatever the declaration — the caller sent no such field, so nothing could be named
in fields and no payload would fix it.
The refusal itself is correct and is not going to be relaxed: on those four shapes
required-field validation does not fire (it skips system and readonly fields before its
required check, and never fires on a field that is not required), so the gate is the only
thing standing between the request and a detail record with a null master reference — which
the controlled_by_parent read filter (fk IN (readable masters)) can never match, leaving
a record readable by nobody.
These shapes are authorable today: lint reports a master_detail without required as
a warning only, and does not report the readonly, system, or fallback-lookup shapes at
all. Branch on code, treat fields as optional, and read the status off the response
rather than deriving it from the table at the end of this page.
INVALID_FORMAT
Cause: Field value does not match the expected format (e.g., invalid email, wrong date format).
Fix: Ensure the value matches the field's format constraint or built-in type validation.
Retry: no_retry
VALUE_TOO_LONG
Cause: String value exceeds the field's maxLength constraint.
Fix: Truncate the value to the maximum allowed length.
Retry: no_retry
VALUE_TOO_SHORT
Cause: String value is below the field's minLength constraint.
Fix: Provide a longer value that meets the minimum length requirement.
Retry: no_retry
VALUE_OUT_OF_RANGE
Cause: Numeric value is outside the field's min/max range.
Fix: Ensure the value is within the allowed range.
Retry: no_retry
INVALID_REFERENCE
Cause: Reserved for an invalid foreign-key reference. No route emits it
today. A lookup / master_detail pointing at a record that does not exist
is refused as a field-level failure instead — see below.
Fix: Do not branch on this code; branch on VALIDATION_FAILED +
fields[].code === 'reference_not_found'.
Retry: no_retry
A dangling reference answers VALIDATION_FAILED, not INVALID_REFERENCE (#4441).
Writing a lookup / master_detail value with no matching row in the target
object is rejected with 400 VALIDATION_FAILED, and the specifics ride in
fields[] — which names the field, the target object and the unresolvable id:
{
"error": "Permission Set: no sys_permission_set record has id \"ps_missing\"",
"code": "VALIDATION_FAILED",
"fields": [{
"field": "permission_set_id",
"code": "reference_not_found",
"label": "Permission Set",
"constraint": { "target": "sys_permission_set" },
"value": "ps_missing"
}]
}The check covers create, update and bulk update. Three cases are deliberately
not rejections: an empty value (null / "" / []) means "no link", a
isSystem write is exempt (seed replay and package install legitimately write
in an order that only resolves once the batch completes), and a target that
cannot be checked at all — an unregistered object, an unreachable datasource —
fails open rather than inventing a rejection.
DUPLICATE_VALUE
Cause: A field with unique: true already has a record with the same value.
Fix: Use a different value or update the existing record.
Retry: no_retry
INVALID_QUERY
Cause: A groupBy / aggregations value the spec cannot read: a bare
string where an array belongs, an entry that names no field, an aggregation
function or dateGranularity outside the spec's enums, or a missing alias.
(Field names that simply don't exist on the object are INVALID_FIELD
instead.)
Fix: Check the aggregation shapes against the
Query Cheat Sheet — e.g.
{ "groupBy": ["status"], "aggregations": [{ "function": "sum", "field": "amount", "alias": "total" }] }. count is the only function that may omit
field.
Why it is an error and not an empty result: every one of these shapes used
to be silently ignored or mis-read — rows came back ungrouped, or an
unknown function computed null — in a response indistinguishable from a
served aggregation.
Retry: no_retry
INVALID_FILTER
Cause: The server could not turn the request's filter into something it can
run. Covers an invalid operator or field in the where clause, a $filter
array that is not a filter AST (a bad operator, a lone ["and"]), a filter
query parameter that is not valid JSON, and JSON that parses to something that
is not a filter (5, "done", null).
Fix: Use valid filter operators ($eq, $ne, $gt, $lt, $in, $contains, etc.) and a filter shape the Data API accepts — an object, or an array of [field, operator, value] conditions. The error message names the offending element.
Why it is an error and not an empty result: a filter the server cannot run
is never silently skipped, because skipping it would return the unfiltered
result set — a response indistinguishable from a successful query.
Retry: no_retry
INVALID_SORT
Cause: The orderBy clause references a non-existent or non-sortable field.
Fix: Ensure the field exists and has sortable: true.
Retry: no_retry
MAX_RECORDS_EXCEEDED
Cause: The request attempts to create or return more records than the system limit.
Fix: Use pagination (limit/offset or cursor-based) for large datasets. Batch operations for bulk creates.
Retry: no_retry
Authentication Errors (401)
UNAUTHENTICATED
Cause: No authentication credentials were provided.
Fix: Include a valid Authorization header (Bearer token, API key, or session cookie).
Retry: no_retry
INVALID_CREDENTIALS
Cause: Username/password combination is incorrect.
Fix: Verify credentials and try again. After multiple failures, account may be locked.
Retry: no_retry
EXPIRED_TOKEN
Cause: The authentication token (JWT, session token) has expired.
Fix: Obtain a new token using the refresh token endpoint or re-authenticate.
Retry: retry_immediate (after refreshing token)
INVALID_TOKEN
Cause: The authentication token is malformed or has been tampered with.
Fix: Obtain a fresh token from the authentication endpoint.
Retry: no_retry
SESSION_EXPIRED
Cause: The user session has timed out due to inactivity.
Fix: Re-authenticate to start a new session.
Retry: no_retry
MFA_REQUIRED
Cause: Multi-factor authentication is required but the MFA challenge has not been completed.
Fix: Complete the MFA verification step before proceeding.
Retry: no_retry
EMAIL_NOT_VERIFIED
Cause: The user's email address has not been verified.
Fix: Complete email verification through the link sent to the user's email.
Retry: no_retry
Authorization Errors (403)
PERMISSION_DENIED
Cause: The authenticated user does not have permission for this operation.
Fix: Request the necessary permissions from an administrator, or use an account with the required role.
Retry: no_retry
INSUFFICIENT_PRIVILEGES
Cause: The user has some access but lacks the specific privilege required for this action.
Fix: Contact an administrator to grant the necessary privilege.
Retry: no_retry
FIELD_NOT_ACCESSIBLE
Cause: The user does not have access to a specific field on the object.
Fix: Remove the inaccessible field from the request, or request field-level access.
Retry: no_retry
RECORD_NOT_ACCESSIBLE
Cause: The user cannot access this specific record due to record-level sharing rules.
Fix: Request access to the record from its owner or an administrator.
Retry: no_retry
LICENSE_REQUIRED
Cause: The feature requires a specific license that is not active.
Fix: Upgrade the license or contact your administrator.
Retry: no_retry
IP_RESTRICTED
Cause: The request originates from an IP address that is not in the allowlist.
Fix: Ensure the request comes from an approved IP range, or update the IP restriction settings.
Retry: no_retry
TIME_RESTRICTED
Cause: Access is limited to specific time windows and the current time is outside the allowed range.
Fix: Retry during the allowed time window.
Retry: retry_after
Not Found Errors (404)
RESOURCE_NOT_FOUND
Cause: The requested resource does not exist (generic).
Fix: Verify the resource identifier and endpoint path.
Retry: no_retry
OBJECT_NOT_FOUND
Cause: The specified object (table/entity) does not exist in the schema.
Fix: Check the object name. Use os meta list object to list available objects.
Retry: no_retry
RECORD_NOT_FOUND
Cause: No record exists with the given ID in the specified object.
Fix: Verify the record ID. The record may have been deleted.
Retry: no_retry
FIELD_NOT_FOUND
Cause: The specified field does not exist on the object.
Fix: Check the field name against the object schema. Field names are snake_case.
Retry: no_retry
ENDPOINT_NOT_FOUND
Cause: The API endpoint path does not match any registered route.
Fix: Verify the URL path and HTTP method. Check the API discovery endpoint for available routes.
Retry: no_retry
Conflict Errors (409)
RESOURCE_CONFLICT
Cause: The operation conflicts with the current state of the resource.
Fix: Fetch the latest resource state and retry the operation.
Retry: retry_immediate
CONCURRENT_MODIFICATION
Cause: The record was modified by another user/process since you last read it (optimistic locking failure).
Fix: Re-fetch the record, merge changes, and retry.
Retry: retry_immediate
DELETE_RESTRICTED
Cause: The record cannot be deleted because other records depend on it (referential integrity).
Fix: Delete or reassign dependent records first, then retry the delete.
Retry: no_retry
DUPLICATE_RECORD
Cause: A record with the same unique key already exists.
Fix: Update the existing record instead, or use a different unique key value.
Retry: no_retry
LOCK_CONFLICT
Cause: The record is locked by another process or user.
Fix: Wait for the lock to be released, or contact the lock holder.
Retry: retry_backoff
Request Errors (405/428)
Added in #3842 so standardErrorCodeForHttpStatus can name every status the
runtime actually returns. Without them a 405 would fall into the generic 4xx
bucket and be reported as a validation failure.
METHOD_NOT_ALLOWED
Cause: The route exists but does not serve this HTTP method.
Fix: Use the method named in the response's Allow header.
Retry: no_retry
PRECONDITION_REQUIRED
Cause: The request is missing a precondition the route requires — most often
an environment scope (no X-Environment-Id header and no hostname mapping).
Fix: Send the missing header, or address the environment-scoped URL form.
Retry: no_retry
Rate Limit Errors (429)
RATE_LIMIT_EXCEEDED
Cause: Too many requests in the current time window.
Fix: Reduce request frequency. Check the retryAfter field for the wait time.
Retry: retry_after
QUOTA_EXCEEDED
Cause: The API usage quota for the current period has been exhausted.
Fix: Wait for the quota to reset (check retryAfter), or upgrade the plan.
Retry: retry_after
CONCURRENT_LIMIT_EXCEEDED
Cause: Too many concurrent requests from the same client.
Fix: Reduce parallel request count. Implement request queuing.
Retry: retry_backoff
Server Errors (5xx)
The heading is 5xx, not 500, because these five codes are not all served with
the same status: TIMEOUT is the code a 504 carries and NOT_IMPLEMENTED the
code a 501 carries (HttpStatusErrorCodeMap, packages/spec/src/api/errors.zod.ts),
and SERVICE_UNAVAILABLE is a 503. The per-code status is in the
quick reference at the end of this page, and
pnpm check:error-status-conformance reconciles every row there against what the
runtime can actually emit.
INTERNAL_ERROR
Cause: An unexpected server-side error occurred.
Fix: Report the issue with the requestId and traceId. Retry may succeed.
Retry: retry_backoff
DATABASE_ERROR
Cause: A database operation failed (connection, query, constraint).
Fix: Report the issue. May be transient — retry with backoff.
Retry: retry_backoff
TIMEOUT
Cause: The operation exceeded the maximum allowed time.
Fix: Simplify the query/operation. For large datasets, use pagination or async processing.
Retry: retry_backoff
SERVICE_UNAVAILABLE
Cause: A required internal service is temporarily unavailable.
Fix: Retry after a brief delay. Check system status.
Retry: retry_backoff
NOT_IMPLEMENTED
Cause: The requested feature or endpoint is not yet implemented.
Fix: Check the documentation for alternative approaches, or wait for the feature release.
Retry: no_retry
External Service Errors (502)
EXTERNAL_SERVICE_ERROR
Cause: An external API or service returned an error.
Fix: Check the external service status. The details field may contain the upstream error.
Retry: retry_backoff
INTEGRATION_ERROR
Cause: An integration connector encountered an error.
Fix: Verify the integration configuration and credentials.
Retry: retry_backoff
WEBHOOK_DELIVERY_FAILED
Cause: A webhook delivery attempt failed (target unreachable or returned error).
Fix: Verify the webhook URL is accessible. The system will auto-retry per delivery policy.
Retry: retry_backoff
Action Errors (/api/v1/actions)
Since #3962 /actions failures speak HTTP like every other route — the status
code is the failure signal, and on success data is the handler's return
value directly (single wrap).
| What happened | HTTP | Body |
|---|---|---|
| Action ran, returned | 200 | { success: true, data: <handler return value> } |
| Action ran, rejected (business rule, validation) | 400 | { success: false, error: { message, code, details: { code?, fields? } } } |
| No such action registered | 404 | { success: false, error: { message, code } } |
Caller lacks requiredPermissions | 403 | { success: false, error: { message, code } } |
Type has no server dispatch (url/modal/form/api), or a param-contract violation | 400 | { success: false, error: { message, code } } |
| Data engine or automation service unavailable | 503 | { success: false, error: { message, code } } |
Handler crashed (TypeError, driver error, sandbox timeout) | 500 | { success: false, error: { message, code } } |
A rejection and a crash are told apart by the thrown error: a plain
throw new Error(msg), a sandboxed body's deliberate throw, or a
ValidationError is a rejection (400); a TypeError / ReferenceError / a
driver's own error class is a crash (500). An error carrying its own status
is served with it. A validation rejection carries error.code: 'VALIDATION_FAILED' with the per-field list in error.details.fields[] — the
same code and field entries /data reports (the envelope assembly promotes a
thrown error's code into error.code; details is context only).
const res = await fetch(`/api/v1/actions/${object}/${action}`, { /* … */ });
const json = await res.json();
if (!res.ok) showToast(json.error?.message); // the status IS the failure signal
else use(json.data); // the handler's return valueclient.actions.invoke() folds this (and the pre-#3962 legacy 200 envelope)
into one { success, data?, error? } result and never throws.
const res = await client.actions.invoke(object, action, { recordId, params });
if (!res.success) showToast(res.error);Metadata API Errors (/meta)
/meta refusals carry codes registered to @objectstack/metadata-protocol in the
error-code ledger, not the standard catalog
above — so their status is published here rather than inherited from a category. This
section documents the two type-boundary refusals the /meta request boundary raises:
the type-spelling refusal, and the unmintable-type refusal that guards writes. Both answer
INVALID_REQUEST with 400, so the code and the status cannot tell them apart — only the
message can. It is not a complete inventory of /meta errors.
INVALID_REQUEST — unrecognised type spelling
HTTP Status: 400
Cause: The type segment of a /meta path is not a spelling ObjectStack recognises,
and it evidently reaches for a metadata type the platform itself declares — a
misspelling of a real type. GET /meta/viewes is refused because viewes is not a
recognised spelling of the declared type view.
Fix: Address the type by its canonical singular name, or by its canonical REST
plural. The refusal names both, so the correction never has to be guessed — for
viewes it names view and views. See the Metadata API.
Retry: no_retry — the spelling is refused deterministically; retrying it
unchanged returns the same 400.
GET /api/v1/meta/viewes answers 400 with:
{
"success": false,
"error": {
"code": "INVALID_REQUEST",
"message": "[invalid_request] 'viewes' is not a recognised spelling of metadata type 'view'. Address it as 'view' or 'views'. Refused rather than treated as a plugin-registered type, because forwarding an unrecognised spelling of a declared type would create a second namespace under type='viewes'.",
"httpStatus": 400
}
}This is not a refusal of plural type names. Recognised plurals are not refused:
the boundary folds them to the canonical singular and serves the request — views
addresses view, objects addresses object. What this error refuses is a spelling
that resolves to no type at all while reaching for one the platform declares.
Why it is refused rather than passed through. A type segment the platform does not
recognise would otherwise be treated as a plugin-registered kind, and a write under it
would mint a second metadata namespace keyed by the misspelling — type='viewes'
alongside type='view' — which nothing reads and nothing serves. Refusing at the
boundary is what keeps one type to one key.
⛔ Not this error: a segment that reaches for no declared type — /meta/fieldz, or
a plugin-registered kind such as theme — is not a misspelling of anything the platform
declares, so this rule stays silent. On a read the request continues down the plugin
path; on a write it meets the separate unmintable-type refusal documented immediately
below, which carries the same INVALID_REQUEST code and 400 status and differs only in
its message. Tell the two apart by the message, not the code.
INVALID_REQUEST — unmintable metadata type
HTTP Status: 400
Cause: A write addresses a :type segment that is not a metadata type at all —
not a misspelling of a declared type, but a name the platform has no type for.
PUT /meta/fieldz/showcase_task.title is refused because nothing declares fieldz, and
since additionalTypes was retired a plugin cannot declare one either — so the write
would mint a sys_metadata namespace under type='fieldz' that nothing reads and nothing
serves.
Fix: Address a real metadata type; GET /api/v1/meta/types lists the ones the
deployment carries. Unlike the spelling refusal above, this message names no replacement —
the segment reaches for no declared type, so there is nothing to suggest.
See the Metadata API.
Retry: no_retry — replaying the same request against the same deployment returns the
same 400. ⚠️ That is stability, not determinism: the verdict is not a pure function of
the type segment, and the exemption below decides whether it fires at all.
PUT /api/v1/meta/fieldz/showcase_task.title answers 400 with:
{
"success": false,
"error": {
"code": "INVALID_REQUEST",
"message": "[invalid_request] 'fieldz' is not a metadata type. The platform declares no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one either — so this write would mint a sys_metadata namespace under type='fieldz' that nothing reads and nothing serves. Address a real metadata type; GET /api/v1/meta/types lists the ones this deployment carries.",
"httpStatus": 400
}
}One shape reaches this door and is served, not refused — it is not visible in the message, so a caller who does not read this entry finds it by collision:
- Pre-existing namespace. If
sys_metadataalready holds rows under that type key, the write proceeds. The store decides this, never the caller — the probe runs only after the static verdict has already fired, and it asks whether the namespace exists, not whether your item does. This is what keeps rows minted before this refusal existed editable by the tooling that copies and re-saves them.
A second exemption used to sit beside it and is retired: a name containing / used
to skip this verdict entirely, because at the compound /meta/:type/:section/:name arity
the :type segment carried an object name rather than a type claim. Item names may no
longer contain / — they are lowercase snake_case segments, optionally dot-qualified
(crm_lead, crm_lead.pipeline), refused at the publish door otherwise — and the compound
arities are un-mounted. The residue that exemption used to document is closed with it:
PUT /meta/fieldz/a/b is no longer accepted, and no longer reaches this verdict at all.
⇒ The same segment can be refused on one deployment and served on another, depending on
stored state. A 400 here is a statement about this deployment, not a portable
verdict about the type name — so do not cache it as one, and do not treat a colleague's
successful PUT as evidence that yours will be served.
It is write-scoped. The verdict runs on the one entry point that mints a namespace
and nowhere else. Reads of an unrecognised type still answer — the live type set
legitimately holds keys the static contract does not, and refusing reads would answer
400 for types this same service advertises through GET /meta/types. Rows already
stored under an unrecognised type stay deletable for the opposite reason: refusing to
delete them would strand the accumulation permanently instead of letting an operator clear
it.
Why it is refused rather than passed through. A namespace nothing reads and nothing serves is not a harmless extra key — it accumulates silently, and every row in it is invisible to the tooling that lists, validates and ships metadata. Refusing at the mint door is what stops the first row from being written; the exemption above is what keeps that refusal from stranding the rows written before it existed.
⛔ Not this error: a segment that misspells a type the platform declares —
/meta/viewes for view — is the spelling refusal documented above, which can name the
replacement. And if the namespace probe fails for any reason other than sys_metadata
not being provisioned yet, the request answers 503 rather than this 400: a deployment
whose metadata store is unreachable is not told its type does not exist. An
unprovisioned store is the one read failure that does not escalate — it counts as "no
rows", and this 400 stands.
Error Response Structure
Every error response follows the EnhancedApiError schema:
interface EnhancedApiError {
code: StandardErrorCode; // Machine-readable error code
message: string; // Human-readable description
category?: ErrorCategory; // Error category
httpStatus?: number; // HTTP status code
retryable: boolean; // Whether retry may succeed
retryStrategy?: RetryStrategy; // Recommended retry approach
retryAfter?: number; // Seconds to wait (for rate limits)
details?: unknown; // Additional error context
fields?: FieldError[]; // One entry per offending value
timestamp?: string; // ISO 8601 timestamp
requestId?: string; // Request tracking ID
traceId?: string; // Distributed trace ID
documentation?: string; // URL to error documentation
helpText?: string; // Suggested resolution actions
}Field Error Structure
interface FieldError {
field: string; // Field path (supports dot notation)
code: FieldErrorCode; // Which CONSTRAINT the value violated — a closed,
// lowercase catalog, separate from error.code (ADR-0114)
message: string; // Human-readable error for this field
value?: unknown; // The invalid value (if safe to include)
constraint?: unknown; // The constraint that was violated (e.g., max length)
}Field-level codes
FieldError.code is its own closed vocabulary, and it is lowercase where
error.code is SCREAMING. That is deliberate (ADR-0114): a top-level code names
the condition the request hit, while a field-level code names the constraint
the value violated — and constraints are declared in the metadata's own
snake_case, so the code and the schema property are the same word.
{ required: true } → code 'required'
{ max_length: 50 } → code 'max_length'
{ min_value: 0 } → code 'min_value'| Group | Codes |
|---|---|
| Presence and shape | required, invalid_type, invalid_shape, unknown_field |
| Per-type parse | invalid_boolean, invalid_number, invalid_date, invalid_time, invalid_email, invalid_url, invalid_phone, invalid_json, invalid_format |
| Bounded ranges | min_length, max_length, min_value, max_value, max_scale, min_items, max_items |
| Closed sets and references | invalid_option, invalid_value, reference_not_found, reference_ambiguous |
| Declarative rules | rule_violation, json_schema_violation, invalid_initial_state, invalid_transition |
Branch on code to decide how to mark an input; show message to the user.
Routes that parse with Zod map its issue codes into this catalog rather than
passing them through, so fields[] speaks one vocabulary whichever route served
it.
The array is fields everywhere — producers, the SDK, the console, and now the
declared envelope. It was declared as fieldErrors and emitted by nobody; that
name is tombstoned (ADR-0114 D4), so writing it fails to parse with the rename
rather than silently losing the array. If you read error.fieldErrors, you were
reading a field no server sent — move to error.fields.
Client-Side Error Handling
The @objectstack/client SDK's built-in fetch error handling attaches
code, category, httpStatus, retryable, details, and — for validation
failures — fields. It does not attach retryAfter or requestId
directly; if your server populates those on the response body, read them from
apiError.details until the client surfaces them at the top level.
Two naming details matter here:
- The per-field list is
apiError.fields, matching the wire and (since ADR-0114 D4) the spec contract too. Each entry is{ field, code, message }. It is left unset when the server reported no per-field detail, soif (apiError.fields)tests "this failure is field-anchored". apiError.codeis always the semantic code as a string. The numeric HTTP status is onapiError.httpStatusand nowhere else. This holds for both wire formats: the flat REST envelope carries the code at the top level and the runtime dispatcher's wrapped envelope carries it inerror.code, with the status alongside it inerror.httpStatus.
TypeScript Example
import type { EnhancedApiError, FieldError } from '@objectstack/spec/api';
// The two names converged in ADR-0114 D4: the spec type and the client both say
// `fields`, so no widening cast is needed any more.
type ThrownApiError = EnhancedApiError;
async function handleApiCall() {
try {
const result = await client.data.create('task', { title: 'New Task' });
return result;
} catch (error) {
const apiError = error as ThrownApiError;
switch (apiError.category) {
case 'validation':
// Show field-level errors to the user. One entry per offending value.
apiError.fields?.forEach(fe => {
showFieldError(fe.field, fe.message);
});
break;
case 'authentication':
// Redirect to login
if (apiError.code === 'EXPIRED_TOKEN') {
await refreshToken();
return handleApiCall(); // Retry
}
redirectToLogin();
break;
case 'rate_limit':
// Wait and retry
const waitTime = apiError.retryAfter ?? 60;
await sleep(waitTime * 1000);
return handleApiCall();
case 'server':
case 'external':
// Log and retry with backoff
console.error(`Server error [${apiError.requestId}]:`, apiError.message);
break;
default:
showGenericError(apiError.message);
}
}
}HTTP Status Quick Reference
| Status | Category | Common Codes |
|---|---|---|
| 400 | validation | VALIDATION_ERROR, INVALID_FIELD, MISSING_REQUIRED_FIELD, INVALID_QUERY |
| 401 | authentication | UNAUTHENTICATED, EXPIRED_TOKEN, INVALID_CREDENTIALS |
| 403 | authorization | PERMISSION_DENIED, FIELD_NOT_ACCESSIBLE, LICENSE_REQUIRED |
| 404 | not_found | RECORD_NOT_FOUND, OBJECT_NOT_FOUND, ENDPOINT_NOT_FOUND |
| 409 | conflict | CONCURRENT_MODIFICATION, DUPLICATE_RECORD, DELETE_RESTRICTED |
| 422 | validation | MISSING_REQUIRED_FIELD on an absent controlled_by_parent master reference (see above) — this row is an exception to the 400 row, not a second home for the code |
| 429 | rate_limit | RATE_LIMIT_EXCEEDED, QUOTA_EXCEEDED |
| 500 | server | INTERNAL_ERROR, DATABASE_ERROR |
| 501 | server | NOT_IMPLEMENTED — the storage routes answer it when the adapter cannot issue presigned URLs |
| 502 | external | EXTERNAL_SERVICE_ERROR, INTEGRATION_ERROR |
| 503 | maintenance | SERVICE_UNAVAILABLE |
| 504 | server | TIMEOUT — the status this code names; no route answers TIMEOUT with 500 |