Errors are part of the contract
Every failure in Bento has a kind, a stable code and a message that is safe to show. The same model runs through both backends, across service boundaries, into events and realtime, and out to the frontends, which branch on the code and never parse text.
Principles
A closed set of kinds
Ten kinds decide the HTTP status. Domain conditions get a code, never a new kind.
Clients branch on the code
Codes such as identity.sign_in_denied are stable. The message is for people, never for logic.
Private by default
Causes, details and internal messages never leave the process. An unexpected error is a bare 500.
Fail closed
A failed dependency is never read as "no", "absent" or "allowed". Only a real answer may deny.
The contract only grows
Every client keeps a default for codes it does not know, because new ones can appear at any time.
Expected failures are values
Go returns error, TypeScript returns Result. Neither throws or panics for a failure it expects.
The error model
Both backends use the same ten kinds, and the kind alone decides the status. A failure also carries a module-qualified code, a public message, optional field problems, and, privately, details and a cause.
| Kind | Status | Means |
|---|---|---|
invalid | 400 | The request is malformed or breaks a rule. 413 and 415 for body size and media type. |
unauthenticated | 401 | No usable session or assertion. |
forbidden | 403 | Known caller, not allowed, or a step-up is needed. |
not_found | 404 | Absent, or hidden from this caller. |
conflict | 409 | State disagrees: a reused key, a stale version, a duplicate name. |
rate_limited | 429 | Too many attempts. Comes with Retry-After. |
unavailable | 503 | A dependency failed. Try again; this is never a refusal. |
timeout | 503 | A dependency or deadline ran out. |
canceled | 503 | The work was cancelled, usually by the caller leaving. |
internal | 500 | A defect. Everything unrecognised ends up here. |
// backend/go/internal/identity/app/errors.go
ErrSignInDenied = apperrors.New(apperrors.Unauthenticated, "sign in was refused").
WithType("identity.sign_in_denied").
WithPublicMessage("Email or password is incorrect.")// backend/nest/src/shared/errors
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
type Failure = Readonly<{
kind: Kind; message: string; type?: string;
fields?: Readonly<Record<string, string>>;
}>;In Go, an error is an ordinary error built with New or Wrap and refined with immutable With* methods, so errors.Is still matches the original sentinel. context.Canceled and DeadlineExceeded become canceled andtimeout. In Nest, expected failures are Result values whose message is the public one; details and causes live in a private map, and a controller throws AppError only to reach the problem filter.
The response
Every failure is application/problem+json with the same fields in both backends. The OpenAPI schema forbids any others.
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json; charset=utf-8
X-Request-ID: 0192c7d8-…
{
"type": "urn:bento:problem:unauthenticated",
"title": "Unauthorized",
"detail": "Email or password is incorrect.",
"kind": "unauthenticated",
"status": 401,
"code": "identity.sign_in_denied",
"request_id": "0192c7d8-…"
}| Field | Holds |
|---|---|
type | urn:bento:problem:<kind> |
title | The HTTP reason phrase |
detail | The public message |
kind · status | The kind and its status |
code | The stable code; omitted for internal failures |
fields | Field name to problem, when a field is at fault |
request_id | Matches X-Request-ID, for support |
Each failure response in the OpenAPI spec lists the codes it can carry in x-bento-codes, 247 lists in all. That is the catalogue frontends write their messages against.
'400':
x-bento-codes:
- http.invalid_json
- identity.invalid_email
- identity.invalid_passwordWhere failures come from
| Source | Returns | How |
|---|---|---|
| Malformed JSON | 400 http.invalid_json | Strict decoding: UTF-8, no duplicate keys, depth at most 32, no unknown fields, nothing trailing. |
| Body or media type | 413 http.body_too_large, 415 http.media_type | Bodies are limited to 1 MiB of JSON with no content encoding. |
| Query parameters | http.invalid_limit, …_cursor, …_filter, …_sort | Shared list parsing. |
| Domain rules | 400 identity.invalid_password, ai.invalid_input | Module codes, with fields naming the field where one is at fault. |
| Absent or hidden | 404 account.not_found | Also returned to non-members; see below. |
| Missing permission | 403 files.access_denied | Members without the permission. Recorded as a denial. |
| Idempotency | 409 <module>.idempotency_key_reused | The key was used with different input. A same-input retry of a write is replayed instead, with Idempotency-Replayed: true. A receipt lookup answers 404 <module>.receipt_not_found (unknown) or 400 <module>.unknown_operation. |
| Concurrency | 409 <module>.version_conflict, 400 http.invalid_precondition | A stale or malformed If-Match. If-Match is optional, so there is no 428. |
| Database constraint | 409 files.name_conflict… | Each module maps its own constraint names, such as folder_sibling_name_key, to its own codes. |
| Database outage | 503 | A shared classifier turns connection, resource and cancellation errors into unavailable, timeout or canceled. |
| A defect | 500, no code | Anything unrecognised. Go recovers panics into the same response. |
Safe by default
Nothing internal leaves
The public view is the kind, the public message, the code and the fields. Causes, details and internal messages stay in the process. An internal error, an unrecognised error or several joined errors all become 500 "internal error" with no code.
Logs keep facts, not text
A 5xx is logged with its kind, code and the Go type of its cause, plus component,dependency and operation only when they are safe identifiers. The raw error text is not logged, and a request ID links the log to the response.
No account enumeration
Sign-up, password reset and email change always answer 202. A refused sign-in is401 identity.sign_in_denied, "Email or password is incorrect.", whether or not the address exists, and unknown addresses are throttled the same way.
404 hides, 403 explains
Someone outside a workspace gets 404 for its resources, so they learn nothing about them. A member without the permission gets 403, which is recorded as a<module>.access_denied.v1 event for the audit log, at most 10 a minute per actor and workspace. Recording never changes the response.
Limits, sessions and step-up
Some failures ask the client to do something specific. They have their own codes:
| Code | Status | The client should |
|---|---|---|
identity.session_required | 401 | Send the user to sign in. Expired and idle sessions simply stop resolving, so there is no separate "expired" code. |
identity.reauthentication_required | 403 | Ask for the password (and a code, with MFA), then retry once. Exports use privacy.reauthentication_required. |
identity.mfa_code_required | 403 | Ask for the authenticator code. |
identity.sign_in_throttled | 429 | Wait for Retry-After. After 5 failures in 15 minutes, the wait starts at 30 seconds and doubles up to 15 minutes. |
edge.rate_limited | 429 | Wait. Also the per-IP limit on POST /v1/auth/*. |
privacy.export_rate_limited | 429 | Wait; one export a day. |
identity.terms_outdated | 409 | Show the current terms. Acceptance is advisory: no other route refuses, and clients read termsAcceptanceRequired. |
Across services
A capability call can fail in three ways, and the caller can always tell them apart:
The provider answered with a failure. It crosses the wire as { kind, type, message } with the status for its kind, never with its cause.
capability.unreachable: a network error, an oversized body, or a reply that is not a provider answer, such as a proxy's error page.
capability.deadline_exceeded after 2 seconds. An in-process call cannot be cancelled, so its late result is discarded.
There are no retries. The calling module turns a transport failure into its own dependency failure, and only a successful negative answer may deny:
// backend/go/internal/usage/app/errors.go
func AuthorizationCheckFailed(cause error) error {
return apperrors.New(dependencyKind(cause), "unable to check account access").
WithType("usage.authorization_check_failed").
WithPublicMessage("Account usage is temporarily unavailable.").
WithCause(cause)
}So a permission check that fails is 503 usage.authorization_check_failed, while a permission check that answers "no" is 404 usage.account_not_found. A few callers degrade instead: whenidentity.profiles.v1 fails, names come back empty rather than failing the page.backend/ARCHITECTURE-ENFORCEMENT.md lists what a failure means for every capability.
| At the edge | Response |
|---|---|
| Identity cannot be reached | 503: an unavailable Identity is not an anonymous caller. |
| The upstream service fails | 503 edge.upstream_failed |
| No route table yet | 503 edge.no_routes |
Unknown route, or /internal/* | 404 http.not_found |
| Body over 1 MiB | 413 edge.body_too_large |
| Over 20 requests a second (burst 40) | 429 edge.rate_limited with Retry-After |
| The client sends its own assertion | Stripped before anything else runs |
Behind the edge, any invalid, expired or unsigned assertion is 401 principal.assertion_invalid, with no hint about which check failed. A bad service token at a capability is401 capability.unauthenticated, and a caller the capability does not accept is403 capability.caller_not_allowed.
Events, jobs and caches
- Publish failsThe outbox row stays unpublished and is retried 5 seconds later, without holding up the rest of the batch. Writes keep succeeding while the transport is down.
- Consumer fails
unavailable,timeoutandrate_limitedare retried. Any other kind, or an unrecognised failure (events.consumer_defect), is permanent. - DeadA permanent failure, or the tenth attempt, marks the delivery dead. It is acknowledged so it cannot block other consumers.
- PoisonAn outbox row that cannot be decoded is quarantined with its original bytes and a reason, and never published.
- Repair
events.sh dead,quarantined,requeueandreplay. Consumers that already finished an event skip it, and published rows are kept for 7 days to replay from.
Jobs
A failed run is logged with its code, never its message, recorded as a failed run, and retried at the next interval. Each run is bounded to its interval or 5 minutes.
Cache
A cache failure is a miss. It never fails a request and never grants anything; writes pause while the store is failing.
Rate limits
If the shared Redis limiter fails or takes over 50 ms, the limited request returns 503 edge.rate_limit_store_unavailable. The service keeps the cross replica limit closed instead of switching to independent process limits.
Realtime
On the WebSocket, a refused operation is an error message with a code, such astopic_denied or subscription_limit, and the connection stays open. If the permission check behind a subscription fails, the topic is not added: it fails closed. Closing is reserved for these cases, each announced with a closing message first:
| Close code | Reason |
|---|---|
4001 | The session ended |
4003 | Access to the workspace was lost |
4008 | The client read too slowly |
4009 | Protocol violation |
4029 | Rate limited |
1001 | Shutting down; clients treat unknown codes the same way |
1011 | Server error, including a session re-check that could not be answered |
In the frontends
Next.js and SvelteKit share the same kernel, and the mobile app has its own copy: a Resulttype and a Failure union over the same ten kinds.
- ProxyIf the backend cannot be reached, the same-origin proxy answers
502 http.upstream_unavailablein the same problem shape. - FetchA network error becomes
unavailable, an abortcanceled, an unreadable successhttp.invalid_response. Requests time out after 15 seconds. - Decode
decodeFailurereads the problem. A missing body or unknown kind falls back to the HTTP status;Retry-Afterbecomes milliseconds. - QueryA
session_requiredanywhere clears the cache and sends the user to sign in, once. Queries do not retry. - Present
present()tries the screen's message for the code, then common codes, then a message for the kind, so an unknown code still reads sensibly. A 503 always reads as "not responding", never "not allowed". - Recover
withStepUpprompts and retries once on a re-authentication code; forms showfieldsnext to each input; the terms dialog cannot be dismissed while acceptance is required.
The rules for screens are in frontend/UI-INTEGRATION.md ("Errors"): branch on the code, never parse detail, show request_id in the default branch, and never retry a 429 automatically.
Observing failures
A failed span has status Error and error.type set to the code, never the message, so traces group by cause without leaking data.
| Alert | Fires when |
|---|---|
HighServerErrorRate | More than 5% of responses are 5xx for 5 minutes. Find them in Tempo by error.type. |
CapabilityFailing | Capability calls keep failing. Callers fail closed, so dependent requests are refused. |
DeadDeliveries | Any delivery is dead. |
OutboxLagging | Events wait over 120 seconds to publish. |
JobFailing | A job fails three times in an hour. |
Known gaps
- Nothing checks
x-bento-codesagainst the codes the backends actually return. - No test compares error codes between Go and Nest directly. The end-to-end suites assert some codes on both, which gives partial parity.
- Nest has no process-level handler for unhandled rejections or uncaught exceptions.
contracts/edge/README.mdsays an unreachable upstream on a WebSocket upgrade is a 502; the code returns503 edge.upstream_failed.- The mobile adapter now sends idempotency keys and
If-Match; the remaining error/API work is broader authenticated route coverage and a systematic cross-runtime error-code parity pass.
In the repository
backend/go/pkg/errorsKinds, the error type and its public view.backend/nest/src/shared/errorsResult, Failure and their helpers.backend/HTTP-V1.mdCommon problem codes and refused actions.backend/ARCHITECTURE-ENFORCEMENT.mdWhat each capability failure means.backend/EVENT-RULES.mdRetries, dead deliveries and replay.frontend/SERVICE-QUERY-CONVENTION.mdThe Result contract across web and mobile.