net/http: Handlers, Middleware, JSON, and Graceful Shutdown
Learning Objectives
By the end of this lesson you can:
- Implement typed JSON handlers with the standard library only, and know where the write order matters.
- Compose middleware as
func(http.Handler) http.Handlerand reason about chain order. - Cap request bodies with
http.MaxBytesReaderand map overflow to a clean413. - Apply server-level timeouts and per-request deadlines so a slow client cannot hold a goroutine forever.
- Shut down with
srv.Shutdownso in-flight requests finish instead of being cut mid-write.
Quick Review
In lesson 2.1 you built a skeleton where cmd/api owns construction and shutdown, while internal/ holds the implementation. Dependencies arrive as explicit arguments, not package-level globals. This lesson fills in the thing that skeleton serves: http.Handler values, wired the same explicit way (dependencies go into a struct, the struct owns methods that satisfy http.HandlerFunc).
Theory
Handlers are just an interface
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Everything else is sugar. http.HandlerFunc adapts a plain function to that interface. A method on a struct holding your dependencies is the idiomatic way to inject services:
type API struct {
store OrderStore
log *slog.Logger
}
func (a *API) handleCreateOrder(w http.ResponseWriter, r *http.Request) { /* uses a.store */ }
If you are on Go 1.22 or newer, ServeMux understands method and wildcard patterns: mux.Handle("POST /v1/orders", ...) and mux.HandleFunc("GET /v1/orders/{id}", ...) with r.PathValue("id"). That removes most hand-rolled routing and path-splitting.
Middleware is a handler decorator
The canonical shape is func(http.Handler) http.Handler. It receives the next handler, returns a new one, and can act before and after the call.
flowchart LR
Req["๐ฅ<br/>Request"] --> Rec["๐ก๏ธ<br/>recover"]
Rec --> Log["๐<br/>logging"]
Log --> Mux["๐งญ<br/>ServeMux"]
Mux --> H["โ๏ธ<br/>handler"]
H --> Log
Log --> Rec
Rec --> Res["๐ค<br/>Response"]
Read the chain outside-in: withRecover(withLogging(mux)) means logging runs inside recover, so a panic that logging never sees still produces a 500. Ordering is a design decision, not a detail. Recovery outermost, request ID early, auth after logging (you want the 401 in the log), and anything that needs a deadline close to the handler.
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("method=%s path=%s status=%d dur=%s",
r.Method, r.URL.Path, rec.status, time.Since(start))
})
}
statusRecorder exists because http.ResponseWriter does not expose the status you wrote. You have to capture it in WriteHeader.
JSON: three rules that cover most bugs
- Set
Content-Type, thenWriteHeader(status), then encode. Once the status line is flushed, laterHeader().Setcalls are silently ignored. - Encode, never marshal-then-write, for responses. On the decode side, use
json.NewDecoder(r.Body)and calldec.DisallowUnknownFields()so typos in client payloads fail loudly instead of being dropped. - Pick one error envelope and use it everywhere:
{"error": {"code": "invalid_payload", "message": "..."}}. Clients should never have to parse two shapes.
Cap the body, cap the time
http.MaxBytesReader wraps the body with a hard limit. When the limit is exceeded the read returns a *http.MaxBytesError, and you map that to 413. Without it, a client can stream gigabytes into your decoder.
On the server side, set all four timeouts. ReadHeaderTimeout is the one people skip and the one that stops slowloris clients from pinning connections open:
srv := &http.Server{
Addr: ":8080",
Handler: withRecover(withLogging(mux)),
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
Read this carefully: these are connection deadlines. When WriteTimeout fires, writes fail, but the handler goroutine keeps running. Only a context deadline actually stops work, which is why r.Context() should be passed into every database call and downstream request.
[!WARNING]
http.TimeoutHandleris convenient but it buffers the entire response before sending, so it breaks streaming and long polls. Prefer a context deadline derived fromr.Context()for those, and keepTimeoutHandlerfor small JSON endpoints.
Graceful shutdown
1. Create a context canceled by SIGINT/SIGTERM with `signal.NotifyContext`.
2. Start `ListenAndServe` in a goroutine and send only real errors to a buffered channel.
3. Block on `select` over that error channel and `ctx.Done()`.
4. On signal, call `srv.Shutdown` with a *fresh* context that has a timeout (10s is a good default).
5. Treat `http.ErrServerClosed` from `ListenAndServe` as success, not failure.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
log.Fatalf("serve: %v", err)
case <-ctx.Done():
stop() // a second Ctrl+C now kills the process immediately
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
Shutdown stops accepting new connections, closes idle ones, and waits for active requests. It does not wait for goroutines your handler spawned and forgot. That is the same errgroup discipline from module 1: track them, wait for them, then exit.
Worked Examples
1. Decode with a cap and a typed error
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
In the handler, errors.As(err, new(*http.MaxBytesError)) selects the 413 branch, and everything else becomes 400 with your error envelope.
2. Write helper used by every endpoint
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
Call it exactly once per request. A second call panics with "superfluous WriteHeader" and corrupts the body.
Discussion & Reflection
- Your logging middleware sits inside recovery. What information do you lose on a panic, and where would you move it?
Shutdownreturnscontext.DeadlineExceededon a slow request. What should the process do next, and what did the client see?- Which of your current handlers pass
r.Context()into every blocking call, and which ones quietly usecontext.Background()?
Expert Corner
- Never wire the signal context into
Server.BaseContext. Canceling it on SIGTERM kills in-flight request contexts instantly, which is the opposite of graceful. - Set headers before
WriteHeader. This is the single most common JSON bug in code review. TimeoutHandlerandhttp.Flusher/Hijackerdo not mix. Streaming and WebSocket endpoints need the raw writer.- Read the body once. If you need it twice (signature check plus decode), buffer with
io.ReadAllunder the sameMaxBytesReader. - Log status codes through a recorder that also implements
FlushandHijackwhen you need streaming. A bare wrapper silently disables both. - After
Shutdownreturns, close your database pool. Order: HTTP first, dependencies second.
Quick check on the key terms:
The handler interface requires a single method, {{c1::ServeHTTP}}, and http.HandlerFunc adapts a plain function to it. Middleware has the signature func(http.Handler) {{c2::http.Handler}}, which is why chains compose in either direction. When a body exceeds http.MaxBytesReader, decode returns a {{c3::*http.MaxBytesError}} that should become a 413. ListenAndServe reports {{c4::http.ErrServerClosed}} after a graceful shutdown, so treat it as success. Go 1.22 wildcards are read back with r.{{c5::PathValue}}("id"). And the timeout that stops slowloris attacks is {{c6::ReadHeaderTimeout}}.
Summary & Next Steps
Handlers are an interface, middleware is function composition, and both are invisible in behavior until you get the ordering and the write sequence right. Cap the body, set all four server timeouts, pass r.Context() everywhere, and shut down with a fresh deadline so in-flight requests finish. ==Graceful shutdown is a context problem, not a signal problem.==
Next up: you take this skeleton and put Postgres behind it, using database/sql and integration tests against real queries. Everything you wired here, the handler struct, the context propagation, the error envelope, becomes the seam those tests press on.
[
{ "front": "What method must a type implement to be an http.Handler?", "back": "ServeHTTP(http.ResponseWriter, *http.Request)." },
{ "front": "What does http.HandlerFunc do?", "back": "Adapts an ordinary function with the ServeHTTP signature into an http.Handler." },
{ "front": "Canonical middleware signature?", "back": "func(http.Handler) http.Handler." },
{ "front": "In withRecover(withLogging(mux)), which runs first on a request?", "back": "withRecover, the outermost wrapper, so logging runs inside it." },
{ "front": "Why must Content-Type be set before WriteHeader?", "back": "Headers are flushed with the status line; later Set calls are ignored." },
{ "front": "What error does http.MaxBytesReader return on overflow, and which status fits?", "back": "*http.MaxBytesError, mapped to 413 Request Entity Too Large." },
{ "front": "What does json.Decoder.DisallowUnknownFields change?", "back": "Unknown JSON fields become a decode error instead of being ignored." },
{ "front": "Which sentinel error does ListenAndServe return after Shutdown?", "back": "http.ErrServerClosed, which should be treated as success." },
{ "front": "Shutdown vs Close on http.Server?", "back": "Shutdown waits for in-flight requests; Close drops connections immediately." },
{ "front": "Which four http.Server timeouts should you set?", "back": "ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout." },
{ "front": "Do server connection timeouts cancel the handler goroutine?", "back": "No, writes fail but the handler keeps running; only a context deadline stops work." },
{ "front": "Why avoid http.TimeoutHandler for streaming endpoints?", "back": "It buffers the whole response before writing it out." },
{ "front": "What does srv.Shutdown return when in-flight work outlasts its context?", "back": "The context error, typically context.DeadlineExceeded." }
]