httpserver

package
v2.0.0-...-c02def0 Latest Latest
Warning

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

Go to latest
Published: Apr 14, 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 Behaviour
/-/liveness GET Always 200 OK. Confirms the process is alive.
/-/readiness GET Runs all registered checks concurrently. 200 if all pass; 503 if any fail.
Registering readiness checks
srv.AddReadinessCheck("database", func(ctx context.Context) error {
    return db.PingContext(ctx)
})

The ctx passed to the check carries the deadline of the incoming probe request, so the check respects any timeout set by the Kubernetes kubelet.

Example 503 response body:

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

Configuration

srv := httpserver.NewWithConfig(&httpserver.Config{
    Name:            "api",           // component name in logs (default: "httpserver")
    Addr:            ":8080",         // default: ":8080"
    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)

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)
	})
	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")
		}),
		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
}

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) ServeHTTP

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

ServeHTTP dispatches the request through built-in middleware and health endpoints, falling through to the application handler for all other routes. 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")
	})

	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, waiting at most [Config.ShutdownTimeout]. If the timeout elapses before all connections have finished, any remaining connections are forcibly closed via Close so that the serve goroutine always exits.

func (*Server) Start

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

Start opens the TCP listener and begins accepting connections in a background goroutine. Bind errors (e.g. port already in use) are returned synchronously so that callers can react before the application proceeds.

Jump to

Keyboard shortcuts

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