http2

package
v1.11.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 18 Imported by: 1

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.

Outbound client (NewClient)

Symmetric to NewServer for the outbound direction — a tuned *http.Client for service-to-service and third-party API calls:

client := http2.NewClient(
    http2.WithResponseHeaderTimeout(30 * time.Second), // slow upstream TTFB
    http2.WithClientOtel(),
    http2.WithRoundTripper(func(next http.RoundTripper) http.RoundTripper {
        return authTransport{next: next, apiKey: cfg.APIKey} // injects a header, then delegates
    }),
)
  • Builds a fresh *http.Transport (not a clone of http.DefaultTransport) and tunes pool/timeout fields: WithMaxIdleConns, WithMaxIdleConnsPerHost, WithDialTimeout, WithKeepAlive, WithTLSHandshakeTimeout, WithResponseHeaderTimeout, WithIdleConnTimeout. Proxy: http.ProxyFromEnvironment and ForceAttemptHTTP2: true are always set — the latter is required because a custom DialContext/TLSClientConfig otherwise makes net/http skip its automatic HTTP/2 upgrade.
  • WithTLSConfig(*tls.Config) — mTLS client certs, a custom root CA pool, or (non-prod only) InsecureSkipVerify.
  • WithTransportOptions(func(*http.Transport)) — escape hatch for *http.Transport fields with no dedicated option (MaxConnsPerHost, DisableCompression, ...), applied after every named option.
  • WithRoundTripper(func(http.RoundTripper) http.RoundTripper) — layer cross-cutting concerns above the transport: auth headers, API keys, HMAC signing, custom retries. First-passed wrapper is outermost (sees the request first), mirroring WithMiddleware's ordering on the server side.
  • WithInnerRoundTripper(func(http.RoundTripper) http.RoundTripper) — like WithRoundTripper, but placed on the OPPOSITE side of WithClientOtel: between the raw *http.Transport and otelhttp.NewTransport, not above it. Exists for exactly one class of concern: annotating the otelhttp client span from inside its own lifetime. otelhttp creates that span before calling into the RoundTripper chain and, on a transport-level error, ends it synchronously before an outer (WithRoundTripper) wrapper regains control — so only an inner wrapper can RecordError/SetAttributes on that path. See NewErrorBodyRoundTripper for the intended consumer. First-passed wrapper is outermost within this inner group (closest to otel, furthest from the wire).
  • WithClientOtel(opts ...otelhttp.Option) — opt-in otelhttp instrumentation, matching every other core/* package's WithOtel convention (disabled unless passed). Named WithClientOtel rather than WithOtel only because both Option types live in this package and Go doesn't allow overloading. Always between the WithInnerRoundTripper chain and the WithRoundTripper chain, so a span covers only the actual network round trip, not time spent in either wrapper group. Pass otelhttp.WithMetricAttributesFn(...) to trim cardinality.
  • http.Client.Timeout is intentionally left unset — bound calls with context.WithTimeout/WithDeadline, not a client-wide wall clock.
Baseline error visibility (NewErrorBodyRoundTripper)
client := http2.NewClient(
    http2.WithClientOtel(),
    http2.WithInnerRoundTripper(http2.NewErrorBodyRoundTripper),
)

Wraps a client's calls with baseline error visibility on the otelhttp client span, with zero per-call-site code: a RecordError event on transport-level failures (dial/TLS/timeout), and a capped (512-byte), UTF-8-sanitized upstream.response_body_snippet attribute on HTTP-level errors (status >= 400) — skipped for gzip/compressed or non-text (Content-Type) bodies to avoid writing invalid-UTF-8 bytes into an OTLP string attribute, and skipped entirely when the span isn't recording (otel disabled, or the trace wasn't sampled), so the read never happens for nothing.

Deliberately does NOT call span.SetStatus: otelhttp's own client semconv already marks any status >= 400 as codes.Error, and it does so AFTER this RoundTripper returns control to otelhttp — a SetStatus call made here would just have its description blanked by otelhttp's later call. This wrapper's RecordError/body-snippet are what carry the actual diagnostic content instead.

Deliberately does NOT attempt domain-level error classification (which upstream, which error class) — it has no notion of "provider" or "model", only raw HTTP. A caller that needs that should keep building its own richer span around the call (e.g. a per-provider chat-completion client); this wrapper is the baseline every call gets for free, not a replacement.

Must be passed to WithInnerRoundTripper, not WithRoundTripper — see that option's doc comment for why placement matters.

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

func NewClient added in v1.10.0

func NewClient(opts ...ClientOption) *http.Client

NewClient builds an *http.Client tuned for outbound service-to-service and third-party API calls: a fresh *http.Transport with connection pool and timeout fields set to sane defaults, optional TLS configuration and RoundTripper wrapping for auth/headers, and optional otel instrumentation (see WithClientOtel).

http.Client.Timeout is intentionally left unset — callers are expected to bound calls with context.WithTimeout/WithDeadline rather than a client-wide wall clock, so that deadlines propagate correctly through retries and don't clip a single slow-but-valid call inside a longer request chain.

func NewErrorBodyRoundTripper added in v1.11.0

func NewErrorBodyRoundTripper(next http.RoundTripper) http.RoundTripper

NewErrorBodyRoundTripper wraps next with baseline error visibility for an otelhttp-instrumented client: a RecordError event on transport-level failures (dial/TLS/timeout), and a capped, UTF-8-safe body snippet attribute on HTTP-level errors (status >= 400). It is meant to be passed to WithInnerRoundTripper, NOT WithRoundTripper — see that option's doc comment for why placement matters here: this wrapper must run WHILE the otelhttp client span is still open, which only holds for the inner (pre-otel) position.

Deliberately does NOT call span.SetStatus: otelhttp's own client semconv already marks any status >= 400 as codes.Error, and that call runs AFTER this RoundTripper returns control to otelhttp — a SetStatus call made here would just have its description blanked by otelhttp's later call (the SDK only refuses to downgrade FROM codes.Error, not same-code overwrites). So today every >=400 client span already shows codes.Error with an empty description; this wrapper's RecordError/body-snippet are what carry the actual diagnostic content, without duplicating (and losing) the status itself. Closing the empty-description gap would need a second, OUTER wrapper (WithRoundTripper) running after otelhttp's SetStatus — but that placement can't see transport-level errors at all (otelhttp has already called span.End() by the time an outer wrapper regains control on that path), so it is not implemented here.

Domain-level error classification (which upstream, which error class) is deliberately NOT this RoundTripper's job — it has no notion of "provider" or "model", only raw HTTP. Callers that need that (e.g. a per-provider chat-completion client) should keep building their own richer span around the call and classify the returned error there; this wrapper is the baseline every call gets for free, not a replacement for that.

Types

type ClientOption added in v1.10.0

type ClientOption func(*clientOptions)

ClientOption configures NewClient.

func WithClientOtel added in v1.10.0

func WithClientOtel(opts ...otelhttp.Option) ClientOption

WithClientOtel enables OpenTelemetry instrumentation of the client's transport via otelhttp.NewTransport, using the global TracerProvider and MeterProvider — otel.Setup must have been called and registered with app.App before NewClient so the providers are non-noop. Named distinctly from NewServer's WithOtel only because both Option types live in this package and Go disallows overloading; the convention is otherwise identical to WithOtel and every other core/* package's opt-in WithOtel: disabled unless passed.

otelhttp's default instrumentation records duration and body-size histograms tagged with the full default attribute set. Pass otelhttp.WithMetricAttributesFn(...) via opts to trim cardinality; see the same concern documented on NewServer's WithOtel.

func WithDialTimeout added in v1.10.0

func WithDialTimeout(d time.Duration) ClientOption

WithDialTimeout overrides the dialer's connect timeout. Default: 5s.

func WithIdleConnTimeout added in v1.10.0

func WithIdleConnTimeout(d time.Duration) ClientOption

WithIdleConnTimeout overrides how long an idle connection is kept in the pool before being closed. Default: 90s.

func WithInnerRoundTripper added in v1.11.0

func WithInnerRoundTripper(wrap func(http.RoundTripper) http.RoundTripper) ClientOption

WithInnerRoundTripper appends a RoundTripper-wrapping function to the chain, like WithRoundTripper, but placed on the OPPOSITE side of otel instrumentation: between the raw *http.Transport and otelhttp.NewTransport, rather than above it. wrap's "next" is the raw transport (or the next inner wrapper), never the otelhttp.Transport.

This exists for exactly one class of concern: annotating the otelhttp client span from inside its own lifetime. otelhttp creates that span before calling into the RoundTripper chain and, on a transport-level error, ends it synchronously before returning control to any WithRoundTripper (outer) wrapper — so an outer wrapper sees an already-ended span on that path, and RecordError/SetAttributes on an ended span are silently dropped by the SDK. A wrapper placed here instead runs WHILE that span is still open (the span is already bound to the request's context.Context, retrievable via trace.SpanFromContext) and can safely enrich it before returning. See NewErrorBodyRoundTripper for the intended consumer.

Ordering among multiple WithInnerRoundTripper calls mirrors WithRoundTripper: the first-passed wrapper is outermost within this inner group (closest to otel, furthest from the wire).

A no-op if WithClientOtel is never passed: without otel, this chain still wraps the raw transport, but there is no span in context for the wrapper to find (trace.SpanFromContext returns a no-op span, safe to call).

func WithKeepAlive added in v1.10.0

func WithKeepAlive(d time.Duration) ClientOption

WithKeepAlive overrides the dialer's TCP keep-alive interval. Default: 30s.

func WithMaxIdleConns added in v1.10.0

func WithMaxIdleConns(n int) ClientOption

WithMaxIdleConns overrides the transport's global idle connection cap. Default: 100.

func WithMaxIdleConnsPerHost added in v1.10.0

func WithMaxIdleConnsPerHost(n int) ClientOption

WithMaxIdleConnsPerHost overrides the transport's per-host idle connection cap. Default: 20.

func WithResponseHeaderTimeout added in v1.10.0

func WithResponseHeaderTimeout(d time.Duration) ClientOption

WithResponseHeaderTimeout overrides how long the transport waits for response headers after writing the request (including its body). Default: 10s. Raise this for slow upstreams (e.g. LLM APIs with high time-to-first-byte); the overall call deadline should still come from context, not this field.

func WithRoundTripper added in v1.10.0

func WithRoundTripper(wrap func(http.RoundTripper) http.RoundTripper) ClientOption

WithRoundTripper appends a RoundTripper-wrapping function to the chain, for cross-cutting concerns that belong above the transport: injecting an Authorization header or API key, HMAC-signing requests, rate limiting, custom retry logic. wrap receives the next RoundTripper in the chain (closer to the wire) and must return the RoundTripper to use in its place.

Chain order mirrors NewServer's WithMiddleware: the first wrapper passed is outermost (sees the request first). otel instrumentation (WithClientOtel) is always innermost, closest to the transport, so a span covers only the actual network round trip — not time spent in these wrappers (e.g. an OAuth2 token refresh should get its own span via its own instrumented client, not inflate this request's span).

func WithTLSConfig added in v1.10.0

func WithTLSConfig(cfg *tls.Config) ClientOption

WithTLSConfig sets the transport's TLS client configuration — client certificates for mTLS against a partner API, a custom root CA pool for a privately-issued server certificate, or (for internal/test environments only) InsecureSkipVerify. Default: nil, which uses Go's standard certificate verification against the system trust store.

func WithTLSHandshakeTimeout added in v1.10.0

func WithTLSHandshakeTimeout(d time.Duration) ClientOption

WithTLSHandshakeTimeout overrides the transport's TLS handshake timeout. Default: 5s.

func WithTransportOptions added in v1.10.0

func WithTransportOptions(fns ...func(*http.Transport)) ClientOption

WithTransportOptions applies arbitrary mutator functions to the built *http.Transport, in the order given, after every named option (WithMaxIdleConns, WithTLSConfig, etc.) has already been applied. Escape hatch for the long tail of *http.Transport fields (MaxConnsPerHost, DisableCompression, DisableKeepAlives, ...) that don't warrant a dedicated named option.

Mutators run before otel instrumentation and RoundTripper wrapping, so they only ever see the raw *http.Transport, never the wrapped http.RoundTripper chain.

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(opts ...otelhttp.Option) 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.

otelhttp's default instrumentation records http.server.request.duration, http.server.request.body.size, and http.server.response.body.size, each tagged with the full default attribute set (route, method, status_code, network/scheme/server attributes). Combined with explicit histogram bucket boundaries (see core/otel's WithHistogramBuckets), this can produce significant active-series cardinality on quota-limited backends. Pass otelhttp.WithMetricAttributesFn(...) via opts to trim or rename attributes per-server instead of working around it at the collector.

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