Framework Cookbooks

Go HTTP Error Handling Patterns

Idiomatic Go patterns for HTTP error handling: from net/http fundamentals to structured error types, middleware chains, context cancellation, and testing.

Go's net/http Error Philosophy

Go's standard library takes an explicit, no-magic approach to errors. There are no exceptions — errors are values returned from functions. HTTP handlers write their own responses, which means there is no automatic error-to-response mapping.

This explicitness is a feature: you always know exactly what status code a handler returns and when. The cost is more boilerplate, which middleware and helper functions can reduce.

http.Error() and Custom Writers

http.Error is the simplest way to send an error response:

package main

import (
    "encoding/json"
    "net/http"
)

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // Go 1.22+
    user, err := db.FindUser(id)
    if err != nil {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(user)
}

For JSON APIs, replace http.Error with a helper that sets the correct Content-Type and encodes a structured body:

type ErrorResponse struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}

func writeError(w http.ResponseWriter, code string, message string, status int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(ErrorResponse{Code: code, Message: message})
}

// Usage:
writeError(w, "not_found", "User not found", http.StatusNotFound)

Critical rule: call w.WriteHeader(status) before writing the body. Any call to w.Write() without a prior WriteHeader implicitly sends 200 OK.

Middleware Error Chains

Go's http.Handler interface enables clean middleware composition. A common pattern is to use a custom handler type that returns an error:

type AppHandler func(http.ResponseWriter, *http.Request) error

func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        handleError(w, r, err)
    }
}

func handleError(w http.ResponseWriter, r *http.Request, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        writeError(w, appErr.Code, appErr.Message, appErr.Status)
        return
    }
    // Unexpected error — log and return 500
    slog.Error("unhandled error", "err", err, "path", r.URL.Path)
    writeError(w, "server_error", "Internal server error", http.StatusInternalServerError)
}

// Register handlers:
mux.Handle("GET /users/{id}", AppHandler(getUser))

Structured Error Types

Define an error type that carries HTTP metadata:

type AppError struct {
    Status  int
    Code    string
    Message string
    Err     error  // Wrapped cause for logging
}

func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error  { return e.Err }

// Constructors:
func NotFound(msg string) *AppError {
    return &AppError{Status: 404, Code: "not_found", Message: msg}
}

func Unauthorized(msg string) *AppError {
    return &AppError{Status: 401, Code: "unauthorized", Message: msg}
}

func InternalError(err error) *AppError {
    return &AppError{
        Status: 500, Code: "server_error",
        Message: "Internal server error", Err: err,
    }
}

Use errors.As to unwrap the type in your error handler without type assertions.

Context Cancellation and Timeouts

When the client disconnects, r.Context() is cancelled. Check this before expensive operations:

func expensiveHandler(w http.ResponseWriter, r *http.Request) error {
    ctx := r.Context()

    result, err := db.SlowQuery(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // Client disconnected — return 499 (nginx convention) or just log
            slog.Info("client disconnected", "path", r.URL.Path)
            return nil  // No point sending a response
        }
        if errors.Is(err, context.DeadlineExceeded) {
            return &AppError{Status: 504, Code: "timeout", Message: "Gateway timeout"}
        }
        return InternalError(err)
    }
    return json.NewEncoder(w).Encode(result)
}

Testing Error Responses

Use net/http/httptest to test handlers without a live server:

func TestGetUser_NotFound(t *testing.T) {
    req := httptest.NewRequest("GET", "/users/999", nil)
    w := httptest.NewRecorder()

    AppHandler(getUser).ServeHTTP(w, req)

    if w.Code != http.StatusNotFound {
        t.Errorf("expected 404, got %d", w.Code)
    }

    var body ErrorResponse
    json.NewDecoder(w.Body).Decode(&body)
    if body.Code != "not_found" {
        t.Errorf("unexpected code: %s", body.Code)
    }
}

Table-driven tests work well for testing multiple error scenarios across a single handler, keeping test code concise and exhaustive:

func TestGetUser(t *testing.T) {
    tests := []struct {
        name       string
        userID     string
        wantStatus int
        wantCode   string
    }{
        {"existing user", "1", http.StatusOK, ""},
        {"missing user", "999", http.StatusNotFound, "not_found"},
        {"invalid id", "abc", http.StatusBadRequest, "bad_request"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", "/users/"+tt.userID, nil)
            w := httptest.NewRecorder()
            AppHandler(getUser).ServeHTTP(w, req)

            if w.Code != tt.wantStatus {
                t.Errorf("status: got %d, want %d", w.Code, tt.wantStatus)
            }
            if tt.wantCode != "" {
                var body ErrorResponse
                json.NewDecoder(w.Body).Decode(&body)
                if body.Code != tt.wantCode {
                    t.Errorf("code: got %s, want %s", body.Code, tt.wantCode)
                }
            }
        })
    }
}

Summary of Go HTTP error handling best practices:

  • Return typed *AppError values from handler functions
  • Call w.WriteHeader(status) before w.Write() to avoid accidental 200 OK
  • Use errors.As to unwrap error types in centralized handlers
  • Check context.Canceled before returning an error response — the client is gone
  • Prefer slog (Go 1.21+) over log for structured, key-value log output

İlgili Protokoller

İlgili Sözlük Terimleri

Daha fazlası Framework Cookbooks