Error handling for Go HTTP servers — structured error responses, error wrapping,
88
81%
Does it follow best practices?
Impact
99%
1.80xAverage score across 5 eval scenarios
Passed
No known issues
Production-grade error handling that belongs in every Go HTTP server from day one. When building any Go API -- whether a quick prototype or a production service -- always include these patterns without being asked.
Error handling is not a "harden later" task. Without it from the start:
http.Error calls return plain text. Some handlers return {"error": "..."}, others return {"message": "..."}, others return bare strings. Mobile clients cannot parse errors reliably.json.NewEncoder(w).Encode(data)) hides failures that corrupt responses. Wrapping with fmt.Errorf without the %w verb destroys the error chain so errors.Is and errors.As stop working.SIGTERM from Kubernetes kills in-flight requests instantly.These are not edge cases. They are the first things that break in production.
Define a structured error type and common sentinel errors. Use the Unwrap method so errors.Is and errors.As work through the error chain:
// errors.go
package main
import (
"errors"
"fmt"
)
// Sentinel errors for errors.Is checks
var (
ErrNotFound = errors.New("not found")
ErrValidation = errors.New("validation error")
ErrConflict = errors.New("conflict")
ErrInternal = errors.New("internal error")
)
// AppError carries structured information for HTTP error responses.
type AppError struct {
Code string `json:"code"`
Message string `json:"message"`
StatusCode int `json:"-"`
Err error `json:"-"`
Details []FieldError `json:"details,omitempty"`
}
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error { return e.Err }
func NewNotFound(resource, id string) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s %s not found", resource, id),
StatusCode: 404,
Err: ErrNotFound,
}
}
func NewValidation(msg string, details ...FieldError) *AppError {
return &AppError{
Code: "VALIDATION_ERROR",
Message: msg,
StatusCode: 400,
Err: ErrValidation,
Details: details,
}
}
func NewConflict(msg string) *AppError {
return &AppError{
Code: "CONFLICT",
Message: msg,
StatusCode: 409,
Err: ErrConflict,
}
}
func NewInternal(err error) *AppError {
return &AppError{
Code: "INTERNAL_ERROR",
Message: "An unexpected error occurred",
StatusCode: 500,
Err: fmt.Errorf("%w: %w", ErrInternal, err),
}
}if errors.Is(err, ErrNotFound) {
writeError(w, NewNotFound("Order", id))
return
}
var appErr *AppError
if errors.As(err, &appErr) {
writeError(w, appErr)
return
}// WRONG -- this fails if err was wrapped with fmt.Errorf("%w", ...)
if e, ok := err.(*AppError); ok {
writeError(w, e)
}A single function that formats every error response consistently and logs internal errors without leaking them:
// response.go
package main
import (
"encoding/json"
"log/slog"
"net/http"
)
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Details []FieldError `json:"details,omitempty"`
}
func writeError(w http.ResponseWriter, appErr *AppError) {
if appErr.StatusCode >= 500 {
slog.Error("internal error",
"code", appErr.Code,
"err", appErr.Err,
)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.StatusCode)
json.NewEncoder(w).Encode(ErrorResponse{
Error: ErrorBody{
Code: appErr.Code,
Message: appErr.Message,
Details: appErr.Details,
},
})
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{"data": data}); err != nil {
slog.Error("failed to write response", "err", err)
}
}// WRONG -- plain text, inconsistent, leaks internal info
http.Error(w, err.Error(), http.StatusInternalServerError)
// WRONG -- every handler invents its own format
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "bad request"})Always use the %w verb to preserve the error chain. Add context about what operation failed:
func (s *Store) GetOrder(id string) (*Order, error) {
order, err := s.db.Query("SELECT ... WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("get order %s: %w", id, err)
}
return order, nil
}// WRONG -- errors.Is and errors.As will not work through this
return nil, fmt.Errorf("get order %s: %v", id, err)
// WRONG -- swallowing the error entirely
if err != nil {
return nil, errors.New("failed to get order")
}Catch panics and convert them to 500 responses instead of crashing the server:
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"method", r.Method,
"path", r.URL.Path,
"panic", rec,
)
writeError(w, NewInternal(fmt.Errorf("panic: %v", rec)))
}
}()
next.ServeHTTP(w, r)
})
}Register it as the outermost middleware so it catches panics from all handlers and other middleware.
func main() {
mux := http.NewServeMux()
// ... register routes ...
srv := &http.Server{
Addr: ":8080",
Handler: recoveryMiddleware(mux),
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
slog.Info("shutting down server")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("forced shutdown", "err", err)
os.Exit(1)
}
slog.Info("server stopped")
}All errors must follow this shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body"
}
}For validation errors with field-level detail:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "name", "message": "is required" }
]
}
}pq: duplicate key value violates unique constraint)http.Error calls -- always use the structured writeError helperfunc createUser(w http.ResponseWriter, r *http.Request) {
// ... decode and validate ...
user, err := store.CreateUser(req)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") ||
strings.Contains(err.Error(), "UNIQUE constraint") {
writeError(w, NewConflict("A user with this email already exists"))
return
}
writeError(w, NewInternal(err))
return
}
writeJSON(w, http.StatusCreated, user)
}func createOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, NewValidation("Invalid JSON body"))
return
}
var fieldErrors []FieldError
if req.CustomerName == "" {
fieldErrors = append(fieldErrors, FieldError{
Field: "customer_name", Message: "is required",
})
}
if len(req.Items) == 0 {
fieldErrors = append(fieldErrors, FieldError{
Field: "items", Message: "must be a non-empty array",
})
}
if len(fieldErrors) > 0 {
writeError(w, NewValidation("Invalid request body", fieldErrors...))
return
}
// ... proceed ...
}func getWeather(w http.ResponseWriter, r *http.Request) {
data, err := weatherClient.Fetch(r.Context(), city)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, &AppError{
Code: "UPSTREAM_TIMEOUT", Message: "Weather service timed out",
StatusCode: 504, Err: err,
})
return
}
writeError(w, NewInternal(err))
return
}
writeJSON(w, http.StatusOK, data)
}// WRONG -- if Encode fails, the client gets a truncated/empty response
json.NewEncoder(w).Encode(data)
// WRONG -- log.Fatal in a handler kills the entire server
if err != nil {
log.Fatal(err)
}if err := json.NewEncoder(w).Encode(data); err != nil {
slog.Error("failed to encode response", "err", err)
}Every Go HTTP server must have from the start:
AppError) with code, message, and HTTP statuserrors.Is checks (ErrNotFound, ErrValidation, etc.)Unwrap() method on custom error types for errors.As compatibilitywriteError helper for consistent JSON error responsesfmt.Errorf and %w verb to preserve error chainshttp.Error calls -- use structured errors everywhere