http2

package
v1.4.0 Latest Latest
Warning

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

Go to latest
Published: Apr 23, 2026 License: MIT Imports: 11 Imported by: 0

README

http2

HTTP/2-capable server with built-in /livez, /readyz, /metrics, opt-in OpenTelemetry instrumentation, panic recovery, and a context-safe graceful shutdown. Implements lifecycle.Runner.

When to use

Any HTTP-facing endpoint of a service: REST APIs, ConnectRPC handlers, SPA static-asset serving, gRPC-Web, server-streaming SSE.

Quickstart

package main

import (
    "log"
    "time"

    "github.com/sergeyslonimsky/core/app"
    "github.com/sergeyslonimsky/core/http2"
)

func main() {
    srv := http2.NewServer(http2.Config{
        Port:         "8080",
        WriteTimeout: 24 * time.Hour, // for server-streaming endpoints
    },
        http2.WithRecovery(),
        http2.WithOtel(),
    )
    srv.Mount("/api", apiHandler())

    a := app.New()
    a.Add(srv)
    log.Fatal(a.Run())
}

Configuration

type Config struct {
    Port            string        // Default "80"
    ReadTimeout     time.Duration // Default 1s
    WriteTimeout    time.Duration // Default 10s. For streaming, set to hours.
    ShutdownTimeout time.Duration // Default 10s
}

App-level mapping example:

http2.Config{
    Port:            raw.GetStringOrDefault("http.frontend.port", "8080"),
    ReadTimeout:     raw.GetDuration("http.frontend.read_timeout"),
    WriteTimeout:    raw.GetDuration("http.frontend.write_timeout"),
    ShutdownTimeout: raw.GetDuration("http.frontend.shutdown_timeout"),
}

Options

  • WithMiddleware(mw ...func(http.Handler) http.Handler) — append user middlewares; outer-first ordering.
  • WithListener(l net.Listener) — override the default TCP listener (tests, unix sockets).
  • WithLogger(*slog.Logger) — logger used by recovery middleware and lifecycle events. Default: slog.Default().
  • WithOtel() — wrap final handler with otelhttp.NewHandler using global tracer/meter providers.
  • WithRecovery() — install panic-recovery middleware that logs the stack and replies 500.
  • WithHealthcheckFrom(lifecycle.Healthchecker) — wire /readyz aggregation (typically pass *app.App).
  • WithReadyzCheck(name string, fn func(ctx) error) — add an inline readiness check.
  • WithMetricsHandler(http.Handler) — install a /metrics handler (typically promhttp.Handler over an otel Prometheus exporter).

Built-in routes

Path Behavior
GET /livez Always 200. The process is up.
GET /readyz 200 if every Healthchecker (WithHealthcheckFrom + WithReadyzCheck) is healthy, else 503 with the failed errors in the body.
GET /metrics The handler from WithMetricsHandler, or 404 if none was provided.

Observability (WithOtel)

Wraps the final handler chain with otelhttp.NewHandler(handler, "http-"+cfg.Port). Uses the global TracerProvider and MeterProvider. Call otel.Setup and register the provider with app.App BEFORE constructing this server, or the providers will be noop:

otelSDK, err := otel.Setup(ctx, cfg.OTel)
// ... handle err
a.Add(otelSDK)            // register first → shut down last → flushes telemetry
srv := http2.NewServer(cfg.Frontend, http2.WithOtel())
a.Add(srv)

Imports go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp.

Lifecycle

http2.Server implements lifecycle.Runner. Register with app.App.Add(srv). The app handles startup, signal-driven shutdown, and ordering — never call srv.Run/srv.Shutdown from your code outside of tests.

Run is single-use — a second call on the same *Server returns ErrServerAlreadyStarted because the underlying http.Server cannot be reused after Shutdown.

Built-in middlewares are layered as user middlewares → recovery → otelhttp → mux. User middlewares are outermost so panics inside them are still caught by recovery, and traces span user-middleware work.

Recovery behaviour

WithRecovery wraps the response writer to track whether the handler already committed the response. If a panic happens AFTER any write (status code or body), the middleware logs the panic but does NOT attempt to emit 500 internal server error — overwriting committed headers is a no-op and re-writing the body would corrupt HTTP framing. The already-partially-written response reaches the client as-is.

If the panic happens BEFORE the response was committed, a clean 500 internal server error is emitted.

Shutdown semantics

Run blocks until ctx is cancelled, then calls Shutdown with a fresh timeout context built from cfg.ShutdownTimeout — NOT the already-cancelled run ctx. This ensures in-flight requests are drained within the configured timeout rather than being force-closed immediately.

A regression test in server_test.go (TestServer_Shutdown_FreshContext_DoesNotShortCircuitOnCancelledRunCtx) covers this.

Extending

There is no Unwrap method. The underlying http.ServeMux and http.Server are package-private — extend by:

  • Adding more middleware via WithMiddleware.
  • Mounting handlers via Mount(pattern, handler).
  • Composing your own handler stack and passing the result to Mount("/", composed).

Testing

l, _ := net.Listen("tcp", "127.0.0.1:0")  // random port
srv := http2.NewServer(http2.Config{}, http2.WithListener(l))
go srv.Run(ctx)
// ... test against l.Addr()

See server_test.go for the full test suite covering routes, middleware order, panic recovery, healthchecks, and the shutdown bug regression.

See also

  • core/app — register your server here.
  • core/lifecycle — the Runner / Healthchecker contracts.
  • core/grpc — sibling gRPC server with the same lifecycle shape.

Documentation

Overview

Package http2 provides an opinionated HTTP/2 server wrapper that implements lifecycle.Runner. It ships with built-in /livez, /readyz, and /metrics endpoints, a slog-aware recovery middleware, an opt-in otelhttp instrumentation hook, and a context-safe shutdown contract.

Typical usage:

cfg := http2.Config{Port: "8080"}
srv := http2.NewServer(cfg,
    http2.WithOtel(),
    http2.WithRecovery(),
    http2.WithHealthcheckFrom(app),
)
srv.Mount("/api", apiHandler)

a := app.New()
a.Add(srv)
log.Fatal(a.Run())

Index

Constants

View Source
const (
	// LivenessPath returns 200 OK unconditionally — a signal that the
	// process is up and the event loop is scheduling. Does NOT check
	// dependencies; that is /readyz's job.
	LivenessPath = "/livez"

	// ReadinessPath returns 200 OK if every Healthchecker wired via
	// WithHealthcheckFrom / WithReadyzCheck reports healthy, or 503 with
	// the error body otherwise.
	ReadinessPath = "/readyz"

	// MetricsPath serves the handler passed via WithMetricsHandler, or 404
	// if no handler was set. Typical use: pass the promhttp handler that
	// scrapes the otel PrometheusExporter.
	MetricsPath = "/metrics"
)

Built-in route paths served by every Server.

Variables

View Source
var ErrServerAlreadyStarted = errors.New("http2: server already started")

ErrServerAlreadyStarted is returned by Server.Run if the server has already been started. A Server instance is single-use: the underlying http.Server cannot be reused after Shutdown.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Port is the TCP port the listener binds to. Default: "80".
	Port string

	// ReadTimeout caps the time taken to read the full request including
	// body. Default: 1 second. Keep small for non-upload endpoints.
	ReadTimeout time.Duration

	// WriteTimeout caps the time taken to write the full response body.
	// For server-streaming endpoints this MUST be large — a 24h value
	// is common for long-lived SSE / server-streaming RPCs. Default: 10s.
	WriteTimeout time.Duration

	// ShutdownTimeout caps the graceful shutdown phase. If in-flight
	// requests do not complete within this window, the server is
	// force-closed. Default: 10 seconds.
	ShutdownTimeout time.Duration
}

Config holds the server's network and timeout settings. All fields are optional — zero values are replaced with defaults in NewServer.

type Option added in v1.3.0

type Option func(*Server)

Option configures a new Server.

func WithHealthcheckFrom added in v1.3.0

func WithHealthcheckFrom(h lifecycle.Healthchecker) Option

WithHealthcheckFrom wires a lifecycle.Healthchecker (typically an *app.App) into the /readyz handler. On every /readyz request, the passed Healthchecker's Healthcheck method is invoked and its result aggregated with any WithReadyzCheck-registered checks.

If no healthchecker is set, /readyz returns 200 OK unconditionally (same as /livez).

func WithListener added in v1.3.0

func WithListener(l net.Listener) Option

WithListener overrides the default TCP listener. Useful for tests (net.Listen on :0) or for exposing the server on a unix socket.

func WithLogger added in v1.3.0

func WithLogger(l *slog.Logger) Option

WithLogger attaches a *slog.Logger to the server. Used by the recovery middleware and for lifecycle events. Defaults to slog.Default() when omitted.

func WithMetricsHandler added in v1.3.0

func WithMetricsHandler(h http.Handler) Option

WithMetricsHandler registers an http.Handler at MetricsPath. Typical use: pass the promhttp.Handler wrapping an otel Prometheus exporter. Kept as a generic Handler parameter so core/http2 does not depend on prometheus or otel/exporters/prometheus directly.

Without this option, /metrics returns 404.

func WithMiddleware added in v1.3.0

func WithMiddleware(mw ...func(http.Handler) http.Handler) Option

WithMiddleware appends one or more middleware functions to the chain. The chain is applied outer-first: the first middleware passed is the outermost wrapper (sees the request first, writes the response last).

Built-in middlewares (WithOtel, WithRecovery) are applied AFTER user middlewares so that panics from inside user middleware are still caught and traces span user middleware work.

func WithOtel added in v1.3.0

func WithOtel() Option

WithOtel enables OpenTelemetry instrumentation by wrapping the final handler with otelhttp.NewHandler. Uses the global TracerProvider and MeterProvider, so otel.Setup must have been called and registered with app.App before NewServer so the providers are non-noop.

WithOtel records one span per request with the route pattern matched by the underlying ServeMux, including HTTP method, status code, and latency.

func WithReadyzCheck added in v1.3.0

func WithReadyzCheck(name string, fn func(ctx context.Context) error) Option

WithReadyzCheck adds a named inline readiness check. Useful for app-level gates that are not tied to a registered Resource/Runner (e.g., "warm cache loaded", "migration finished").

The check function receives the request's context and must respect its deadline. Returning a non-nil error fails the /readyz response.

func WithRecovery added in v1.3.0

func WithRecovery() Option

WithRecovery installs a panic-recovery middleware that logs the panic with a stack trace (via the configured slog.Logger) and responds with HTTP 500. Without this, a panic in a handler crashes the goroutine and terminates the connection without a response.

Recommended for every production-facing server.

type Server

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

Server is an HTTP/2-capable http.Server wrapped with middleware chain, built-in /livez, /readyz, /metrics routes, and a context-safe graceful shutdown. Implements lifecycle.Runner.

func NewServer

func NewServer(cfg Config, opts ...Option) *Server

NewServer constructs a Server with the given Config and options. Zero values in cfg are replaced with defaults (see defaultXxx constants).

The returned *Server implements lifecycle.Runner. Register it with app.App via a.Add(srv); do not call Run directly outside of tests.

func (*Server) Address

func (s *Server) Address() string

Address returns the server's listen address in ":port" form.

func (*Server) Mount

func (s *Server) Mount(pattern string, handler http.Handler)

Mount registers handler at pattern on the underlying ServeMux. Patterns follow net/http.ServeMux rules (see https://pkg.go.dev/net/http#ServeMux).

Mount must be called before Run. Calling it concurrently with Run is undefined behavior.

func (*Server) Run

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

Run starts the HTTP listener and blocks until ctx is cancelled or the server fails. On ctx cancellation, Run invokes Shutdown with a FRESH timeout context (NOT the already-cancelled ctx) so graceful drain has its full budget.

Implements lifecycle.Runner.

Run must be called at most once per Server instance — the underlying http.Server cannot be reused after Shutdown. A second call returns ErrServerAlreadyStarted.

func (*Server) Shutdown

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

Shutdown initiates graceful shutdown bounded by ctx. Implements lifecycle.Resource.

Can be called multiple times safely — after the first call, subsequent calls to the underlying http.Server.Shutdown return http.ErrServerClosed, which Shutdown treats as a no-op.

Jump to

Keyboard shortcuts

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