HTTP Services & REST APIs
Irbisa · cheatsheetSeptember 19, 2026

HTTP Services & REST APIs

net/http, ServeMux patterns, middleware, timeouts, graceful shutdown

Middle Developer16 itemscompressed for a skim
  1. 01

    mux.Handle("/healthz", healthz) will not compile — *cannot use healthz (value of type func(http.ResponseWriter, http.Request)) as http.Handler — yet mux.HandleFunc("/healthz", healthz) compiles fine. What does http.HandlerFunc add?

    Easy

    http.HandlerFunc is a named function type that has a ServeHTTP method, so converting your function to it gives the function the one method http.Handler requires; a bare func value 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")) }
    …
  2. 02

    You register GET /items/{id} on Go 1.22's http.ServeMux, and POST /items/42 now returns 405 instead of reaching any handler. Where does that 405 come from, how do you read {id}, and what does the new mux still not do?

    Easy

    The 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, ServeMux replies 405 Method Not Allowed with an Allow header 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 {
    …
  3. 03

    A handler encodes the JSON body and then calls w.WriteHeader(http.StatusCreated). The client sees 200 OK and the server logs http: superfluous response.WriteHeader call from .... What actually happened?

    Easy

    The first Write already sent the header block with an implicit 200 — the status line and headers are on the wire by the time WriteHeader runs, so the later call cannot change anything and net/http logs 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)
    }
    …
  4. 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?

    Easy

    http.Error writes text/plain; charset=utf-8 and your raw error string 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"`
    }
    …
  5. 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 back 401. Which ordering rule did this break?

    Medium

    Wrapping is inside-out: the outermost middleware sees the request first and the response last, so Recover at the bottom only guards the handler, RequestID below the logger cannot be read by it, and Auth in 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) {
    …
  6. 06

    Two handlers in one package both read a package-level var db *sql.DB assigned in init(), and a new test needs a different store for one of them. Short of swapping the global and restoring it in t.Cleanup, what is the idiomatic shape?

    Medium

    Hang the handlers off a struct that owns their dependenciestype Server struct { store Store; log *slog.Logger } with methods like func (s *Server) handleItemGet() http.HandlerFunc — so each test builds its own Server and 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 {
    …
  7. 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?

    Medium

    http.ListenAndServe builds a Server with every timeout at zero, so a connection that never finishes sending its headers is held forever — ReadHeaderTimeout is the field that closes the Slowloris hole, and you can only set it by constructing the http.Server yourself.

    // ❌ 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",
    …
  8. 08

    On every deploy a handful of in-flight requests get a connection reset, and the logs show ListenAndServe returning before the handlers finish. The signal handler calls srv.Close(). What does a correct shutdown sequence look like?

    Medium

    Close tears 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}
    …
  9. 09

    POST /items does body, _ := 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?

    Medium

    Wrap the body in http.MaxBytesReader(w, r.Body, n) and decode with a json.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. 10

    A handler kicks off go func() { out := slowJob(r.Context()); w.Write(out) }() and immediately returns 202. Sometimes the job does nothing at all; sometimes the whole process dies with an unrecovered panic. Explain both halves.

    Medium

    Neither the ResponseWriter nor the *http.Request may be touched once ServeHTTP returns, and a panic in a goroutine you started is recovered by nothing — recover only 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. 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 has defer resp.Body.Close() and only reads the body on the 2xx path. What is missing?

    Medium

    A 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 Transport has 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. 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.

    Medium

    The connection pool lives in the Transport, so a client per call throws away every keep-alive connection — build one *http.Client at wiring time and set the per-call budget with a context derived from r.Context(), not with Client.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. 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?

    Hard

    Every 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 buffernet/http's bufio.Writer, Server.WriteTimeout and 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. 14

    One shared *http.Client built 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?

    Hard

    Transport.MaxIdleConnsPerHost defaults to http.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. 15

    POST /charges occasionally double-charges a customer. Your code contains no retry loop at all. What inside net/http might still be replaying that request, and what makes a retry safe?

    Hard

    Transport silently retries a request when a connection it took from the idle pool fails before any response byte arrives — and it will replay a POST if the request carries an Idempotency-Key (or X-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. 16

    You front two backends with httputil.NewSingleHostReverseProxy(target). The backends log every client as the proxy's own IP, a header your Director sets never arrives, and a streaming endpoint buffers. What is Rewrite for?

    Hard

    ReverseProxy.Rewrite (Go 1.20+) exists because hop-by-hop headers are stripped after Director runsRewrite is called after the stripping, receives both the inbound and the outbound request, and gives you SetURL and SetXForwarded instead 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
    …