HTTP¶
The pkg/http package provides hardened HTTP components for both server-side and client-side operations. It enforces secure TLS defaults, provides built-in observability endpoints, and mirrors the security posture required for production environments.
Server Control¶
The HTTP server implementation integrates seamlessly with the controls lifecycle management.
Features¶
- Standardized Endpoints: Automatically mounts
/healthz,/livez, and/readyz. - Production Timeouts: Pre-configured Read (5s), Write (10s), and Idle (120s) timeouts.
- Secure TLS: Enforces TLS 1.2 minimum with curated AEAD-based cipher suites and X25519 preference.
TLS Configuration¶
TLS configuration cascades — transport-specific keys override the shared defaults:
| Key | Shared Default | HTTP Override |
|---|---|---|
| Enabled | server.tls.enabled |
server.http.tls.enabled |
| Certificate | server.tls.cert |
server.http.tls.cert |
| Private key | server.tls.key |
server.http.tls.key |
To use the same certificate for both HTTP and gRPC, configure the shared keys only:
When TLS is enabled, the server uses ServeTLS with the shared hardened config from pkg/tls (TLS 1.2+, curated AEAD ciphers, X25519). When disabled, it uses plain Serve.
TLS configuration and resolution live in pkg/tls (gtbtls.DefaultConfig, gtbtls.Resolve, the typed gtbtls.Pair, plus the CertPool/ClientConfig client helpers), shared across the HTTP, gRPC and gateway transports.
Running a Second HTTP Server¶
By default a server reads its port and TLS from the server.http config prefix. To run more than one HTTP server in the same process — for example a public API server plus an internal/admin server — pass a ServerOption so each server reads its own config block or binds an explicit port.
Through the controller-integrated Register (a single WithConfigPrefix threads the prefix to both construction and start):
// Reads server.gateway.port and server.gateway.tls.* (falling back to server.tls.*)
srv, err := gtbhttp.Register(ctx, "gateway", controller, props.Config, props.Logger, handler,
gtbhttp.WithConfigPrefix("server.gateway"),
)
Or constructing standalone with NewServer + Start — pass the same prefix to both so the listen port and TLS settings stay consistent:
// Public API server on server.http.*
pub, _ := gtbhttp.NewServer(ctx, props.Config, pubHandler)
controller.Register("public",
controls.WithStart(gtbhttp.Start(props.Config, props.Logger, pub)),
controls.WithStop(gtbhttp.Stop(props.Logger, pub)))
// Internal admin server on its own config block (server.admin.*)
adm, _ := gtbhttp.NewServer(ctx, props.Config, admHandler, gtbhttp.WithConfigPrefix("server.admin"))
controller.Register("admin",
controls.WithStart(gtbhttp.Start(props.Config, props.Logger, adm, gtbhttp.WithConfigPrefix("server.admin"))),
controls.WithStop(gtbhttp.Stop(props.Logger, adm)))
// ...or a fixed port with no config block at all:
dbg, _ := gtbhttp.NewServer(ctx, props.Config, dbgHandler, gtbhttp.WithPort(9090))
The prefix governs <prefix>.port, <prefix>.tls.* and <prefix>.max_header_bytes; the shared server.port remains the fallback. WithPort overrides config entirely. When the resolved port is 0, the OS assigns an ephemeral port and Start logs the actually-bound address.
Server Options¶
ServerOption values configure NewServer and Start, and are also accepted by Register:
WithConfigPrefix(prefix string) ServerOption: Config prefix for port, TLS and max-header-bytes (defaultserver.http).WithPort(port int) ServerOption: Explicit listen port, bypassing config lookup (highest precedence).WithMaxHeaderBytes(n int) ServerOption: Overrides<prefix>.max_header_bytesand the 1 MB default.WithReadTimeout/WithWriteTimeout/WithIdleTimeout(d time.Duration) ServerOption: Override the built-inhttp.Servertimeouts.WithServerTLSConfig(c *tls.Config) ServerOption: Replaces the default hardened*tls.Configon the constructed server (named distinctly from the client-sideWithTLSConfig).
Functions¶
NewServer(ctx context.Context, cfg config.Containable, handler http.Handler, opts ...ServerOption) (*http.Server, error): Returns a pre-configured*http.Server. With no options it reads theserver.httpprefix.Start(cfg config.Containable, logger logger.Logger, srv *http.Server, opts ...ServerOption) controls.StartFunc: Returns a controller start function. PassWithConfigPrefixto match a server built on a custom prefix; it logs the bound listener address (so an ephemeral:0port surfaces resolved).Register(ctx context.Context, id string, controller controls.Controllable, cfg config.Containable, logger logger.Logger, handler http.Handler, opts ...any) (*http.Server, error): Creates, configures, and registers the server with aController. The variadic accepts bothServerOptionandRegisterOptionvalues (mirroringpkg/grpc.Register). Health endpoints (/healthz,/livez,/readyz) are mounted outside any middleware chain.Stop(logger logger.Logger, srv *http.Server) controls.StopFunc: Returns a controller stop function. It callssrv.Shutdown(ctx)to drain in-flight requests; if the shutdown context deadline expires (a handler outlives it), the server is force-closed viasrv.Close()so a hung handler cannot leave the listener and connections open. This mirrors the gRPC transport's graceful-then-force-stop behaviour.
Drain semantics: the server's per-request BaseContext is detached from the construction context with context.WithoutCancel. Cancelling the construction context (typically at shutdown) therefore does not cancel already-accepted requests mid-drain — Shutdown is left to drain them within its deadline. Context values on the construction context are still propagated to each request.
Middleware Chaining¶
The HTTP server chain is one of four transport middleware surfaces. For the cross-cutting pattern (server/client × HTTP/gRPC), the resilience composition rules, and the config-prefix convention, see the Transport Middleware & Resilience concept.
The package provides an alice-style middleware chaining API. Middleware uses the standard func(http.Handler) http.Handler signature.
NewChain(middlewares ...Middleware) Chain: Creates a middleware chain. The first middleware is the outermost wrapper.(c Chain) Append(middlewares ...Middleware) Chain: Returns a new chain with additional middleware appended (immutable).(c Chain) Extend(other Chain) Chain: Composes two chains.(c Chain) Then(handler http.Handler) http.Handler: Applies the chain to a handler.(c Chain) ThenFunc(fn http.HandlerFunc) http.Handler: Convenience forThen(http.HandlerFunc(fn)).WithMiddleware(chain Chain) RegisterOption: Applies a middleware chain when usingRegister. Health endpoints are unaffected.
Built-in Logging Middleware¶
LoggingMiddleware logs each completed HTTP request with structured fields (method, path, status, latency, bytes, client IP, user agent).
LoggingMiddleware(logger logger.Logger, opts ...LoggingOption) Middleware
Format options (WithFormat):
| Format | Description |
|---|---|
FormatStructured (default) |
Structured key-value fields via logger.Logger |
FormatCommon |
NCSA Common Log Format |
FormatCombined |
NCSA Combined Log Format (CLF + Referer + User-Agent) |
FormatJSON |
Single JSON object per request |
Other options: WithLogLevel, WithoutLatency, WithoutUserAgent, WithPathFilter, WithHeaderFields, WithTrustedProxy.
Client IP and trusted proxies: by default the logged client_ip is taken from the connection's RemoteAddr. The X-Forwarded-For and X-Real-IP headers are ignored because any direct client can forge them. Enable WithTrustedProxy() only when the server sits behind a trusted reverse proxy or load balancer that overwrites these headers; with it set, the left-most X-Forwarded-For entry (falling back to X-Real-IP) is used. Enabling it on a directly-exposed server lets clients spoof the recorded client IP.
Built-in Security-Headers Middleware¶
SecurityHeadersMiddleware sets a conservative set of response security headers on every request. It is a standard Middleware, so it composes into any NewChain/WithMiddleware pipeline.
SecurityHeadersMiddleware(opts ...SecurityHeadersOption) Middleware
Default headers:
| Header | Default value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Content-Security-Policy |
frame-ancestors 'none' |
Referrer-Policy |
no-referrer |
Strict-Transport-Security |
(off by default) |
Options:
| Option | Effect |
|---|---|
WithContentTypeOptions(v) |
Override X-Content-Type-Options (empty omits the header). |
WithFrameOptions(v) |
Override X-Frame-Options (empty omits the header). |
WithReferrerPolicy(v) |
Override Referrer-Policy (empty omits the header). |
WithContentSecurityPolicy(p) |
Set a full CSP, replacing the frame-ancestors-only default. An empty value falls back to the default so the clickjacking control is never silently dropped. |
WithHSTS(maxAge, includeSubdomains, preload) |
Enable Strict-Transport-Security. Off by default — HSTS is only meaningful (and only safe) over TLS. A non-positive maxAge leaves it disabled. |
Headers are set before the wrapped handler runs, so a handler that writes its own response still emits them; a handler may override any value by setting its own.
Applied to the built-in surfaces by default. The interactive docs/OpenAPI handlers (pkg/openapi.Register) and the documentation server (pkg/docs.Serve) wrap their handlers with this middleware automatically — the docs UI serves a "try-it" console that benefits from nosniff/frame/referrer protections. Customise via openapi.WithSecurityHeaderOptions(...) or opt out with openapi.WithoutSecurityHeaders(). The middleware is not forced onto user-supplied handlers; add it to your own chain where you want it.
Built-in Rate-Limit Middleware¶
RateLimitMiddleware protects a server from overload by admitting requests under a token-bucket limiter and rejecting excess traffic with 429 Too Many Requests plus a Retry-After header (which a GTB client's retry layer honours — a pleasing closed loop). It is an ordinary Middleware, so it composes into any Chain.
RateLimitMiddleware(log logger.Logger, cfg RateLimitConfig) MiddlewareDefaultRateLimitConfig() RateLimitConfig— 50 rps sustained, burst 100, single global bucketClientIPKey(r *http.Request) string— a ready-made per-clientKeyFuncRateLimitConfigFromConfig(cfg config.Containable, prefix string) RateLimitConfig— read policy from config
RateLimitConfig fields:
| Field | Default | Description |
|---|---|---|
RequestsPerSecond |
50 | Sustained token-bucket fill rate |
Burst |
100 | Bucket capacity — max instantaneous spike admitted |
KeyFunc |
nil | Derives a per-client key; nil = one global bucket |
MaxTrackedKeys |
8192 | Bound on the per-key bucket store (ignored when KeyFunc is nil) |
OnLimited |
nil | Optional callback invoked on each rejection (metrics/telemetry) |
Admission is non-blocking (Allow, not Wait): ingress must reject excess, never queue it, or a flood would exhaust memory.
Scoping is composition, not configuration. A global limiter is one entry in the server chain; a per-route limiter wraps a single handler; a per-client limiter sets KeyFunc. The per-key bucket store is bounded and LRU-evicting (capped at MaxTrackedKeys) so an attacker rotating source keys cannot exhaust memory.
Security note on
ClientIPKey. It keys on the connection'sRemoteAddrand deliberately ignoresX-Forwarded-For/X-Real-IP— those headers are spoofable by any direct client, so keying on them would let an attacker both evade their own bucket and churn the key store. A server behind a trusted reverse proxy that terminates XFF should supply its ownKeyFuncthat reads the proxy-set header.
Health endpoints (/healthz, /livez, /readyz) are mounted outside the WithMiddleware chain, so a global limiter never throttles liveness/readiness probes.
Config keys (RateLimitConfigFromConfig, prefix defaults to server.http):
| Key | Maps to |
|---|---|
server.http.ratelimit.requests_per_second |
RequestsPerSecond |
server.http.ratelimit.burst |
Burst |
server.http.ratelimit.max_tracked_keys |
MaxTrackedKeys |
Unset keys keep their defaults; the code-only fields (KeyFunc, OnLimited) are never read from config — wiring stays explicit.
Built-in Authentication Middleware¶
AuthMiddleware authenticates (and optionally authorizes) each request from an pkg/authn verifier — an API key, a JWT/OIDC bearer token, or an mTLS client certificate — storing the verified identity in the request context. It is an ordinary Middleware, so it composes into any Chain.
AuthMiddleware(opts ...AuthOption) (Middleware, error)— with no verifier configured it is a construction error (fail-closed)IdentityFromContext(ctx context.Context) (*authn.Identity, bool)— read the verified identity in a handler (same context key as the gRPC interceptor)
keys, _ := authn.NewAPIKeyVerifier(authn.KeyEntry{Key: ciKey, Subject: "ci"})
authMW, _ := gtbhttp.AuthMiddleware(
gtbhttp.WithAPIKeyHeader("X-API-Key", keys),
gtbhttp.WithAuthorize(authn.RequireScopes("api:write")),
gtbhttp.WithAuthLogger(props.Logger),
)
chain := gtbhttp.NewChain(gtbhttp.LoggingMiddleware(props.Logger), authMW)
Options: WithBearerVerifier, WithAPIKeyHeader, WithMTLSVerifier, WithAuthorize, WithAuthLogger, WithAuthSkipper. On failure it writes a generic 401 (with WWW-Authenticate) or 403 and logs the cause with the credential redacted — never disclosing why to the client. Health endpoints are outside the chain, so a global auth middleware never gates probes. See Authentication & Authorization for the full reference, including the verifiers, the authorization seam, credential precedence, and the security model.
Usage Example¶
mux := http.NewServeMux()
mux.HandleFunc("/api/data", myDataHandler)
// Build a middleware chain
chain := gtbhttp.NewChain(
gtbhttp.LoggingMiddleware(props.Logger,
gtbhttp.WithFormat(gtbhttp.FormatCombined),
gtbhttp.WithPathFilter("/healthz", "/livez", "/readyz"),
),
)
// Register with middleware — health endpoints stay outside the chain
srv, err := gtbhttp.Register(ctx, "http-api", controller, props.Config, props.Logger, mux,
gtbhttp.WithMiddleware(chain),
)
Client Factory¶
The pkg/http package provides a factory for creating hardened http.Client instances for outbound requests.
Features¶
- Mandatory Timeouts: Default 30s timeout to prevent blocked goroutines.
- Secure Transport: Uses the same hardened TLS configuration as the server.
- Scheme Protection: Redirect policy rejects HTTPS-to-HTTP downgrades.
- Connection Limits: Pre-configured idle connection pooling and timeouts.
Functions¶
NewClient(opts ...ClientOption) *http.Client: Returns a hardened HTTP client.NewTransport(tlsCfg *tls.Config) *http.Transport: Returns a pre-configured secure transport for custom client needs.
Options¶
WithTimeout(d time.Duration)WithMaxRedirects(n int)WithTLSConfig(cfg *tls.Config)WithCertPool(pool *x509.CertPool)— trusts a custom root CA pool (private CA / self-signed) while preserving the hardened TLS defaults; build the pool withtls.CertPoolWithTransport(rt http.RoundTripper)WithRetry(cfg RetryConfig)— enables automatic retry with exponential backoffWithClientMiddleware(chain ClientChain)— applies a middleware chain to the transport
Retry with Exponential Backoff¶
The client supports opt-in retry for transient failures via WithRetry. Retry is implemented as a http.RoundTripper decorator, so it composes cleanly with custom transports set via WithTransport.
RetryConfig fields:
| Field | Default | Description |
|---|---|---|
MaxRetries |
3 | Maximum number of retry attempts (0 = no retries) |
InitialBackoff |
500ms | Base delay before the first retry |
MaxBackoff |
30s | Cap on computed delay |
RetryableStatusCodes |
429, 502, 503, 504 | HTTP status codes that trigger a retry |
ShouldRetry |
nil | Optional custom predicate replacing default logic |
Backoff strategy: Full jitter — uniform random in [0, min(cap, base × 2^attempt)]. This reduces thundering-herd effects compared to fixed or equal-jitter backoff. The exponential term is clamped to MaxBackoff before the doubling so the computed delay can never overflow. Invalid RetryConfig values (negative MaxRetries, non-positive backoff durations, MaxBackoff below InitialBackoff) are normalised to safe defaults at client construction.
Client Middleware Chain¶
The client supports composable RoundTripper middleware, mirroring the server-side chain pattern. Middleware wraps the transport — the first in the chain executes first on the request and last on the response.
// ClientMiddleware wraps an http.RoundTripper with additional behaviour.
type ClientMiddleware func(next http.RoundTripper) http.RoundTripper
Chain API:
NewClientChain(middlewares ...ClientMiddleware) ClientChain— creates a chain(c ClientChain) Append(middlewares ...ClientMiddleware) ClientChain— returns a new chain with additional middleware (immutable)(c ClientChain) Then(rt http.RoundTripper) http.RoundTripper— applies the chain to a transport
Built-in middleware:
| Middleware | Description |
|---|---|
WithRequestLogging(log) |
Logs method, URL, status code, and duration at debug level. Headers and body are NOT logged (security). |
WithBearerToken(token) |
Injects Authorization: Bearer {token}. Sent only to the first host the client addresses, so a cross-host redirect cannot capture the token. |
WithBasicAuth(user, pass) |
Injects Authorization: Basic {base64}. Host-pinned like WithBearerToken — not re-sent across a cross-host redirect. |
WithRateLimit(rps) |
Token bucket rate limiting. Blocks until a token is available or the request context is cancelled. |
Usage example:
chain := gtbhttp.NewClientChain(
gtbhttp.WithRequestLogging(props.Logger),
gtbhttp.WithBearerToken(os.Getenv("API_TOKEN")),
gtbhttp.WithRateLimit(10), // 10 requests per second
)
client := gtbhttp.NewClient(
gtbhttp.WithTimeout(30 * time.Second),
gtbhttp.WithClientMiddleware(chain),
)
The middleware chain is applied after retry wrapping, so retry operates on the raw transport (not on logged/authed requests), and a ClientMiddleware placed in the chain therefore sits outside the retry transport. Custom middleware can be written by implementing the ClientMiddleware function signature.
Circuit Breaker¶
WithCircuitBreaker is a ClientMiddleware that fails fast while a downstream is consistently failing, avoiding wasted retry/backoff cycles against a service that will not answer. It is the partner primitive to retry: retry handles transient flakiness, the breaker handles a hard outage.
WithCircuitBreaker(log logger.Logger, cfg CircuitBreakerConfig) ClientMiddlewareDefaultCircuitBreakerConfig() CircuitBreakerConfig— threshold 5, cooldown 30s, half-open trial 1ErrCircuitOpen— sentinel error returned (wrapped) while open; test witherrors.IsCircuitBreakerConfigFromConfig(cfg config.Containable, prefix string) CircuitBreakerConfig
States: StateClosed (admit all, count failures) → StateOpen (reject immediately with ErrCircuitOpen until the cooldown elapses) → StateHalfOpen (admit a bounded number of trials; the first success closes the breaker, any failure re-opens it).
CircuitBreakerConfig fields:
| Field | Default | Description |
|---|---|---|
FailureThreshold |
5 | Consecutive failures (in Closed) that trip the breaker open |
Cooldown |
30s | How long the breaker stays Open before a trial |
HalfOpenMaxRequests |
1 | Trial requests admitted in HalfOpen |
IsFailure |
nil | Classifier; default = transport errors + 5xx are failures |
OnStateChange |
nil | Optional transition callback (metrics/telemetry) |
By default, transport errors and 5xx responses count as failures; 4xx do not — in particular a 429 does not trip the breaker (that is the retry/backoff layer's concern, not a downstream-health signal).
Ordering (composition with retry). Place the breaker in the ClientChain so it sits outside the retry transport:
The breaker sees the final post-retry verdict: one logical call that exhausts its retry budget against a dead service counts as one breaker failure, not N. Once Open, calls are rejected before entering the retry layer — so no backoff sleeps are spent on a service known to be down (exactly the waste the retry design flagged). The breaker never serves a cached response; an open breaker returns an error, never a stored body.
client := gtbhttp.NewClient(
gtbhttp.WithRetry(gtbhttp.DefaultRetryConfig()),
gtbhttp.WithClientMiddleware(gtbhttp.NewClientChain(
gtbhttp.WithCircuitBreaker(props.Logger, gtbhttp.DefaultCircuitBreakerConfig()),
gtbhttp.WithRequestLogging(props.Logger),
)),
)
Config keys (CircuitBreakerConfigFromConfig, prefix defaults to server.http): server.http.circuitbreaker.failure_threshold, .cooldown, .half_open_max_requests.
Retry-After support: When a 429 or 503 response includes a Retry-After header (seconds or HTTP-date), that value is used as the delay instead of the computed backoff. The header value is clamped to MaxBackoff, so a hostile or misconfigured server cannot stall the client beyond the configured cap.
Body rewind: Request bodies are rewound via GetBody between attempts. Response bodies from failed attempts are drained and closed to allow connection reuse. A request whose body has already been consumed and that has no GetBody to rewind it (e.g. a streamed io.Reader body) is not retried — resending it would silently send an empty or partial body. A nil body (e.g. GET) is always safe to retry.
Context cancellation: If the request context is cancelled during a backoff wait, the retry loop exits immediately with the context error.
Usage Example¶
// Simple secure client
client := http.NewClient()
// Client with automatic retry for transient failures
client := http.NewClient(
http.WithTimeout(60*time.Second),
http.WithRetry(http.DefaultRetryConfig()),
)
// Custom retry configuration
client := http.NewClient(
http.WithRetry(http.RetryConfig{
MaxRetries: 5,
InitialBackoff: 200 * time.Millisecond,
MaxBackoff: 10 * time.Second,
RetryableStatusCodes: []int{429, 502, 503, 504},
}),
)
// Custom retry predicate
client := http.NewClient(
http.WithRetry(http.RetryConfig{
MaxRetries: 3,
InitialBackoff: 500 * time.Millisecond,
MaxBackoff: 30 * time.Second,
ShouldRetry: func(attempt int, resp *http.Response, err error) bool {
if err != nil {
return true // retry all errors
}
return resp != nil && resp.StatusCode >= 500
},
}),
)
// Power user: custom client with secure transport
customClient := &http.Client{
Transport: http.NewTransport(nil),
}