Errors as JSON
Give every failed request one JSON body shape, mapped from domain errors, with internals kept in your logs.
func writeError(w http.ResponseWriter, err error) // status and body decided in one placeWhen a request fails, the client needs two things: a status code, and a body it can act on. If every failure uses the same JSON shape, client code has one parser and one place to branch. If each handler invents its own, every client grows a pile of special cases.
One shape
A small struct is enough: a short machine-readable error code, a human-readable message, and optionally per-field detail. Codes like not_found are stable and safe to branch on. Messages are for people and may be reworded at any time, so clients must not match on them.
Mapping from domain errors
The store and service return domain errors — plain Go errors about notes, not about HTTP. The service may add context with fmt.Errorf("find note %s: %w", id, err), where %w wraps the original so it can still be recognised. The handler layer is the only place that knows status codes. It asks errors.Is(err, ErrNotFound) for sentinel errors and errors.As(err, &target) for errors carrying fields, then picks the code.
Never leak internals
Database driver text, host names, file paths and SQL fragments tell an attacker about your system and tell your user nothing. Any error you did not map is a bug in your service: answer 500 with a fixed message, and write the full error, wrapping and all, to your logs where you can find it by request path.
Example
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
)
// The service returns these. It does not know what a status code is.
var (
ErrNotFound = errors.New("note not found")
ErrInvalid = errors.New("note is invalid")
)
// apiError is the one body shape every failed request gets.
type apiError struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, err error) {
// The default is deliberately vague: an unmapped error is our bug.
status, code, msg := http.StatusInternalServerError, "internal", "something went wrong"
switch {
case errors.Is(err, ErrNotFound):
status, code, msg = http.StatusNotFound, "not_found", "no note with that id"
case errors.Is(err, ErrInvalid):
status, code, msg = http.StatusUnprocessableEntity, "invalid", "text is required"
}
body, mErr := json.Marshal(apiError{Error: code, Message: msg})
if mErr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, wErr := w.Write(body); wErr != nil {
return
}
}
func main() {
rec := httptest.NewRecorder()
// errors.Is still matches through the wrapping added by %w.
writeError(rec, fmt.Errorf("find note 7: %w", ErrNotFound))
fmt.Printf("%d %s\n", rec.Code, rec.Body.String())
rec = httptest.NewRecorder()
// The driver's message stays out of the reply.
writeError(rec, errors.New("dial tcp 10.0.0.5:5432: connection refused"))
fmt.Printf("%d %s\n", rec.Code, rec.Body.String())
}
// Output:
// 404 {"error":"not_found","message":"no note with that id"}
// 500 {"error":"internal","message":"something went wrong"}When to use it
- Giving a mobile app one error body to parse instead of a different one per endpoint.
- Turning a store's ErrNotFound into 404 without the store importing net/http.
- Keeping Postgres connection strings and SQL text out of HTTP responses.
More examples
Field errors in the same shape
A form can show each message next to the input it belongs to, and the shape is still the same struct, so no client needs a second parser. encoding/json sorts map keys when it encodes, so the field order in the output is stable and testable.
type apiError struct {
Error string `json:"error"`
Message string `json:"message"`
// Fields maps a request field name to what is wrong with it. omitempty
// leaves the key out entirely when there is nothing to report.
Fields map[string]string `json:"fields,omitempty"`
}
func validationBody(reasons map[string]string) apiError {
return apiError{
Error: "invalid",
Message: "some fields are not acceptable",
Fields: reasons,
}
}
// Produces, with status 422:
// {"error":"invalid","message":"some fields are not acceptable",
// "fields":{"text":"must not be empty","title":"too long"}}errors.As for an error that carries data
Use errors.Is when you only need to know which error it is, and errors.As when you need something out of it. Both see through fmt.Errorf wrapping, so the service can add context to an error without the handler losing the ability to classify it.
// ValidationError is returned by the service, which knows the rules but not
// the HTTP status codes they deserve.
type ValidationError struct {
Field string
Reason string
}
// Error makes the type satisfy the error interface.
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
func statusFor(err error) int {
var ve *ValidationError
switch {
// errors.As unwraps until it finds a *ValidationError and assigns it to ve,
// so the field name is available to the reply.
case errors.As(err, &ve):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrConflict):
return http.StatusConflict
default:
return http.StatusInternalServerError
}
}Log the cause, send the shape
One funnel for failures means the 500s are logged exactly once, with the path that caused them, and the client contract stays the same. Log only 5xx at this level: logging every 404 fills your logs with other people's typos.
func (a api) fail(w http.ResponseWriter, r *http.Request, err error) {
status := statusFor(err)
if status >= 500 {
// log.Printf, from the standard library log package, writes one
// timestamped line to standard error. The full error, including every
// wrapped cause, goes here and nowhere near the client.
log.Printf("%s %s: %v", r.Method, r.URL.Path, err)
}
writeError(w, err)
}
// Client sees: {"error":"internal","message":"something went wrong"}
// Log holds: POST /notes: insert note: dial tcp 10.0.0.5:5432: connection refused
Discussion