61 lines
2.7 KiB
Markdown
61 lines
2.7 KiB
Markdown
|
|
# ADR-002: xerrors.Code to HTTP Status Mapping
|
||
|
|
|
||
|
|
**Status:** Accepted
|
||
|
|
**Date:** 2026-03-18
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
HTTP handlers must translate application errors into appropriate HTTP status codes.
|
||
|
|
Without a shared mapping, each handler or controller does its own ad-hoc conversion,
|
||
|
|
leading to inconsistent status codes across endpoints (e.g. one handler returning 500
|
||
|
|
for a not-found, another returning 404).
|
||
|
|
|
||
|
|
`xerrors` defines stable `Code` constants aligned with gRPC canonical status names.
|
||
|
|
The transport layer is responsible for translating those codes to HTTP — this was
|
||
|
|
an explicit decision in `xerrors` ADR-001.
|
||
|
|
|
||
|
|
## Decision
|
||
|
|
|
||
|
|
`Error(w http.ResponseWriter, err error)` is the single entry point for writing
|
||
|
|
error responses. It uses `errors.As` to unwrap `*xerrors.Err` and reads the `Code`
|
||
|
|
field. `errorCodeToStatus` maps each code to an HTTP status:
|
||
|
|
|
||
|
|
| xerrors.Code | HTTP Status |
|
||
|
|
|---|---|
|
||
|
|
| `ErrInvalidInput` | 400 Bad Request |
|
||
|
|
| `ErrUnauthorized` | 401 Unauthorized |
|
||
|
|
| `ErrPermissionDenied` | 403 Forbidden |
|
||
|
|
| `ErrNotFound` | 404 Not Found |
|
||
|
|
| `ErrAlreadyExists` | 409 Conflict |
|
||
|
|
| `ErrGone` | 410 Gone |
|
||
|
|
| `ErrPreconditionFailed` | 412 Precondition Failed |
|
||
|
|
| `ErrRateLimited` | 429 Too Many Requests |
|
||
|
|
| `ErrInternal` | 500 Internal Server Error |
|
||
|
|
| `ErrNotImplemented` | 501 Not Implemented |
|
||
|
|
| `ErrUnavailable` | 503 Service Unavailable |
|
||
|
|
| `ErrDeadlineExceeded` | 504 Gateway Timeout |
|
||
|
|
| (unknown / nil) | 500 Internal Server Error |
|
||
|
|
|
||
|
|
The JSON response body always has the shape `{"code": "...", "message": "..."}`.
|
||
|
|
If `*xerrors.Err` carries context fields (from `WithContext`), those fields are
|
||
|
|
merged into the top-level response body alongside `code` and `message`.
|
||
|
|
|
||
|
|
Errors that do not unwrap to `*xerrors.Err` (plain `errors.New`, third-party errors)
|
||
|
|
are mapped to 500 with a generic `"INTERNAL"` code and `"internal server error"`
|
||
|
|
message. The original error is not exposed to the caller.
|
||
|
|
|
||
|
|
## Consequences
|
||
|
|
|
||
|
|
- All endpoints in a service share the same error shape and status mapping. A
|
||
|
|
`NOT_FOUND` error always becomes 404, regardless of which handler returns it.
|
||
|
|
- Business logic selects the appropriate `xerrors.Code`; the transport layer picks
|
||
|
|
the status code. Neither layer needs to know about the other's mapping.
|
||
|
|
- The `code` field in the JSON response is the stable `xerrors.Code` string value
|
||
|
|
(e.g. `"NOT_FOUND"`), not an HTTP status integer. Clients can switch on the code
|
||
|
|
string to distinguish cases within the same status class.
|
||
|
|
- Context fields from `xerrors.Err.WithContext` are promoted to top-level JSON
|
||
|
|
fields. This means field names must not collide with `"code"` or `"message"`.
|
||
|
|
This is a caller responsibility, not enforced by the package.
|
||
|
|
- Unknown or nil errors produce a 500 with no internal detail leaked — defensive
|
||
|
|
by default.
|