HTTP Services & REST APIs
net/http, ServeMux patterns, middleware, timeouts, graceful shutdown
- 01
mux.Handle("/healthz", healthz)will not compile — *cannot use healthz (value of type func(http.ResponseWriter, http.Request)) as http.Handler — yetmux.HandleFunc("/healthz", healthz)compiles fine. What doeshttp.HandlerFuncadd?Easyhttp.HandlerFuncis a named function type that has aServeHTTPmethod, so converting your function to it gives the function the one methodhttp.Handlerrequires; a barefuncvalue has no methods and can never satisfy an interface.// net/http, verbatim in spirit: // type HandlerFunc func(ResponseWriter, *Request) // func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) } func healthz(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) } … - 02
You register
GET /items/{id}on Go 1.22'shttp.ServeMux, andPOST /items/42now returns405instead of reaching any handler. Where does that 405 come from, how do you read{id}, and what does the new mux still not do?EasyThe mux answers it itself: since Go 1.22 a pattern can name a method, and when some pattern matches the path but none matches the method,
ServeMuxreplies405 Method Not Allowedwith anAllowheader listing the methods it does have.mux := http.NewServeMux() // Method + single-segment wildcard. GET also serves HEAD. mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) // {id} is a string; validate it yourself if err != nil { … - 03
A handler encodes the JSON body and then calls
w.WriteHeader(http.StatusCreated). The client sees200 OKand the server logshttp: superfluous response.WriteHeader call from .... What actually happened?EasyThe first
Writealready sent the header block with an implicit200— the status line and headers are on the wire by the timeWriteHeaderruns, so the later call cannot change anything andnet/httplogs it, with the file and line of the offending call.// ❌ status after the body: client gets 200, log gets "superfluous WriteHeader" func createBad(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(item) // implicit WriteHeader(200) + sniffed Content-Type w.WriteHeader(http.StatusCreated) } … - 04
Every success path in your API returns JSON, but the mobile client throws on the 400s. The handlers use
http.Error(w, err.Error(), http.StatusBadRequest). What is that actually putting on the wire?Easyhttp.Errorwritestext/plain; charset=utf-8and your rawerrorstring as the body, so every failure path leaves the API's content type and ships whatever the internal error happened to say.type apiError struct { Code string `json:"code"` // stable, clients switch on this Message string `json:"message"` // safe to show a human RequestID string `json:"request_id,omitempty"` } … - 05
Your chain is
Logging(Auth(RequestID(Recover(mux)))). Panics never reach your alerting, the access log has no request id, and the browser's CORS preflight comes back401. Which ordering rule did this break?MediumWrapping is inside-out: the outermost middleware sees the request first and the response last, so
Recoverat the bottom only guards the handler,RequestIDbelow the logger cannot be read by it, andAuthin front of CORS rejects a preflight that by definition carries no credentials.type ctxKey struct{ name string } var requestIDKey = ctxKey{"request-id"} func RequestID(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { … - 06
Two handlers in one package both read a package-level
var db *sql.DBassigned ininit(), and a new test needs a different store for one of them. Short of swapping the global and restoring it int.Cleanup, what is the idiomatic shape?MediumHang the handlers off a struct that owns their dependencies —
type Server struct { store Store; log *slog.Logger }with methods likefunc (s *Server) handleItemGet() http.HandlerFunc— so each test builds its ownServerand nothing is shared through package state.// The interface is declared where it is consumed, and it is small. type Store interface { Item(ctx context.Context, id int64) (Item, error) } type Server struct { … - 07
A pen-test report says a few hundred connections that trickle one header byte per minute can take the service down. The whole server is
log.Fatal(http.ListenAndServe(":8080", mux)). Which field closes that hole, and what do the other timeouts cover?Mediumhttp.ListenAndServebuilds aServerwith every timeout at zero, so a connection that never finishes sending its headers is held forever —ReadHeaderTimeoutis the field that closes the Slowloris hole, and you can only set it by constructing thehttp.Serveryourself.// ❌ every timeout is zero — one slow-header connection is held forever log.Fatal(http.ListenAndServe(":8080", mux)) // ✅ own the Server so the timeouts are yours srv := &http.Server{ Addr: ":8080", … - 08
On every deploy a handful of in-flight requests get a connection reset, and the logs show
ListenAndServereturning before the handlers finish. The signal handler callssrv.Close(). What does a correct shutdown sequence look like?MediumClosetears down live connections;Shutdown(ctx)is the graceful one — it closes the listeners, closes idle keep-alive connections, and then waits for active requests to finish, bounded by the context you hand it.func run() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() srv := &http.Server{Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second} … - 09
POST /itemsdoesbody, _ := io.ReadAll(r.Body); json.Unmarshal(body, &in). A client posts a 4 GB body and the pod is OOM-killed. Beyond "add a limit", what else is wrong with reading a request body this way?MediumWrap the body in
http.MaxBytesReader(w, r.Body, n)and decode with ajson.Decoder— the reader caps the bytes and tells the server to stop reading the connection, while the decoder streams instead of materialising the whole request in memory before parsing starts.type createItem struct { Name string `json:"name"` Price *float64 `json:"price"` // pointer: absent and 0 are different answers } func (s *Server) handleItemCreate(w http.ResponseWriter, r *http.Request) { … - 10
A handler kicks off
go func() { out := slowJob(r.Context()); w.Write(out) }()and immediately returns202. Sometimes the job does nothing at all; sometimes the whole process dies with an unrecovered panic. Explain both halves.MediumNeither the
ResponseWriternor the*http.Requestmay be touched onceServeHTTPreturns, and a panic in a goroutine you started is recovered by nothing —recoveronly works in the goroutine that panicked, so it takes the process down.// ❌ w is dead after the handler returns, r.Context() is already cancelled, // and a panic in this goroutine kills the process. func startBad(w http.ResponseWriter, r *http.Request) { go func() { w.Write(slowJob(r.Context())) }() w.WriteHeader(http.StatusAccepted) } … - 11
A service that calls one upstream shows a TCP handshake on nearly every request and thousands of client-side sockets in
TIME_WAIT, even though it reuses a single*http.Client. Every call hasdefer resp.Body.Close()and only reads the body on the 2xx path. What is missing?MediumA connection returns to the idle pool only if the body is read to EOF and closed — bailing out on a non-2xx status closes a body with bytes still in the socket, so the
Transporthas no choice but to discard the connection.// ❌ a non-2xx returns with bytes still in the socket — the connection is burned func fetchBad(ctx context.Context, url string) ([]byte, error) { resp, err := client.Get(url) if err != nil { return nil, err } … - 12
A handler builds
client := &http.Client{Timeout: 5 * time.Second}on every call, ignores the incoming request's deadline, and under load the upstream sees a flood of fresh TLS handshakes. Name both mistakes and the correct per-call budget.MediumThe connection pool lives in the
Transport, so a client per call throws away every keep-alive connection — build one*http.Clientat wiring time and set the per-call budget with a context derived fromr.Context(), not withClient.Timeout.// Built once, at wiring time. Clone the default so you keep Proxy, dial // timeouts, ForceAttemptHTTP2 and ExpectContinueTimeout. func newUpstreamClient() *http.Client { tr := http.DefaultTransport.(*http.Transport).Clone() tr.MaxIdleConnsPerHost = 100 tr.IdleConnTimeout = 60 * time.Second … - 13
An SSE endpoint sends nothing for thirty seconds, then dumps every event at once, and behind the reverse proxy it dies at exactly
WriteTimeout. There are three independent problems here — what are they?HardEvery event has to be flushed, the write deadline has to be pushed out per event, and every buffering layer between the handler and the browser has to be told not to buffer —
net/http'sbufio.Writer,Server.WriteTimeoutand the proxy are three separate things and fixing one does not help the others.func (s *Server) events(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // nginx: stop buffering this response w.WriteHeader(http.StatusOK) … - 14
One shared
*http.Clientbuilt with&http.Transport{TLSClientConfig: cfg}, 2000 rps to a single upstream, bodies properly drained — and still a TLS handshake on nearly every request plus ephemeral port exhaustion. Which setting is it?HardTransport.MaxIdleConnsPerHostdefaults tohttp.DefaultMaxIdleConnsPerHost, which is 2 — every connection beyond the second idle one is closed instead of pooled, so a single hot upstream re-dials constantly no matter how much concurrency you have.// ❌ a bare Transport: no Proxy, no dial timeout, no HTTP/2, and an idle // pool of 2 per host — at 2000 rps that is a handshake per request client := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} // ✅ clone the default and size the pool for the traffic func newHotClient(cfg *tls.Config) *http.Client { … - 15
POST /chargesoccasionally double-charges a customer. Your code contains no retry loop at all. What insidenet/httpmight still be replaying that request, and what makes a retry safe?HardTransportsilently retries a request when a connection it took from the idle pool fails before any response byte arrives — and it will replay aPOSTif the request carries anIdempotency-Key(orX-Idempotency-Key) header and has a rewindable body.func charge(ctx context.Context, c *http.Client, body []byte) (*http.Response, error) { key := uuid.NewString() // ONE key for the operation, reused by every attempt var lastErr error for attempt := 0; attempt < 4; attempt++ { // A Request is single-use: build a fresh one, with a rewindable body so … - 16
You front two backends with
httputil.NewSingleHostReverseProxy(target). The backends log every client as the proxy's own IP, a header yourDirectorsets never arrives, and a streaming endpoint buffers. What isRewritefor?HardReverseProxy.Rewrite(Go 1.20+) exists because hop-by-hop headers are stripped afterDirectorruns —Rewriteis called after the stripping, receives both the inbound and the outbound request, and gives youSetURLandSetXForwardedinstead of hand-rolled forwarding headers.func newProxy(target *url.URL, tr http.RoundTripper, log *slog.Logger) *httputil.ReverseProxy { return &httputil.ReverseProxy{ // Runs AFTER hop-by-hop stripping, so these headers actually survive. Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(target) // scheme, host, and path join r.SetXForwarded() // XFF from r.In.RemoteAddr + Host + Proto, unspoofable …