Handling Errors in Go HTTP Handlers
How to turn Go errors into the right HTTP status codes, keep internal details out of responses, and avoid repeating error handling in every handler.
An HTTP handler cannot return an error the way a normal function does. Its signature is fixed: func(w http.ResponseWriter, r *http.Request). So every failure inside it has to become a response: a status code and a message for the client. This page shows how to make that translation correctly and without repeating yourself in every handler.
The Basic Pattern With http.Error
http.Error writes a status code and a plain-text message, then you return so nothing else is written.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
)
func getItem(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "id must be a number", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "item %d\n", id)
}
func main() {
for _, url := range []string{"/item?id=7", "/item?id=abc"} {
rec := httptest.NewRecorder()
getItem(rec, httptest.NewRequest("GET", url, nil))
fmt.Printf("%s -> %d %s", url, rec.Code, rec.Body.String())
}
}
Output:
/item?id=7 -> 200 item 7
/item?id=abc -> 400 id must be a number
The httptest package calls the handler directly without starting a real server, which makes it easy to try handlers and to test them.
Forgetting return after http.Error:
http.Error does not stop the handler. Without return, the code continues and writes more output, and the server logs http: superfluous response.WriteHeader call. The client gets a mixed response like an error message followed by data.
Mapping Errors to Status Codes
Inside your application, the database or service layer returns normal Go errors. The handler's job is to decide which status code each one means. errors.Is and errors.As make this mapping reliable, even after several layers of %w.
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
)
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
)
func loadOrder(id string) (string, error) {
switch id {
case "1":
return "order 1: 2 books", nil
case "2":
return "", fmt.Errorf("load order %s: %w", id, ErrForbidden)
case "3":
return "", fmt.Errorf("load order %s: connection reset", id)
}
return "", fmt.Errorf("load order %s: %w", id, ErrNotFound)
}
func statusFor(err error) int {
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrForbidden):
return http.StatusForbidden
default:
return http.StatusInternalServerError
}
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
order, err := loadOrder(r.URL.Query().Get("id"))
if err != nil {
code := statusFor(err)
if code == http.StatusInternalServerError {
fmt.Println("log:", err) // full detail goes to logs only
http.Error(w, "internal error", code)
return
}
http.Error(w, http.StatusText(code), code)
return
}
fmt.Fprintln(w, order)
}
func main() {
for _, id := range []string{"1", "2", "3", "9"} {
rec := httptest.NewRecorder()
orderHandler(rec, httptest.NewRequest("GET", "/order?id="+id, nil))
fmt.Printf("id=%s -> %d %s", id, rec.Code, rec.Body.String())
}
}
Output:
id=1 -> 200 order 1: 2 books
id=2 -> 403 Forbidden
log: load order 3: connection reset
id=3 -> 500 internal error
id=9 -> 404 Not Found
Expected conditions get a clear status. Anything unexpected becomes a 500, and its full message only goes to the log.
Do not send raw errors to the client:
http.Error(w, err.Error(), 500) can expose SQL queries, file paths or internal host names to anyone who calls your API. Send a short generic message for unexpected errors and log the details on the server.
Removing Repetition With an Error-Returning Handler
Writing the same mapping and logging in every handler gets tiring. A common pattern is to write handlers that return an error, and wrap them once with an adapter that does the translation:
package main
import (
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
)
var ErrNotFound = errors.New("not found")
type BadRequestError struct{ Msg string }
func (e *BadRequestError) Error() string { return e.Msg }
type appHandler func(w http.ResponseWriter, r *http.Request) error
func (h appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
var bad *BadRequestError
switch {
case errors.As(err, &bad):
http.Error(w, bad.Msg, http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
default:
logger.Printf("%s %s: %v", r.Method, r.URL.Path, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
var logger = log.New(os.Stdout, "log: ", 0)
func profile(w http.ResponseWriter, r *http.Request) error {
name := r.URL.Query().Get("name")
if name == "" {
return &BadRequestError{Msg: "name is required"}
}
if name == "ghost" {
return fmt.Errorf("profile %q: %w", name, ErrNotFound)
}
if name == "broken" {
return errors.New("cache: connection refused")
}
fmt.Fprintf(w, "hello %s\n", name)
return nil
}
func main() {
mux := http.NewServeMux()
mux.Handle("GET /profile", appHandler(profile))
for _, q := range []string{"name=asha", "", "name=ghost", "name=broken"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/profile?"+q, nil))
fmt.Printf("%-12q -> %d %s", q, rec.Code, rec.Body.String())
}
}
Output:
"name=asha" -> 200 hello asha
"" -> 400 name is required
"name=ghost" -> 404 not found
log: GET /profile: cache: connection refused
"name=broken" -> 500 internal error
appHandler is a function type with a ServeHTTP method, so it satisfies http.Handler and can be registered on the mux. Each handler now reads like a normal Go function, returning errors, and all status mapping and logging live in one place.
Signs your handler error handling is solid:
Each handler only returns errors, one function decides status codes with errors.Is and errors.As, unexpected errors are logged once with the request path, and clients never see internal messages.
Sending JSON Error Responses
APIs usually return errors as JSON so clients can parse them. Set the content type before writing the status:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
func writeJSONError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func main() {
rec := httptest.NewRecorder()
writeJSONError(rec, http.StatusUnprocessableEntity, "email is not valid")
fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
fmt.Print(rec.Body.String())
}
Output:
422 application/json
{"error":"email is not valid"}
Header order matters:
Headers must be set before WriteHeader or the first Write. Anything set afterwards is ignored, because the headers have already been sent.
Handling the Server's Own Error
ListenAndServe returns an error when the server stops. After a planned shutdown with Shutdown, that error is http.ErrServerClosed, which is not a real failure:
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server failed: %v", err)
}
Without the errors.Is check, every clean shutdown would be logged as a crash. A port that is already taken shows up here as listen tcp :8080: bind: address already in use.
Summary
Handlers turn errors into responses: call http.Error or write JSON, then return. Map known errors to status codes with errors.Is and errors.As, send generic messages for unexpected ones, and log those details once on the server. An error-returning handler type with a single ServeHTTP adapter removes repetition, and http.ErrServerClosed should be treated as a normal shutdown.