httputil depends on xerrors (Tier 0) and valid (Tier 1), placing it at Tier 2. No infrastructure or lifecycle dependencies exist in this module.
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.
|