Errors as JSON

Give every failed request one JSON body shape, mapped from domain errors, with internals kept in your logs.

Syntaxfunc writeError(w http.ResponseWriter, err error) // status and body decided in one place

When 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

Example · go
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.

Example · go
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.

Example · go
// 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.

Example · go
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

  • Be the first to comment on this lesson.