httpserver

package
v2.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 12, 2026 License: MIT Imports: 24 Imported by: 0

README

httpserver

Package httpserver provides an HTTP server that implements app.Component, plugging into the app.App lifecycle for managed startup and graceful shutdown.

Quick start

mux := http.NewServeMux()
mux.HandleFunc("GET /api/projects", listProjects)
mux.HandleFunc("GET /api/projects/{id}", getProject)

srv := httpserver.NewWithConfig(&httpserver.Config{
    Addr:    ":8080",
    Logger:  a.Logger(),
    Tracer:  a.Tracer(),
    Handler: mux,
})

a.Register(srv)

Pass the server to app.App.Register and it will start when app.Start is called and drain in-flight requests when app.Shutdown is called.

Routing

LabKit does not own the routing layer. Consumers bring their own router and pass it as Config.Handler. Any http.Handler works:

  • Standard library http.ServeMux (Go 1.22+): supports method-based routing (GET /path) and path parameters (/items/{id}). Sufficient for most services.
  • chi: recommended when you need route grouping, scoped middleware, or sub-router mounting. Zero external dependencies, http.Handler throughout.
  • Any other http.Handler: the Server wraps whatever you provide with built-in middleware.
Standard library example
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)

srv := httpserver.NewWithConfig(&httpserver.Config{
    Handler: mux,
})
chi example
r := chi.NewRouter()
r.Use(requireAuth)
r.Get("/users/{id}", getUser)
r.Route("/api/v2", func(r chi.Router) {
    r.Get("/projects", listProjects)
})

srv := httpserver.NewWithConfig(&httpserver.Config{
    Handler: r,
})
Path parameters
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := httpserver.URLParam(r, "id")
    // ...
})

URLParam uses r.PathValue() (Go 1.22+), which works with both the standard library and chi v5.1+.

Built-in middleware

When Logger and Tracer are set, two middleware layers wrap the handler automatically:

Layer What it does
Tracing Extracts incoming W3C traceparent/tracestate headers, creates a server span named "METHOD /pattern", records the HTTP status code, and marks 5xx responses as errors.
Logging Emits a structured slog line per request with method, path, status, and duration_ms.

Middleware is applied in the order listed above (tracing outermost, logging innermost). The trace span covers the full request including the log write.

Low-cardinality span names

By default, span names use the concrete URL path (e.g. GET /users/42). For low-cardinality names, set the route pattern via SetRoutePattern in a router-specific middleware:

// Example for chi:
func chiRoutePattern(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)
        rctx := chi.RouteContext(r.Context())
        if rctx != nil {
            r = r.WithContext(httpserver.SetRoutePattern(r.Context(), rctx.RoutePattern()))
        }
    })
}

Health endpoints

Two endpoints are available on every server without any registration:

Path Method Port Behaviour
/-/liveness GET probe (:9090) Always 200 OK. Confirms the process is alive.
/-/readiness GET probe (:9090) Runs all registered checks concurrently. 200 if all pass; 503 if any fail.
/-/metrics GET probe (:9090) Prometheus metrics. Only available when Config.Metrics is set.
Liveness

/-/liveness always returns 200 OK. It confirms the process is running and has not deadlocked. It does not test any dependencies and never should — a liveness failure causes Kubernetes to restart the container, which cannot fix an unavailable database.

Startup

Kubernetes disables liveness and readiness probes until the startup probe succeeds. This makes startup probes the right solution for services with slow or unpredictable startup times — loading large config, warming a connection pool, waiting for an init container. Without a startup probe, you must set initialDelaySeconds as a rough guess; too low causes false restarts, too high slows rollouts.

Use /-/readiness as the startup probe endpoint. It returns 503 until the process is up and checks pass, then 200, unlocking liveness and readiness probing. /-/liveness is not suitable here because it always returns 200, so it provides no startup gate.

Set failureThreshold generously so that failureThreshold × periodSeconds covers your worst-case startup time:

startupProbe:
  httpGet:
    path: /-/readiness
    port: 9090             # dedicated probe port
  periodSeconds: 5
  failureThreshold: 24   # 24 × 5s = 2 minutes of startup grace

Once a startup probe is configured, remove initialDelaySeconds from liveness and readiness probes as it becomes redundant.

If your startup checks genuinely differ from ongoing readiness checks (for example, verifying a database migration ran once at boot but not on every poll), register a custom endpoint on your http.ServeMux and point the startup probe at that instead.

Readiness

/-/readiness runs every registered check concurrently and returns 200 when all pass or 503 when one or more fail.

Registering checks
srv.AddReadinessCheck("database", func(ctx context.Context) error {
    return db.PingContext(ctx)
})

The ctx passed to each check carries the deadline of the incoming probe request, so checks respect any timeout configured in the Kubernetes probe spec.

Example 503 response body:

{
  "status": "error",
  "checks": {
    "database": "dial tcp: connection refused",
    "redis": "ok"
  }
}
Writing safe readiness checks

Readiness means "this pod can serve traffic right now" — not "all my dependencies are healthy".

Kubernetes uses the readiness state to decide whether to route requests to a pod. A failing readiness check removes the pod from the load balancer; it does not restart the container.

Serving probes on a dedicated port (:9090 by default, separate from the application port) means your operational endpoints can be restricted to internal cluster traffic in your ingress or network policy without affecting application routing.

What to check

The only things the pod itself controls:

  • Process finished initialising (configuration loaded, connection pool warmed up)
  • A primary dependency that every handler requires and without which the pod genuinely cannot serve any request (see below)
What not to check

Shared external dependencies whose failure should not pull the pod from rotation:

  • Caches (Redis, Memcached)
  • Feature flag services
  • Optional third-party APIs
  • Downstream services

If a non-critical dependency is unavailable, the pod should keep serving traffic and return appropriate errors at the request level.

The cascading failure pattern

Checking a shared dependency creates a correlated failure mode:

  1. The dependency experiences a transient blip
  2. Every pod's readiness check fails at the same time
  3. All pods are removed from the load balancer simultaneously
  4. The remaining pods absorb the full traffic load and also start failing
  5. The dependency recovers in seconds; the incident runs for minutes
If you must check a critical dependency

Some services cannot serve any request without a specific dependency (for example, a primary database that every handler queries). If you include such a check:

  • Keep it lightweight — a connection PING, not a query
  • Always honour the context deadline passed to CheckFunc; it carries the probe timeout
  • Tune the Kubernetes probe to tolerate transient failures (see below)
Kubernetes probe configuration
readinessProbe:
  httpGet:
    path: /-/readiness
    port: 9090               # dedicated probe port — never exposed externally
  initialDelaySeconds: 10    # allow the process to finish starting
  periodSeconds: 10          # how often to probe
  timeoutSeconds: 5          # count as a failure if no response within 5s
  failureThreshold: 3        # require 3 consecutive failures before removing from load balancer
  successThreshold: 1        # one success restores traffic
  • failureThreshold — do not evict on a single failure. Three consecutive failures at periodSeconds: 10 gives 30 seconds of tolerance for transient blips.
  • timeoutSeconds — must exceed the maximum expected response time of your check. The Kubernetes default of 1s is too short for any check that touches a real dependency.
  • initialDelaySeconds — set based on actual startup time; too low causes false failures during rollout.
Liveness probes

Liveness failures restart the container — the right response to a deadlocked process, but the wrong response to a temporarily unavailable database. Restarting a pod that cannot reach its database changes nothing and adds unnecessary connection churn.

Never check external dependencies in a liveness probe. Liveness should only detect unrecoverable internal failure: a deadlock, a failed background goroutine, or a sentinel condition that only a restart can fix.

Configuration

srv := httpserver.NewWithConfig(&httpserver.Config{
    Name:            "api",           // component name in logs (default: "httpserver")
    Addr:            ":8080",         // default: ":8080"
    ProbeAddr:       ":9090",         // default: ":9090" — serves /-/liveness, /-/readiness, /-/metrics
    ReadTimeout:     5 * time.Second, // default: 5s
    WriteTimeout:   10 * time.Second, // default: 10s
    IdleTimeout:    60 * time.Second, // default: 60s
    ShutdownTimeout: 30 * time.Second, // default: 30s
    Logger:          a.Logger(),
    Tracer:          a.Tracer(),
    Handler:         mux,
})

Use Addr: ":0" to let the OS pick a free port, then read it back with srv.Addr() after Start. This is the recommended pattern in tests.

Multiple servers (e.g. a public API and an internal admin server) can be registered independently by giving each a distinct Name and Addr:

api   := httpserver.NewWithConfig(&httpserver.Config{Name: "api",   Addr: ":8080", Handler: apiMux})
admin := httpserver.NewWithConfig(&httpserver.Config{Name: "admin", Addr: ":9090", Handler: adminMux})
a.Register(api)
a.Register(admin)

Testing

Server implements http.Handler, so tests can call ServeHTTP directly without binding a real port:

func TestListUsers(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users", listUsersHandler)

    srv := httpserver.NewWithConfig(&httpserver.Config{Handler: mux})

    req := httptest.NewRequest(http.MethodGet, "/users", nil)
    rec := httptest.NewRecorder()
    srv.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code)
}

To test with a live port (e.g. for integration tests), use Addr: ":0" and call Start:

mux := http.NewServeMux()
mux.HandleFunc("GET /ping", pingHandler)

srv := httpserver.NewWithConfig(&httpserver.Config{Addr: ":0", Handler: mux})

require.NoError(t, srv.Start(ctx))
defer srv.Shutdown(ctx)

resp, err := http.Get("http://" + srv.Addr() + "/ping")

Documentation

Overview

Package httpserver provides an HTTP server that implements app.Component, allowing it to be plugged into an app.App and have its lifecycle managed alongside the logger and tracer.

Consumers bring their own router (standard library http.ServeMux, chi, or any http.Handler) and pass it via [Config.Handler]. LabKit does not own the routing layer; its value is in the built-in middleware that wraps the handler automatically:

  • Tracing: extracts incoming W3C trace context (traceparent / tracestate), creates a server span named "METHOD /pattern", and records the response status. Use SetRoutePattern from a router-specific middleware to get low-cardinality span names.
  • Access logging: emits a structured log entry per request (message "access") with correlation_id, method, uri, status, duration_s, system, host, proto, remote_addr, remote_ip, referrer, user_agent, written_bytes, content_type, and ttfb_s. Sensitive query parameters and URL userinfo are masked automatically. X-Forwarded-For is honored to surface the real client IP.

AccessLogger is also exported for standalone use -- wrap any http.Handler directly when you need access logging outside of a Server.

Basic usage with standard library ServeMux

mux := http.NewServeMux()
mux.HandleFunc("GET /api/projects/{id}", getProject)
mux.HandleFunc("POST /api/projects", createProject)

a, err := app.New(ctx)
if err != nil { log.Fatal(err) }

srv := httpserver.NewWithConfig(&httpserver.Config{
	Addr:    ":8080",
	Logger:  a.Logger(),
	Tracer:  a.Tracer(),
	Handler: mux,
})

a.Register(srv)

if err := a.Start(ctx); err != nil { log.Fatal(err) }
defer a.Shutdown(ctx) //nolint:errcheck

Using chi for advanced routing

For services that need route grouping, scoped middleware, or sub-router mounting, chi is recommended:

r := chi.NewRouter()
r.Use(requireAuth)
r.Get("/api/users", listUsers)
r.Route("/api/v2", func(r chi.Router) {
	r.Get("/projects", listProjects)
	r.Get("/projects/{id}", getProject)
})

srv := httpserver.NewWithConfig(&httpserver.Config{
	Addr:    ":8080",
	Handler: r,
})

Path parameters

Use URLParam to extract named path parameters. This works with both the standard library ServeMux (Go 1.22+) and chi:

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := httpserver.URLParam(r, "id")
	// ...
})

Low-cardinality span names

The tracing middleware names spans "METHOD /path" by default. To get low-cardinality names like "GET /users/{id}" instead of "GET /users/42", set the route pattern via SetRoutePattern in a router-specific middleware:

// Example for chi:
func chiRoutePattern(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r)
		rctx := chi.RouteContext(r.Context())
		if rctx != nil {
			r = r.WithContext(httpserver.SetRoutePattern(r.Context(), rctx.RoutePattern()))
		}
	})
}

Standalone access logger

AccessLogger can wrap any http.Handler when the full Server is not needed -- for example to add access logging to a plain http.ServeMux:

logger := log.New()
http.Handle("/api/", httpserver.AccessLogger(apiMux, logger))

Testing

Server implements http.Handler, so tests can call ServeHTTP directly to exercise handlers without binding a real port:

mux := http.NewServeMux()
mux.HandleFunc("GET /ping", pingHandler)

srv := httpserver.NewWithConfig(&httpserver.Config{Handler: mux})

req := httptest.NewRequest(http.MethodGet, "/ping", nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
Example

Example shows a server wired into an app.App lifecycle with a standard library ServeMux and a readiness check.

package main

import (
	"context"
	"fmt"
	"net/http"

	"gitlab.com/gitlab-org/labkit/v2/httpserver"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /api/projects/{id}", func(w http.ResponseWriter, r *http.Request) {
		id := httpserver.URLParam(r, "id")
		fmt.Fprintf(w, `{"id": "%s"}`, id) //nolint:errcheck
	})
	mux.HandleFunc("GET /api/v2/ping", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	srv := httpserver.NewWithConfig(&httpserver.Config{
		Addr:    ":8080",
		Handler: mux,
		// Logger: a.Logger(),
		// Tracer: a.Tracer(),
	})

	ctx := context.Background()
	if err := srv.Start(ctx); err != nil {
		panic(err)
	}
	defer srv.Shutdown(ctx) //nolint:errcheck
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func AccessLogger

func AccessLogger(next http.Handler, logger *slog.Logger) http.Handler

AccessLogger returns HTTP middleware that emits a structured access log entry for each request. If logger is nil, slog.Default() is used.

Example

ExampleAccessLogger shows how to wrap any http.Handler with structured access logging when the full Server is not needed.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"gitlab.com/gitlab-org/labkit/v2/httpserver"
	"gitlab.com/gitlab-org/labkit/v2/log"
)

func main() {
	logger := log.New()

	handler := httpserver.AccessLogger(
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprint(w, "ok") //nolint:errcheck
		}),
		logger,
	)

	req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)

	fmt.Println(rec.Code)
	fmt.Println(rec.Body.String())
}
Output:
200
ok

func CorrelationIDMiddleware

func CorrelationIDMiddleware(next http.Handler) http.Handler

CorrelationIDMiddleware reads the X-Request-ID header from each incoming request and injects it into the request context via correlation.InjectToContext. If the header is absent, empty, longer than 255 characters, or contains characters outside [a-zA-Z0-9_-], a new UUID is generated and used instead. This prevents log injection and downstream poisoning from malicious headers. The injected ID is available to all downstream handlers and middleware via correlation.ExtractFromContext. The resolved ID is also written back as an X-Request-ID response header. This middleware is applied automatically by NewWithConfig. Use it directly only when composing a handler stack outside of the standard Server.

func PanicRecoveryMiddleware

func PanicRecoveryMiddleware(logger *slog.Logger) func(http.Handler) http.Handler

PanicRecoveryMiddleware recovers from panics in downstream handlers, logs a structured error entry with the panic value and stack trace, records the panic on the active OTel span (if any), and writes a 500 Internal Server Error response to the client. If logger is nil, slog.Default() is used.

func RoutePattern

func RoutePattern(r *http.Request) string

RoutePattern returns the matched route pattern from the request context, or an empty string if no pattern has been set. The tracing middleware uses this to produce low-cardinality span names.

func SetRoutePattern

func SetRoutePattern(ctx context.Context, pattern string) context.Context

SetRoutePattern records the matched route pattern in the request context for use by the tracing middleware. Routers that support route patterns should call this in a middleware so that span names use a low-cardinality pattern (e.g. "/users/{id}") instead of the concrete URL path.

When called inside a Server's middleware chain, SetRoutePattern mutates a shared holder that the tracing middleware reads after the handler returns. The returned context is the same as the input (no allocation needed).

Example chi middleware:

r.Use(func(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(w, r)
		rctx := chi.RouteContext(r.Context())
		if rctx != nil {
			httpserver.SetRoutePattern(r.Context(), rctx.RoutePattern())
		}
	})
})

func URLParam

func URLParam(r *http.Request, key string) string

URLParam returns the named URL parameter from the request. This is a thin wrapper around http.Request.PathValue (Go 1.22+), which works with both the standard library ServeMux and chi v5.1+ (chi calls SetPathValue automatically).

Types

type CheckFunc

type CheckFunc func(ctx context.Context) error

CheckFunc is a health check function. It should return nil when the dependency is healthy and a descriptive error when it is not. The context carries the deadline of the incoming probe request, so CheckFunc implementations should honour it and return promptly.

type Config

type Config struct {
	// Name identifies this server in logs and errors. Defaults to "httpserver".
	// Use distinct names when running multiple servers (e.g. "api", "admin").
	Name string

	// Addr is the TCP address to listen on, e.g. ":8080" or "127.0.0.1:9000".
	// Use ":0" to let the OS pick a free port (useful in tests).
	// Defaults to ":8080".
	Addr string

	// ReadTimeout is the maximum duration for reading the entire request,
	// including the body. Defaults to 5s.
	ReadTimeout time.Duration

	// WriteTimeout is the maximum duration before timing out the response write.
	// Defaults to 10s.
	WriteTimeout time.Duration

	// IdleTimeout is the maximum duration to wait for the next request on a
	// keep-alive connection. Defaults to 60s.
	IdleTimeout time.Duration

	// ShutdownTimeout is the maximum duration allowed for graceful shutdown
	// before in-flight requests are forcibly terminated. Defaults to 30s.
	ShutdownTimeout time.Duration

	// Logger is used for structured access logs. When nil, access logging is
	// disabled.
	Logger *slog.Logger

	// Tracer is used to create server spans and extract incoming W3C trace
	// context. When nil, request tracing is disabled.
	Tracer *trace.Tracer

	// Metrics exposes the Prometheus registry at /-/metrics alongside the
	// built-in liveness and readiness endpoints. When nil, /-/metrics is not
	// registered and callers must mount the handler themselves via
	// [metrics.Metrics.MountOn].
	Metrics *metrics.Metrics

	// Handler is the application's HTTP handler (typically an [http.ServeMux]
	// or a third-party router such as chi). When nil, all non-health requests
	// return 404.
	//
	// Built-in middleware (tracing, access logging) wraps this handler
	// automatically. Consumers do not need to add LabKit middleware manually.
	Handler http.Handler

	// Version, Commit, and BuildDate are included in /-/liveness and /-/readiness
	// response bodies. Typically wired from app.Version, app.Commit, app.BuildDate.
	// Fields are omitted from the response when empty.
	Version   string
	Commit    string
	BuildDate string

	// ProbeAddr is the TCP address for the dedicated probe server that serves
	// /-/liveness, /-/readiness, and /-/metrics. Defaults to ":9090".
	// Use ":0" in tests to let the OS pick a free port.
	ProbeAddr string
}

Config holds optional configuration for New / NewWithConfig. Zero values produce sensible defaults.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is an HTTP server that implements app.Component. Create routes on your own http.ServeMux or router, pass it as [Config.Handler], then plug the Server into an app.App via Register and call app.App.Start to begin serving.

Built-in middleware (tracing, logging) wraps the handler automatically. Health endpoints (/-/liveness, /-/readiness) are handled by the Server directly, bypassing the application handler.

func New

func New() *Server

New returns a Server with default Config.

func NewWithConfig

func NewWithConfig(cfg *Config) *Server

NewWithConfig returns a Server configured with cfg. A nil cfg is treated identically to an empty Config (all defaults).

func (*Server) AddReadinessCheck

func (s *Server) AddReadinessCheck(name string, fn CheckFunc) *Server

AddReadinessCheck registers a named check that is run on every request to /-/readiness. If the check returns an error the endpoint responds with 503 Service Unavailable and includes the error message in the JSON body.

All checks must be registered before [Start] is called.

srv.AddReadinessCheck("database", func(ctx context.Context) error {
	return db.PingContext(ctx)
})
Example

ExampleServer_AddReadinessCheck shows how to register a dependency health check. The /-/readiness endpoint runs all checks concurrently.

package main

import (
	"context"

	"gitlab.com/gitlab-org/labkit/v2/httpserver"
)

func main() {
	srv := httpserver.New()

	srv.AddReadinessCheck("database", func(ctx context.Context) error {
		// Replace with: return db.PingContext(ctx)
		return nil
	})

	srv.AddReadinessCheck("cache", func(ctx context.Context) error {
		// Replace with: return cache.Ping(ctx)
		return nil
	})

	_ = srv
}

func (*Server) Addr

func (s *Server) Addr() string

Addr returns the network address the server is listening on. Returns an empty string if [Start] has not been called yet. Use this after starting with Addr ":0" to discover the bound port.

func (*Server) Name

func (s *Server) Name() string

Name returns the component name for use in logs and error messages.

func (*Server) ProbeAddr added in v2.6.0

func (s *Server) ProbeAddr() string

ProbeAddr returns the network address the probe server is listening on. Returns an empty string if [Start] has not been called yet. Use this after starting with ProbeAddr ":0" to discover the bound port.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP dispatches the request through built-in middleware to the application handler. Health and metrics endpoints are served on the dedicated probe port (see [Config.ProbeAddr]) and are not available here. This implements http.Handler so the Server can be used directly in tests without calling [Start].

Example

ExampleServer_ServeHTTP shows how to test handlers without binding a real port. Calling ServeHTTP directly is the fastest way to exercise routes.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"gitlab.com/gitlab-org/labkit/v2/httpserver"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /ping", func(w http.ResponseWriter, _ *http.Request) {
		fmt.Fprint(w, "pong") //nolint:errcheck
	})

	srv := httpserver.NewWithConfig(&httpserver.Config{
		Handler: mux,
	})

	req := httptest.NewRequest(http.MethodGet, "/ping", nil)
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	fmt.Println(rec.Code)
	fmt.Println(rec.Body.String())
}
Output:
200
pong

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully drains in-flight requests on the application server, then shuts down the probe server. The probe server is shut down last so that /-/readiness remains available during the application drain window — Kubernetes can observe the pod going unready before the probe port closes.

func (*Server) Start

func (s *Server) Start(ctx context.Context) error

Start opens the TCP listeners for both the application server and the probe server, then begins accepting connections in background goroutines. Bind errors are returned synchronously. If the probe listener fails after the application listener succeeds, the application listener is closed so no resources are leaked.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL