Error Handling
Every failure the platform answers carries a stable, machine-readable code. Branch on it, localize on it, log it — and never parse the human-readable text, which is free to change between releases.
An app that localizes its interface needs to tell a permission refusal from a missing record from a rate limit, and the HTTP status alone cannot do that: a 403 might be "you are not a member of this app" or "this document's access rule says no", and those are different sentences in every language you ship. The code is what separates them.
The Envelope
Every 4xx and 5xx response with a body, on both /app/{appId}/api/* and /admin/api/*, is JSON:
{
"error": "Document not found",
"status": 404,
"code": "NOT_FOUND",
"timestamp": "2026-09-13T04:12:57.219Z",
"details": {}
}code— a non-empty string, always present. This is the contract. It is the handler's own code when it has one (DOC_ACCESS_DENIED,FUNCTION_DISABLED,VALIDATION_FAILED,RATE_LIMITED, …), and otherwise the status-derived default from the table below. An explicit code always wins over the default.error— the human-readable text. It is written for a developer reading a log, not for your user, and it may change without notice. Show your own copy, chosen bycode.status— the numeric HTTP status, repeated in the body.timestamp— when the server produced the failure, ISO 8601.details— present only when a handler attaches structured context (the offending field names on a validation failure, theretryAfterseconds on a rate limit).
Content-Type is application/json.
A few bodies carry the human text under message instead of error — the API integration proxy's refusals are the ones you are most likely to meet. Read error ?? message and you will handle both. Those bodies carry code like every other.
Some responses also carry an errorCode field. It is the same value as code; the two spellings agree on the wire, and the supported clients surface either one as the code.
Default Codes by Status
A failure whose handler named no specific cause carries the default for its status:
| Status | code |
|---|---|
| 400 | INVALID_REQUEST |
| 401 | UNAUTHENTICATED |
| 403 | ACCESS_DENIED |
| 404 | NOT_FOUND |
| 405 | METHOD_NOT_ALLOWED |
| 409 | STATE_CONFLICT |
| 410 | GONE |
| 413 | PAYLOAD_TOO_LARGE |
| 415 | UNSUPPORTED_MEDIA_TYPE |
| 422 | UNPROCESSABLE |
| 429 | RATE_LIMITED |
| 500 | INTERNAL_ERROR |
| 501 | NOT_IMPLEMENTED |
| 502 | UPSTREAM_ERROR |
| 503 | UNAVAILABLE |
| 504 | UPSTREAM_TIMEOUT |
Any other 4xx is INVALID_REQUEST; any other 5xx is INTERNAL_ERROR.
Note the 409: a generic conflict — a name already taken, a resource that already exists — is STATE_CONFLICT. CONFLICT is reserved for the optimistic-concurrency refusal, the one that also carries serverModifiedAt and expectedModifiedAt so you can report what moved underneath you. Treating a duplicate name as a concurrency conflict would send the wrong user through a merge flow, so the two are deliberately different strings.
STORAGE_CONSISTENCY_PENDING — safe to retry
One explicit code exists to be retried on. 503 STORAGE_CONSISTENCY_PENDING means the platform wrote your row and could not confirm that write within the store's consistency window. Nothing about the request was wrong, and sending it again is safe. It is rare, and you will see it on the paths that write a row and then answer from it.
On a run, retry with the same key: the retry then replays the recorded run rather than starting a second one. You do not have to have chosen that key yourself — when the failure concerns a run, details carries the runKey the platform recorded it under (with its runId and contextDocId), including when your request left the key out and the platform generated it. Reuse the runKey and contextDocId from details on the retry; resending a body with no key mints a new one, and that starts a second run over any work the first already did.
Deliberately not the status default (UNAVAILABLE), and deliberately not a 4xx — a caller that read this as "your request was malformed" or as "you may not do that" would be wrong in both directions.
Branching by Code
try {
await client.documents.open(documentId);
} catch (err) {
if (err instanceof JsBaoApiError) {
switch (err.code) {
case "DOC_ACCESS_DENIED":
case "ACCESS_DENIED":
show(t("errors.noAccessToDocument"));
return;
case "NOT_FOUND":
show(t("errors.documentGone"));
return;
case "UNAUTHENTICATED":
case "INVALID_TOKEN":
show(t("errors.signInAgain"));
return;
case "RATE_LIMITED":
show(t("errors.slowDown"));
return;
case "STATE_CONFLICT":
show(t("errors.alreadyExists"));
return;
case undefined:
// No code: the response did not come from the platform's error
// path — an older server, or an intermediary's HTML 502.
show(t("errors.unknown"));
return;
default:
show(t("errors.unknown"));
return;
}
}
throw err;
}do {
_ = try await client.documents.open(documentId)
} catch let error as HttpError {
switch error.serverCode {
case "DOC_ACCESS_DENIED", "ACCESS_DENIED":
show(t("errors.noAccessToDocument"))
case "NOT_FOUND":
show(t("errors.documentGone"))
case "UNAUTHENTICATED", "INVALID_TOKEN":
show(t("errors.signInAgain"))
case "RATE_LIMITED":
show(t("errors.slowDown"))
case "STATE_CONFLICT":
show(t("errors.alreadyExists"))
case nil:
// No code: the response did not come from the platform's error path —
// an older server, or an intermediary's HTML 502.
show(t("errors.unknown"))
default:
show(t("errors.unknown"))
}
}In the JavaScript client the code is JsBaoApiError.code; in Swift it is HttpError.serverCode. Both are populated on every path the client can fail on, including the terminal error you get after a refused token refresh — that error keeps its Invalid credentials message and also carries the original 401's body and code, so you can tell an expired session from a revoked one.
That includes the transfers that move raw bytes rather than JSON, which are the ones you might reasonably doubt: a blob upload or download, a bucket transfer, an avatar upload. Those read the response body as bytes rather than through the typed request path, and they parse the same envelope: the error keeps its message and carries the code beside it.
The CLI's ApiError exposes the same value as code, on its blob and document-artifact transfers as well as on its JSON calls.
The code on these paths is read by the client, and js-bao-wss-client ships pre-built files — so what your app sees is decided by the client version it installs. Keep that dependency current.
When There Is No Code
code is undefined (Swift: nil) when the response did not come from the platform's error path: an intermediary that answered on its own — a CDN's HTML 502, a proxy's timeout page. Treat that as "unknown failure" and fall back to a generic message; it is not a code you can branch on, and it never will be.
What Is Not Covered
- 3xx responses are not errors. A blob download that answers
304 Not Modifiedon an ETag match is bodyless, and an OAuth initiation redirects. - A failing
HEADrequest has no body, because HTTP says so. It answers the same status and the same headers itsGETcounterpart would; if you need the code, issue theGET. - WebSocket error frames are a separate surface with their own shape.
- A server function that throws is not an error response. The call reached your function, so it answers HTTP 200 with
status: "failed"and anerrorCodesuch asFUNCTION_THREWin the body — branch on those, not on a 4xx/5xx. See The response envelope.
Related
- Authentication — the auth codes (
INVITATION_REQUIRED,DOMAIN_NOT_ALLOWED,OTP_MAX_ATTEMPTS, …) an app branches on during sign-in. - Access Control — where the
<DOMAIN>_ACCESS_DENIEDrefusals come from. - Working with Documents —
DOC_ACCESS_DENIEDin context.