Bento docs
Architecture

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.

Go, NestJS, Next.js, SvelteKit, ExpoChecked against the current v1 readiness tracker, 16 October 2026

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.

KindStatusMeans
invalid400The request is malformed or breaks a rule. 413 and 415 for body size and media type.
unauthenticated401No usable session or assertion.
forbidden403Known caller, not allowed, or a step-up is needed.
not_found404Absent, or hidden from this caller.
conflict409State disagrees: a reused key, a stale version, a duplicate name.
rate_limited429Too many attempts. Comes with Retry-After.
unavailable503A dependency failed. Try again; this is never a refusal.
timeout503A dependency or deadline ran out.
canceled503The work was cancelled, usually by the caller leaving.
internal500A 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-…"
}
FieldHolds
typeurn:bento:problem:<kind>
titleThe HTTP reason phrase
detailThe public message
kind · statusThe kind and its status
codeThe stable code; omitted for internal failures
fieldsField name to problem, when a field is at fault
request_idMatches 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_password

Where failures come from

SourceReturnsHow
Malformed JSON400 http.invalid_jsonStrict decoding: UTF-8, no duplicate keys, depth at most 32, no unknown fields, nothing trailing.
Body or media type413 http.body_too_large, 415 http.media_typeBodies are limited to 1 MiB of JSON with no content encoding.
Query parametershttp.invalid_limit, …_cursor, …_filter, …_sortShared list parsing.
Domain rules400 identity.invalid_password, ai.invalid_inputModule codes, with fields naming the field where one is at fault.
Absent or hidden404 account.not_foundAlso returned to non-members; see below.
Missing permission403 files.access_deniedMembers without the permission. Recorded as a denial.
Idempotency409 <module>.idempotency_key_reusedThe 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.
Concurrency409 <module>.version_conflict, 400 http.invalid_preconditionA stale or malformed If-Match. If-Match is optional, so there is no 428.
Database constraint409 files.name_conflict…Each module maps its own constraint names, such as folder_sibling_name_key, to its own codes.
Database outage503A shared classifier turns connection, resource and cancellation errors into unavailable, timeout or canceled.
A defect500, no codeAnything 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:

CodeStatusThe client should
identity.session_required401Send the user to sign in. Expired and idle sessions simply stop resolving, so there is no separate "expired" code.
identity.reauthentication_required403Ask for the password (and a code, with MFA), then retry once. Exports use privacy.reauthentication_required.
identity.mfa_code_required403Ask for the authenticator code.
identity.sign_in_throttled429Wait for Retry-After. After 5 failures in 15 minutes, the wait starts at 30 seconds and doubles up to 15 minutes.
edge.rate_limited429Wait. Also the per-IP limit on POST /v1/auth/*.
privacy.export_rate_limited429Wait; one export a day.
identity.terms_outdated409Show 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:

Refusal

The provider answered with a failure. It crosses the wire as { kind, type, message } with the status for its kind, never with its cause.

Unreachable

capability.unreachable: a network error, an oversized body, or a reply that is not a provider answer, such as a proxy's error page.

Timeout

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 edgeResponse
Identity cannot be reached503: an unavailable Identity is not an anonymous caller.
The upstream service fails503 edge.upstream_failed
No route table yet503 edge.no_routes
Unknown route, or /internal/*404 http.not_found
Body over 1 MiB413 edge.body_too_large
Over 20 requests a second (burst 40)429 edge.rate_limited with Retry-After
The client sends its own assertionStripped 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

  1. 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.
  2. Consumer failsunavailable, timeout and rate_limited are retried. Any other kind, or an unrecognised failure (events.consumer_defect), is permanent.
  3. DeadA permanent failure, or the tenth attempt, marks the delivery dead. It is acknowledged so it cannot block other consumers.
  4. PoisonAn outbox row that cannot be decoded is quarantined with its original bytes and a reason, and never published.
  5. Repairevents.sh dead, quarantined, requeue and replay. 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 codeReason
4001The session ended
4003Access to the workspace was lost
4008The client read too slowly
4009Protocol violation
4029Rate limited
1001Shutting down; clients treat unknown codes the same way
1011Server 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.

  1. ProxyIf the backend cannot be reached, the same-origin proxy answers 502 http.upstream_unavailable in the same problem shape.
  2. FetchA network error becomes unavailable, an abort canceled, an unreadable success http.invalid_response. Requests time out after 15 seconds.
  3. DecodedecodeFailure reads the problem. A missing body or unknown kind falls back to the HTTP status; Retry-After becomes milliseconds.
  4. QueryA session_required anywhere clears the cache and sends the user to sign in, once. Queries do not retry.
  5. Presentpresent() 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".
  6. RecoverwithStepUp prompts and retries once on a re-authentication code; forms show fields next 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.

AlertFires when
HighServerErrorRateMore than 5% of responses are 5xx for 5 minutes. Find them in Tempo by error.type.
CapabilityFailingCapability calls keep failing. Callers fail closed, so dependent requests are refused.
DeadDeliveriesAny delivery is dead.
OutboxLaggingEvents wait over 120 seconds to publish.
JobFailingA job fails three times in an hour.

Known gaps

  • Nothing checks x-bento-codes against 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.md says an unreachable upstream on a WebSocket upgrade is a 502; the code returns 503 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.