Error handling
How errors are created, passed, mapped to HTTP responses and logged, from the domain layer to the client. The contract is ADR-0018; the code is apistock.dev/httpx (problem.go) and apistock.dev/modules/openapi (errors.go).
The rules#
- Errors are values with meaning in the layer that creates them. A domain returns
ErrProjectNotFound, never404. A repository returnsErrProjectNameTaken, never a pgx error. - Driver and library errors are wrapped with
%v, not%w, so callers can't depend on pgx or provider types:fmt.Errorf("insert project: %v", err). - Only the app maps errors to HTTP, through one
httpx.Mapperbuilt ininternal/app/routes.go. Modules register their own mappings ininternal/app/module_<name>.go. - Errors are logged once, at the edge. Lower layers return errors without logging them. The mapper logs only what it can't map (500s); expected errors (404, 409, 422) aren't logged as errors, and the access log line already records the status.
- Clients get stable codes, never internal messages.
codeis public API (ADR-0015);titleanddetailmay change.
The response: problem details#
Every error response is application/problem+json (RFC 9457), extended with code and request_id:
{
"title": "Conflict",
"status": 409,
"code": "project_name_taken",
"detail": "you already have a project with this name",
"request_id": "req_9f86d081884c7d65"
}
| Field | Type | Always? | Meaning |
|---|---|---|---|
type |
string | No | URI of the problem type, when one exists |
title |
string | Yes | http.StatusText(status) |
status |
integer | Yes | The HTTP status, repeated |
code |
string | Yes | Stable snake_case code. Branch on this |
detail |
string | Usually | Human-readable explanation. Don't parse it |
request_id |
string | Yes | The request's X-Request-ID; the same ID is in the logs and the trace |
errors |
array | Validation only | [{"location": "body.name", "message": "expected length >= 1"}]. Never echoes the submitted value, which may be a password |
Responses also set Cache-Control: no-store.
How an error becomes a response#
repository postgres.UniqueViolation(err) → return domain.ErrProjectNameTaken
│
usecase return err (unchanged, or wrapped with %w to add context)
│
delivery return nil, err
│
Huma huma.NewErrorWithContext → openapi.InstallErrors → mapper
│
httpx.Mapper.Match
├─ err is or wraps *httpx.Problem → use it
├─ errors.Is(err, mapping.Err) → NewProblem(mapping.Status, mapping.Code, mapping.Detail or err.Error())
└─ no match → log "unhandled error" with request_id; 500 internal_error
│
httpx.WriteProblem → Content-Type: application/problem+json, Cache-Control: no-store, request_id filled
openapi.InstallErrors(mapper) replaces Huma's huma.NewError and huma.NewErrorWithContext, so Huma's own errors (validation, malformed JSON, unsupported media type) and handler errors all go through the same mapper and come out in the same shape. Outside Huma, middleware writes problems directly with httpx.WriteProblem, and the catch-all route answers 404 not_found.
Mappings#
A mapping connects a sentinel error to a status and code:
// internal/app/module_projects.go
mapper.Add(
httpx.Mapping{Err: projectsdomain.ErrProjectNotFound, Status: http.StatusNotFound, Code: "project_not_found", Detail: "no project of yours has this ID"},
httpx.Mapping{Err: projectsdomain.ErrProjectNameTaken, Status: http.StatusConflict, Code: "project_name_taken", Detail: "you already have a project with this name"},
httpx.Mapping{Err: projectsdomain.ErrProjectVersionConflict, Status: http.StatusConflict, Code: "project_version_conflict", Detail: "the project changed since you read it; get it again and retry"},
)
Mapper.Add validates each mapping when the app starts, so mistakes fail at boot rather than in production:
| Check | Error |
|---|---|
Err is nil |
httpx: mapping error must not be nil |
| Status outside 400–599 | httpx: mapping "x": status 200 is not an error status |
Code not snake_case |
httpx: mapping code "X" must be snake_case |
| The same error mapped twice | httpx: duplicate mapping for "x" |
| One code with two statuses | httpx: code "x" is mapped with statuses 404 and 409 |
Several errors may share a code with the same status, such as unauthenticated from different modules. Matching uses errors.Is, so wrapped errors (fmt.Errorf("create: %w", ErrProjectNameTaken)) still match. Mappings are checked in registration order.
When Detail is empty, the error's own message is used, so write domain error messages for API clients: lowercase, no internal names.
Codes without a mapping#
When Huma or middleware produces a status with no specific code, httpx.DefaultCode(status) names it:
| Status | Code |
|---|---|
| 400 | bad_request |
| 401 | unauthorized |
| 403 | forbidden |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict |
| 413 | request_too_large |
| 422 | validation_failed |
| 429 | rate_limited |
| 503 | unavailable |
| other 5xx | internal_error |
| other 4xx | error |
Codes in a Full app#
| Where | Codes | Reference |
|---|---|---|
| Middleware | cross_origin_request_denied (403), request_too_large (413), auth_unavailable (503), rate_limited (429), internal_error (500 from a panic), not_found (404, no route) |
Life of a request |
Pagination (apistock.dev/page) |
invalid_cursor, invalid_sort, invalid_limit (400) |
routes.go |
| Authentication | unauthenticated, forbidden, mfa_required, mfa_unavailable, passkeys_unavailable, and each flow's codes |
Authentication |
| Ops APIs | setting_not_found, setting_version_conflict, job_definition_disabled, invalid_recipient, … |
Ops API |
| Resources | <resource>_not_found, <resource>_<field>_taken, <resource>_version_conflict |
internal/app/module_<name>.go |
Adding an error#
-
Declare the sentinel in the module's
domain/errors.go:govar ErrInvoicePaid = errors.New("a paid invoice can't be changed") -
Return it from the domain or use case.
-
Map it in
internal/app/module_invoices.go:gohttpx.Mapping{Err: invoicesdomain.ErrInvoicePaid, Status: http.StatusConflict, Code: "invoice_paid"}, -
Document the response on the Huma operation (
Errors: []int{http.StatusConflict}) so it appears in/docs, then exportapi/openapi.json. -
Test the status and code in
internal/app/<names>_test.go.
For errors that carry data, such as a retry delay, define a type implementing error and match it with errors.As in the use case, or return an *httpx.Problem built with httpx.NewProblem from delivery code.
Logging#
- Unmapped errors:
level=ERROR msg="unhandled error" err=… request_id=…, once, by the mapper. - Panics:
level=ERROR msg="panic recovered" panic=… stack=… request_id=…, byhttpx.Recover. - Every request:
level=INFO msg="http request" … status=… request_id=…, byhttpx.AccessLog. - Background jobs: failed attempts are recorded on the job (visible in
GET /ops/jobs/runs/{id}) and retried;mail.ErrRejectedand other permanent errors cancel the job instead of retrying (background jobs). - Never logged: passwords, tokens, codes, secrets (
config.Secretprints[redacted]), email addresses, request bodies.
Startup errors#
Configuration errors don't reach HTTP: LoadConfig collects every problem and cmd/api exits with invalid configuration: and one line per variable. Construction errors (app.New: database connection, invalid mappings, invalid keys) exit with the wrapped cause, such as postgres: connect: …. See Troubleshooting.