go 98 lines · 2 tabs

HTTP handler with timeouts, request IDs, and context cancellation

Leah Thompson Jan 2026
2 tabs
        package api

        import (
          "context"
          "crypto/rand"
          "encoding/hex"
          "net/http"
          "time"
        )

        type ctxKey string

        const requestIDKey ctxKey = "request_id"

        func withRequestID(ctx context.Context) (context.Context, string) {
          b := make([]byte, 16)
          _, _ = rand.Read(b)
          id := hex.EncodeToString(b)
          return context.WithValue(ctx, requestIDKey, id), id
        }

        func Handler(next http.Handler) http.Handler {
          return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
            defer cancel()

            ctx, reqID := withRequestID(ctx)
            r = r.WithContext(ctx)

            w.Header().Set("x-request-id", reqID)
            r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB

            next.ServeHTTP(w, r)
          })
        }
            description: "To get useful traces, you need propagation and a real exporter. I set a global `TextMapPropagator` (`TraceContext` + `Baggage`) so inbound headers connect spans across services. Then I configure an OTLP exporter and a batch span processor so tracing overhead stays low. I also explicitly set a sampler: `ParentBased(TraceIDRatioBased(0.1))` is a common starting point that respects upstream sampling and keeps costs predictable. The other key piece is resource attributes like `service.name`, which is how traces are grouped in most backends. Once this is initialized, you can start spans in handlers with `otel.Tracer("...").Start(ctx, ...)` and get end-to-end visibility without special log parsing.",
    }
  ]
},
{
  title: 'Structured logging with zap + per-request fields',
  description: "When a service grows past one instance, `fmt.Println` becomes a liability. I prefer `zap` for structured logs, and I treat the request logger as a derived value: a base logger plus fields like `request_id`, `method`, and `path`. That way every log line in the request path is automatically correlated, including errors returned from lower layers. I also explicitly avoid logging secrets by keeping payload logs opt-in. The main trick is keeping the logger in context (or attaching it to a request-scoped struct) so handlers stay clean and don’t balloon with parameters. In practice this pattern makes incident response faster because you can filter by `request_id` and see the full story.",
  tags: %w[go logging observability http],
  code_blocks: [
    {
      name: 'logger.go',
      hljs_language: 'go',
      code: <<~GO
        package observability

        import (
          "context"
          "net/http"

          "go.uber.org/zap"
        )

        type ctxKey string

        const loggerKey ctxKey = "logger"

        func FromContext(ctx context.Context) *zap.Logger {
          if v := ctx.Value(loggerKey); v != nil {
            if lgr, ok := v.(*zap.Logger); ok {
              return lgr
            }
          }
          return zap.NewNop()
        }

        func Middleware(base *zap.Logger) func(http.Handler) http.Handler {
          return func(next http.Handler) http.Handler {
            return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
              reqID := r.Header.Get("x-request-id")
              lgr := base.With(
                zap.String("request_id", reqID),
                zap.String("method", r.Method),
                zap.String("path", r.URL.Path),
              )
              ctx := context.WithValue(r.Context(), loggerKey, lgr)
              next.ServeHTTP(w, r.WithContext(ctx))
            })
          }
        }
2 files · go Explain with highlit

I treat context.Context as the contract between the edge and everything downstream. The pattern here starts by creating a per-request timeout using context.WithTimeout, then storing a requestID in the context so logs and traces can correlate without passing extra parameters everywhere. I also limit request body size early with http.MaxBytesReader to prevent memory blowups. The important operational detail is what happens when the deadline hits: any database call, HTTP call, or goroutine that uses ctx gets the same cancellation signal, which makes backpressure real instead of wishful. When this is wired in consistently, you see fewer stuck goroutines and far more predictable latency under load.


Related snips

Share this code

Here's the card — post it anywhere.

HTTP handler with timeouts, request IDs, and context cancellation — share card
Link copied