CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/go-error-handling

Error handling for Go HTTP servers — structured error responses, error wrapping,

88

1.80x
Quality

81%

Does it follow best practices?

Impact

99%

1.80x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
go-error-handling
description:
Production error handling patterns that must be included from day one in any Go HTTP server. Apply whenever building, extending, or scaffolding a Go backend -- even if the task only asks for routes or CRUD endpoints. These patterns prevent panic crashes, inconsistent error formats, swallowed errors, and dropped connections on deploy. Works with net/http, Chi, Gin, and Echo.
keywords:
go error handling, golang errors, http error, error wrapping, recovery middleware, graceful shutdown, structured errors, go api, go web server, go http server, go rest api, go crud, go backend, go chi, go gin, go echo, go net/http, go middleware, go routes, errors.Is, errors.As, fmt.Errorf, sentinel errors, panic recovery
license:
MIT

Go Error Handling

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.


Why This Matters From Day One

Error handling is not a "harden later" task. Without it from the start:

  • Panics kill your server -- A single nil pointer dereference in a handler crashes the entire process. Without recovery middleware, one bad request takes down all active connections.
  • Clients get garbage -- Raw http.Error calls return plain text. Some handlers return {"error": "..."}, others return {"message": "..."}, others return bare strings. Mobile clients cannot parse errors reliably.
  • Errors vanish silently -- Calling a function that returns an error and ignoring it (e.g., 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.
  • Deploys drop connections -- Without graceful shutdown, a SIGTERM from Kubernetes kills in-flight requests instantly.

These are not edge cases. They are the first things that break in production.


The Patterns

1. Custom Error Types with Sentinel Errors

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),
    }
}

RIGHT: Use errors.Is and errors.As to inspect wrapped errors

if errors.Is(err, ErrNotFound) {
    writeError(w, NewNotFound("Order", id))
    return
}

var appErr *AppError
if errors.As(err, &appErr) {
    writeError(w, appErr)
    return
}

WRONG: Type-assert directly -- breaks when errors are wrapped

// WRONG -- this fails if err was wrapped with fmt.Errorf("%w", ...)
if e, ok := err.(*AppError); ok {
    writeError(w, e)
}

2. Error Response Helper

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: Bare http.Error calls with inconsistent formats

// 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"})

3. Error Wrapping with fmt.Errorf

Always use the %w verb to preserve the error chain. Add context about what operation failed:

RIGHT: Wrap with %w to preserve the error chain

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: Using %v destroys the error chain

// 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")
}

4. Recovery Middleware

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.

5. Graceful Shutdown

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")
}

Error Response Format

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" }
    ]
  }
}

Never:

  • Return raw error messages from libraries (pq: duplicate key value violates unique constraint)
  • Return stack traces or internal file paths in production
  • Return different JSON shapes from different handlers
  • Return errors as 200 responses
  • Use bare http.Error calls -- always use the structured writeError helper

Common Patterns

Handling Database Constraint Errors

func 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)
}

Validation with Field Details

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 ...
}

Handling External Service Errors

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)
}

Never Swallow Errors

WRONG: Ignoring error return values

// 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)
}

RIGHT: Handle every error return value

if err := json.NewEncoder(w).Encode(data); err != nil {
    slog.Error("failed to encode response", "err", err)
}

Checklist

Every Go HTTP server must have from the start:

  • Custom error type (AppError) with code, message, and HTTP status
  • Sentinel errors for errors.Is checks (ErrNotFound, ErrValidation, etc.)
  • Unwrap() method on custom error types for errors.As compatibility
  • writeError helper for consistent JSON error responses
  • Error wrapping with fmt.Errorf and %w verb to preserve error chains
  • Recovery middleware to catch panics and return structured 500s
  • Graceful shutdown with signal handling (SIGTERM/SIGINT)
  • No raw http.Error calls -- use structured errors everywhere
  • Internal errors logged with context, never leaked to clients
  • Validation errors include field-level details
  • Appropriate HTTP status codes: 400 validation, 404 not found, 409 conflict, 500 internal
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/go-error-handling badge