Branch onerror.code, never onerror.message. Codes are a frozen, documented catalogue. Messages are human-readable and may be reworded at any time.
The envelope
Every/v1 response — success or failure — uses the same shape:
meta.request_id is also returned as the X-Request-Id header. Quote it in support
requests — it is how a specific call is found in the logs.
We deliberately kept this envelope for v1 rather than adopting RFC 9457 (application/problem+json): existing consumers depend on it, and it already carries a stablecodeplus a request id. Revisit at v2.
The code catalogue
Defined insupabase/functions/_shared/errors.ts and mirrored into the OpenAPI spec
as the ErrorCode enum. A test (src/test/api/error-catalogue.test.ts) fails the
build if the two ever disagree, so the spec cannot go stale.
The
IDEMPOTENCY_* codes distinguish three different situations that all
look like “your retry did not go through”: the endpoint needs a key and you sent
none (428), you reused a key with a different body (422 — a caller bug), or an
identical request is still executing (409 — normal under concurrency, retry
shortly). See idempotency.md.
QUOTA_UNAVAILABLE means the quota engine could not be consulted — it is
our fault, not the caller’s, and it is retryable. It is deliberately separate from
QUOTA_EXCEEDED: one means “you have used your allowance”, the other means “we cannot
tell”. The rate-limit layers fail OPEN when Redis is down, but the quota fails CLOSED,
because serving requests we cannot account for gives away metered calls with no record.
See rate-limits.md.
MODULE_DISABLED is distinct from a scope failure: the key has the scope, but the
tenant has that product module switched off. Grants stay inert and restore
automatically when the module is re-enabled — so treat it as recoverable, not as a
reason to delete the key.
Behaviours worth knowing
Unknown endpoint →404 NOT_FOUND.
Known endpoint, wrong verb → 405 METHOD_NOT_ALLOWED, with an Allow header and
details.allowed_methods. Hono would otherwise answer 404 for this, which is
misleading — the resource exists, the method does not.
Malformed JSON body → 400 VALIDATION_ERROR. Handled centrally in app.onError, so
it applies to every route, present and future, without each handler wrapping its own
parse. Previously an unparseable body threw and surfaced as 500 INTERNAL_ERROR — a
client mistake reported as a server fault, which also polluted error monitoring.
A malformed body and a body that failed field validation share one code. They are told apart bySignup is enumeration-safe.details: a field-validation failure carriesdetails.fieldsnaming what to fix, an unparseable body has no fields to name.
POST /v1/onboarding returns the same generic
VALIDATION_ERROR for a bad payload and for an email that already exists, so the
endpoint cannot be used to discover which addresses are registered. Do not “improve”
this by returning ALREADY_EXISTS.
Rules when adding an error
- Never leak internals.
messagemust not contain stack traces, SQL, or upstream provider text. Log the real error server-side; return something a caller can act on. - Sanitise
details. It is for actionable context (which field, which allowed values) — not raw exception payloads. - Reuse an existing code where one fits. New codes are a contract change: add to
errors.tsand the spec enum in the same PR, or the catalogue test fails. - Use the shared helpers
apiSuccess/apiErrorfrom_shared/response.ts. Rawnew Responseis justified only for CORS preflight (204, no body), an idempotency replay, or a201needing custom headers — and even then the body must still be the envelope.
⚠️ Testing gap to be aware of
The Deno tests undersupabase/functions/tests/ (activities.test.ts, auth.test.ts,
leads.test.ts, onboard.test.ts, …) are not executed by CI. ci.yml runs vitest
(which excludes supabase/**) and pgTAP SQL tests — nothing runs deno test.
That is why the tests live in the vitest suite (src/test/api/) and why the
405 matching logic was extracted into _shared/route-match.ts — a dependency-free
module that can be imported outside Deno. Wiring the Deno suite into CI is worth its
own ticket.