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
*AppErrorvalues from handler functions - Call
w.WriteHeader(status)beforew.Write()to avoid accidental200 OK - Use
errors.Asto unwrap error types in centralized handlers - Check
context.Canceledbefore returning an error response — the client is gone - Prefer
slog(Go 1.21+) overlogfor structured, key-value log output