Skip to content

Latest commit

 

History

History
1014 lines (832 loc) · 50.8 KB

error-catalog.mdx

File metadata and controls

1014 lines (832 loc) · 50.8 KB
title API error codes — the complete catalog
navTitle Error Code Catalog
description Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies

ObjectStack uses a structured error system of error categories and 58 error codes reachable on the wire, catalogued in full below (that count is pinned by packages/spec/src/api/error-catalog-docs.test.ts against the wire face). Every error includes a machine-readable code, HTTP status mapping, and retry guidance.

This catalog documents the wire face — the codes a client can actually receive. That is not quite the StandardErrorCode enum: the enum also carries in-process spellings the REST door translates at the boundary, and the catalog carries error-code ledger codes the enum does not. A translated code is documented under the spelling clients receive, and named in that entry's cross-reference sentence so the in-process one stays findable.

**Source:** `packages/spec/src/api/errors.zod.ts` **Import:** `import { StandardErrorCode, ErrorCategory, ErrorResponseSchema } from '@objectstack/spec/api'` **One vocabulary, two tiers** ([ADR-0112](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0112-error-code-vocabulary-and-ledger.md), settles [#3841](#3841)): every top-level `error.code` is SCREAMING_SNAKE and comes from either
  1. the standard catalog below (StandardErrorCode — generic conditions with platform-wide HTTP semantics), or
  2. the error-code ledger (ERROR_CODE_LEDGER in @objectstack/spec/api) — service-specific codes such as VALIDATION_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 retryAfterSeconds the envelope carries 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

**A request too large for its door is not this code.** An upload over the File Storage settings' `max_upload_mb`, and an import over its route's row ceiling, answer `413` [`PAYLOAD_TOO_LARGE`](#payload_too_large).
{
  "code": "VALIDATION_ERROR",
  "message": "Request validation failed",
  "category": "validation",
  "httpStatus": 400,
  "retryable": false,
  "fields": [
    { "field": "email", "message": "Invalid email format", "code": "invalid_email" }
  ]
}

API_KEY_EXPIRY_TOO_LONG

Cause: POST /api/v1/keys asked for an expires_at further away than the deployment's maximum key lifetime (api_key_max_expiry_days, one year by default). The message names the latest date allowed; exactly the maximum is accepted.
Fix: Ask for that date or an earlier one, and rotate the key before it expires.
Retry: no_retry

VALIDATION_FAILED

Cause: Record-level validation refused the write. This is the code a producer names — ValidationError from the record and rule validators, and any error of the same shape (code: 'VALIDATION_FAILED' / name: 'ValidationError') — as opposed to VALIDATION_ERROR above, which is derived from the status when the producer named no code at all. It covers required, type, format, length, range and picklist violations, a dangling lookup / master_detail reference (fields[].code === 'reference_not_found' — see the callout below), a malformed expectedVersion / If-Match token, and a batch row that names no record id.
Fix: Branch on this code and read fields[]. Each entry carries field (the API name, so a form can focus the right input), code from the field-level catalog (ADR-0114), a message rendered in the caller's locale, and — where they apply — label, constraint, value and options. Correct the named values and resend; no retry of the same payload can succeed.
Retry: no_retry

**Where the per-field list rides is decided by the door, not by the code — and it can be empty.** On the `/data` routes the envelope is flat and `fields` is a top-level sibling of `code`, always present, `[]` when the producer named none:
{
  "error": "Email is not a valid email address",
  "code": "VALIDATION_FAILED",
  "fields": [
    { "field": "email", "code": "invalid_format", "label": "Email",
      "message": "Email is not a valid email address" }
  ]
}

The same throw served through the runtime dispatcher answers the nested envelope, and the list rides in details instead:

{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Email is not a valid email address",
    "httpStatus": 400,
    "details": { "fields": [{ "field": "email", "code": "invalid_format" }] }
  }
}

Both are 400. A refusal a route builds itself rather than throwing — share link creation without object / recordId, for instance — carries the code and the sentence and no fields at all. So read the sentence out of error / message, and treat fields as a refinement that may be absent or empty.

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, and so does a value with the wrong shape — an array entry that is not a string, or a value that is itself neither a comma-separated string nor an array of field names), groupBy, and aggregations[].field. The same code also refuses a field that DOES exist but is declared internal: true, when a read names it in a position that would evaluate its stored value: a filter (including a condition on a related object reached through a lookup, and an aggregation's own filter), a sort, a groupBy, an aggregations[].field, a searchFields entry, or an expand entry's own filter or sort at any depth. Such a field is never returned on a generic read, so it cannot be queried by either — the refusal names the object that declares it. Query by another field. The engine itself gives the same refusal on its own reads for every caller but a system context; server code that matches on such a field runs its lookup as a system context. A non-blank search is refused (param: 'search'), not answered with every row, when the object's resolved search set is empty: no searchableFields entry names a stored field, and the default search scans none (internal: true fields never count). Declare one that does, or mirror a composed title onto a stored text field. Off the request path the same code answers backfillSummaryNulls's recomputeUndefinedOnEmpty (os migrate summary-nulls --recompute-undefined-on-empty object.field) when an entry is not a roll-up owned by an object the run walks — a typo, a real non-summary field, or a roll-up on an object --object left out are refused alike, one message naming every unresolved entry and how many objects the run walked.
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 stored-master path 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: a `controlled_by_parent` detail whose stored master reference is null 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. An update or delete by id of a detail record whose stored master reference is null answers 422 MISSING_REQUIRED_FIELD, whatever the declaration: the caller sent no such field, so nothing could be named in fields and no payload would fix it.

A request that leaves the master reference out, or empties it, does not answer this code. On a master_detail that is required and neither readonly nor system, validation answers it with the documented 400 VALIDATION_FAILED and fields. A master reference marked readonly or system is not one an author's write supplies, so it cannot be the master (ADR-0055 §1): ObjectSchema refuses that declaration at parse (master-not-writable), and on an object that reaches the runtime without the parse, every write that sets the reference answers 422 INVALID_METADATA:

  • every insert, single or batch, whether or not it names a master;
  • every update whose body carries the reference key, whatever the value, null included.

Its message starts [Security] Configuration defect: and carries the parse's own refusal, naming the object, the field, the flag and the fix. A delete, and an update that leaves the key out, are answered as on any detail.

A master_detail with no required, and a required lookup on an object that declares no master_detail, are not master references at all: the master is exactly one master_detail declared required: true (ADR-0055 §1). ObjectSchema refuses such an object at parse, and one that reaches the runtime unparsed answers every write 422 INVALID_METADATA.

The full matrix is on the protocol page: MISSING_REQUIRED_FIELD.

The INVALID_METADATA refusal is not going to be relaxed: required-field validation skips system and readonly fields before its required check, so on those two declarations the gate is the only thing standing between the request and a detail record outside its master — which the controlled_by_parent read filter (fk IN (readable masters)) can never match, leaving a record readable by nobody.

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: Reserved for a value that does not match an expected format. No route emits it today. A malformed value is refused as a field-level failure inside VALIDATION_FAILED instead, and its fields[].code names what checked it:

  • the field's type — an email, url or phone field runs a built-in shape check keyed on type itself and answers invalid_email / invalid_url / invalid_phone; a value a date, datetime or time field cannot parse answers invalid_date / invalid_time;
  • a format validation rule — its regex, or its named format (email | url | phone | json) — answers invalid_format.

A value that misses a declared pattern outside record metadata (a settings value, a request body a route parses with Zod) also answers field-level invalid_format.
Fix: Do not branch on this code; branch on VALIDATION_FAILED + fields[].code. To change what a field accepts, change its type or the format validation rule — a field-level format key runs no write-time check on any field type, so editing it changes nothing.
Retry: no_retry

VALUE_TOO_LONG

Cause: Reserved for a string value longer than its declared maximum. No route emits it today. A length miss is refused as a field-level max_length entry instead, and where that entry rides depends on what was written:

  • a record write (a string over a field's maxLength) and a request body a route parses with Zod answer 400 VALIDATION_FAILED with the entry in fields[] — top-level on the /data routes, under details when the runtime dispatcher serves it (see VALIDATION_FAILED); on a record write its constraint carries maxLength and the actual length;
  • a settings write (a value over its declared maximum) answers 400 SETTINGS_VALIDATION with the entry in details.fields[].

Fix: Do not branch on this code; branch on VALIDATION_FAILED + fields[].code max_length, or on SETTINGS_VALIDATION + details.fields[].code max_length for a settings write, and shorten the value to fit the declared maximum.
Retry: no_retry

VALUE_TOO_SHORT

Cause: Reserved for a string value shorter than its declared minimum. No route emits it today. A length miss is refused as a field-level min_length entry instead, and where that entry rides depends on what was written:

  • a record write (a string under a field's minLength) and a request body a route parses with Zod answer 400 VALIDATION_FAILED with the entry in fields[] — top-level on the /data routes, under details when the runtime dispatcher serves it (see VALIDATION_FAILED); on a record write its constraint carries minLength and the actual length;
  • a settings write (a value under its declared minimum) answers 400 SETTINGS_VALIDATION with the entry in details.fields[].

Fix: Do not branch on this code; branch on VALIDATION_FAILED + fields[].code min_length, or on SETTINGS_VALIDATION + details.fields[].code min_length for a settings write, and lengthen the value to meet the declared minimum.
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": "User: no sys_user record has id \"usr_missing\"",
  "code": "VALIDATION_FAILED",
  "fields": [{
    "field": "user_id",
    "code": "reference_not_found",
    "label": "User",
    "constraint": { "target": "sys_user" },
    "value": "usr_missing"
  }]
}

The check covers create, update and bulk update. Four 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), a target that cannot be checked at all — an unregistered object, an unreachable datasource — fails open rather than inventing a rejection, and an update that carries back a value (or a member of a multi-valued field) the row already stores is not judged again, because the update does not add it; on a bulk update that holds only when every matched row already stores it.

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 asked for more records than a request may carry. The data API answers it when the deployment sets OS_DATA_MAX_PAGE_SIZE and a list or analytics read named a limit ($top, top or limit) above it, and when an analytics read that named no limit would answer more rows than OS_DATA_DEFAULT_PAGE_SIZE. The message names the maximum (or the default).
Fix: Name a limit within the maximum and page with offset (or a cursor). See Read limits a deployment may set.
Why it is an error and not a shorter page: a clamped page is a different page from the one asked for, so a caller paging by its own limit would skip rows, and an analytics answer has no hasMore to say it was cut.
Retry: no_retry

QUERY_ROW_BUDGET_EXCEEDED

Cause: The deployment sets OS_DATA_QUERY_ROW_BUDGET and the read's offset plus its rows would walk more than that budget. The message names the budget.
Fix: Narrow the read with a filter, or use a smaller offset and limit. See Read limits a deployment may set.
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

API_KEY_EXPIRED

Cause: The API key is past its expires_at, or it was created with no expiry and the one-year grace the API-key lifecycle policy gave it has ended. The message names the key's prefix and the date.
Fix: Keys do not renew: create a new key and replace this one wherever it is configured. See key lifetime, inactivity and rotation.
Retry: no_retry

API_KEY_INACTIVE

Cause: The API key has no recorded use for longer than the deployment's inactivity window (api_key_inactivity_days, 90 by default). The key is not revoked; its own call does not revive it. The message names the date the idle period started.
Fix: Create a new key and replace this one wherever it is configured.
Retry: no_retry

API_KEY_REVOKED

Cause: The API key was revoked by its owner or an administrator, or by an administrator's forced password reset (the message says which).
Fix: Ask the key's owner or a platform administrator to restore it (the Restore API Key action on the API Keys list); the same key then works again at once. A key a forced password reset revoked is restored by a platform administrator only: its owner is refused 403 PERMISSION_DENIED. Otherwise create a new key and replace this one wherever it is configured.
Retry: no_retry

These three are answered on every endpoint that accepts an API key: the endpoints the runtime serves (the MCP HTTP endpoint among them), the REST data API, the settings, file-download, datasource admin and share-link routes, the marketplace install routes and the MCP stdio transport. An unknown key is always the generic 401.


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

UNIQUE_VIOLATION

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

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.

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

PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE

Cause: PATCH /api/v1/packages/:id/disable was refused because a validation rule of an enabled package (declared dependent or not, an app's .custom pair included, any severity) reads a field this package adds to another object through objectExtensions. Disabling it would withhold that field, and every write to the object would then fail the rule. Each rule is judged as the object serves it: one rule per name (the copy a later layer declares wins), with a tenant overlay's switch honoured. The message names each rule as package · object · rule (reads FIELD), one or several, after the layer whose copy is served (tenant overlay for a rule the overlay declares itself). Nothing changed. POST /api/v1/packages answers the same code for an overwrite of an enabled package with enableOnInstall: false that would leave such a package disabled: the overwrite itself lands and the package stays enabled. A fresh install with enableOnInstall: false lands disabled, as before. The enable door answers the same code for the mirror order: PATCH /api/v1/packages/:id/enable is refused while a rule the enable would put into service (the package's own, or one on an object it owns) reads a field a still-disabled package withholds, the message naming each rule and the disabled package to enable first; POST /api/v1/packages with enableOnInstall: true on an overwrite of a disabled package answers it too, and the package stays disabled.
Fix: Switch off or change each named rule first, or disable the package that owns it, then disable this package; or keep it enabled. A formula over the field is no reason to refuse: it answers null. On the enable door: enable the named disabled package first, or switch off or change each named rule.
Retry: no_retry


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


Size Limit Errors (413)

PAYLOAD_TOO_LARGE is an error-code ledger code, listed under each package that answers it — @objectstack/rest and @objectstack/service-storage — not a standard-catalog member, so its status is published here rather than inherited from a category. The standard catalog names no 413 member.

PAYLOAD_TOO_LARGE

HTTP Status: 413
Cause: The request carries more than its door accepts:

  • An upload over the File Storage settings' max_upload_mb. The presigned-upload door (the declared size), the chunked-upload start (the declared totalSize), the local adapter's raw upload (the body) and the chunk door (the bytes the upload already holds plus the chunk) refuse it before anything is stored. The message names the size, the limit and the setting.
  • An import over its route's row ceiling — 5,000 rows on the synchronous route, 50,000 on the job route (size limits).

Fix: Raise the setting or send a smaller file; split the import across requests. Resending the same request cannot succeed.
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 retryAfterSeconds field for the wait.
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 retryAfterSeconds), or upgrade the plan.
Retry: retry_after


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 value

client.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": "'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": "'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_metadata already 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
  refusal?: true;                // Producer-declared refusal: keeps message on a 5xx
  category?: ErrorCategory;      // Error category
  httpStatus?: number;           // HTTP status code
  retryable: boolean;            // Whether retry may succeed
  retryStrategy?: RetryStrategy; // Recommended retry approach
  retryAfterSeconds?: number;    // Wait before retrying (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, max_precision, min_items, max_items
Closed sets and references invalid_option, value_domain (the written value is not a member of the field's declared valueDomain standard), 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 `retryAfterSeconds` 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, so if (apiError.fields) tests "this failure is field-anchored".
  • apiError.code is always the semantic code as a string. The numeric HTTP status is on apiError.httpStatus and 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 in error.code, with the status alongside it in error.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.retryAfterSeconds ?? 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_FAILED, 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, API_KEY_EXPIRED, API_KEY_INACTIVE, API_KEY_REVOKED
404 not_found RECORD_NOT_FOUND, OBJECT_NOT_FOUND, ENDPOINT_NOT_FOUND
409 conflict CONCURRENT_MODIFICATION, UNIQUE_VIOLATION, DELETE_RESTRICTED
413 validation PAYLOAD_TOO_LARGE — an upload over the storage settings' max_upload_mb, or an import over its row ceiling (see above)
422 validation MISSING_REQUIRED_FIELD on a controlled_by_parent detail whose stored master reference is null (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