Error Code Catalog
Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
Error Code Catalog
ObjectStack uses a structured error system with 9 error categories and 53 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.
Retry: no_retry
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 (500)
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
Batch Operation Errors
BATCH_PARTIAL_FAILURE
Cause: Some operations in a batch request succeeded while others failed.
Fix: Check the details for individual operation results. Retry only the failed operations.
Retry: retry_immediate (failed operations only)
BATCH_COMPLETE_FAILURE
Cause: All operations in the batch request failed.
Fix: Check the details for root cause. Fix and retry the entire batch.
Retry: retry_backoff
TRANSACTION_FAILED
Cause: A database transaction failed and was rolled back.
Fix: Check the details for the specific failure. Retry the entire transaction.
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);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, 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 |
| 429 | rate_limit | RATE_LIMIT_EXCEEDED, QUOTA_EXCEEDED |
| 500 | server | INTERNAL_ERROR, DATABASE_ERROR, TIMEOUT |
| 502 | external | EXTERNAL_SERVICE_ERROR, INTEGRATION_ERROR |
| 503 | maintenance | SERVICE_UNAVAILABLE |