Help us shape the future of Go! Take the 2026 Go Developer Survey

httpx

package
v0.3.4 Latest Latest
Warning

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

Go to latest
Published: Sep 23, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package httpx provides the HTTP foundation of a gorbital app: a server that runs under app.Run with safe timeouts, security middleware, and the RFC 9457 problem+json error contract with an application-owned error mapping (ADR-0018).

A typical middleware chain, outermost first:

handler := httpx.Chain(mux,
	httpx.Recover(logger),
	httpx.RequestID(),
	httpx.AccessLog(logger),
	httpx.SecureHeaders(httpx.SecureHeadersOptions{}),
	cors,
	crossOrigin,
	httpx.BodyLimit(1<<20),
)

Stability: stable (ADR-0015, ADR-0054).

Index

Examples

Constants

View Source
const (
	DefaultReadHeaderTimeout = 5 * time.Second
	DefaultReadTimeout       = 30 * time.Second
	DefaultWriteTimeout      = 60 * time.Second
	DefaultIdleTimeout       = 120 * time.Second
	DefaultShutdownTimeout   = 20 * time.Second
	DefaultMaxHeaderBytes    = 1 << 20
)

Server timeouts used by NewServer unless overridden.

View Source
const DefaultMaintenanceMessage = "the service is down for maintenance; try again later"

DefaultMaintenanceMessage is the problem detail Maintenance sends while MaintenanceOptions.Message is unset or empty.

View Source
const ProblemContentType = "application/problem+json"

ProblemContentType is the media type of problem responses.

Variables

View Source
var ErrTrustAll = errors.New("httpx: a trusted proxy range can't cover every address")

ErrTrustAll reports a trusted-proxy range covering every address, which would let any client choose its own IP.

Functions

func Chain

func Chain(h http.Handler, middlewares ...Middleware) http.Handler

Chain wraps h with middlewares. The first middleware is the outermost: it sees the request first and the response last.

func DefaultCode

func DefaultCode(status int) string

DefaultCode returns the generic code for a status without a mapping, such as "not_found" for 404.

func ParseTrustedProxies

func ParseTrustedProxies(list string) ([]netip.Prefix, error)

ParseTrustedProxies reads a comma-separated list of CIDR ranges or single addresses, such as "10.0.0.0/8, 192.0.2.10". An empty list trusts nothing. Ranges are masked ("10.0.0.5/8" is 10.0.0.0/8), and IPv4 addresses written in IPv6 form ("::ffff:10.0.0.5") become IPv4, as peer addresses are compared; an address with a zone ("fe80::1%en0") is refused, as it could never match one. It refuses ranges covering every IPv4 or IPv6 address (ErrTrustAll), including "::ffff:0.0.0.0/96", which is every IPv4 address written in IPv6 form.

A mapped range shorter than /96 mixes the two families and is refused: netip would re-base it on a range the operator never wrote ("::ffff:10.0.0.0/8" masks to "::/8", which covers ::1).

func WriteProblem

func WriteProblem(w http.ResponseWriter, r *http.Request, p *Problem)

WriteProblem writes p as application/problem+json, filling the request ID from the request context when empty.

Types

type AccessNote

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

AccessNote carries attributes from later middleware and handlers back to AccessLog's record for the request: the authenticated user, set by the auth middleware, so request logs can be filtered by user. It is safe for concurrent use.

func AccessNoteFrom

func AccessNoteFrom(ctx context.Context) *AccessNote

AccessNoteFrom returns the request's note, or nil without AccessLog.

func (*AccessNote) Add

func (n *AccessNote) Add(attrs ...slog.Attr)

Add adds attrs to the request's log record; a nil note ignores them. A key added twice keeps the last value.

type CORSOptions

type CORSOptions struct {
	// AllowedOrigins are exact origins such as "https://app.example.com".
	// Empty disables CORS.
	AllowedOrigins []string
	AllowedMethods []string // default: GET, POST, PUT, PATCH, DELETE
	AllowedHeaders []string // default: Authorization, Content-Type, X-Request-ID
	ExposedHeaders []string // default: X-Request-ID, Retry-After
	// AllowCredentials allows cookies. It can't be combined with a wildcard origin.
	AllowCredentials bool
	MaxAge           time.Duration // preflight cache; default 10 minutes
}

CORSOptions configure CORS.

type Captured added in v0.2.0

type Captured struct {
	http.ResponseWriter
	// contains filtered or unexported fields
}

Captured is a response writer that records what the handler wrote, for middleware that reads the response after the handler returns: its status, whether headers were sent, and the body size. Create one with Capture.

Example
package main

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

	"gorbital.dev/httpx"
)

func main() {
	cw := httpx.Capture(httptest.NewRecorder())
	cw.WriteHeader(http.StatusCreated)
	_, _ = cw.Write([]byte(`{"id":"bok_1"}`))
	fmt.Println(cw.Status(), cw.Bytes(), cw.WroteHeader())
}
Output:
201 14 true

func Capture added in v0.2.0

func Capture(w http.ResponseWriter) *Captured

Capture returns w wrapped to record the response, or w itself when it is already a *Captured, so several middlewares share one record. Pass the result to the next handler:

cw := httpx.Capture(w)
next.ServeHTTP(cw, r)
if cw.Status() >= 500 { … }
Example
package main

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

	"gorbital.dev/httpx"
)

func main() {
	// logErrors logs every response with a 5xx status.
	logErrors := func(logger *slog.Logger) httpx.Middleware {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				cw := httpx.Capture(w)
				next.ServeHTTP(cw, r)
				if cw.Status() >= 500 {
					logger.ErrorContext(r.Context(), "server error", "status", cw.Status(), "bytes", cw.Bytes())
				}
			})
		}
	}

	failing := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		http.Error(w, "database unavailable", http.StatusServiceUnavailable)
	})
	rec := httptest.NewRecorder()
	logErrors(slog.New(slog.DiscardHandler))(failing).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/books", nil))
	fmt.Println(rec.Code)
}
Output:
503

func (*Captured) Bytes added in v0.2.0

func (w *Captured) Bytes() int64

Bytes returns the number of body bytes written.

Example
package main

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

	"gorbital.dev/httpx"
)

func main() {
	cw := httpx.Capture(httptest.NewRecorder())
	_, _ = fmt.Fprint(cw, "hello, ")
	_, _ = fmt.Fprint(cw, "world")
	fmt.Println(cw.Bytes())
}
Output:
12

func (*Captured) Status added in v0.2.0

func (w *Captured) Status() int

Status returns the status the handler sent: 200 when it wrote a body without calling WriteHeader, or wrote nothing at all.

Example
package main

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

	"gorbital.dev/httpx"
)

func main() {
	cw := httpx.Capture(httptest.NewRecorder())
	fmt.Println(cw.Status()) // nothing written yet: 200, as net/http sends
	cw.WriteHeader(http.StatusNotFound)
	fmt.Println(cw.Status())
}
Output:
200
404

func (*Captured) Unwrap added in v0.2.0

func (w *Captured) Unwrap() http.ResponseWriter

Unwrap supports http.ResponseController (flushing, deadlines).

func (*Captured) Write added in v0.2.0

func (w *Captured) Write(b []byte) (int, error)

Write records an implicit 200 status and the number of bytes written.

func (*Captured) WriteHeader added in v0.2.0

func (w *Captured) WriteHeader(code int)

WriteHeader records the first status code and forwards it.

func (*Captured) WroteHeader added in v0.2.0

func (w *Captured) WroteHeader() bool

WroteHeader reports whether the handler has sent the status and headers, after which a middleware can no longer change them.

Example
package main

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

	"gorbital.dev/httpx"
)

func main() {
	// A middleware that sets a header only while it still can.
	addVersion := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			cw := httpx.Capture(w)
			next.ServeHTTP(cw, r)
			if !cw.WroteHeader() {
				cw.Header().Set("X-Version", "1")
				cw.WriteHeader(http.StatusNoContent)
			}
		})
	}
	rec := httptest.NewRecorder()
	addVersion(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/", nil))
	fmt.Println(rec.Code, rec.Header().Get("X-Version"))
}
Output:
204 1

type FieldError

type FieldError struct {
	Location string `json:"location,omitempty" doc:"Where the error occurred" example:"body.name"`
	Message  string `json:"message" doc:"What is wrong" example:"expected length >= 1"`
}

FieldError describes one invalid input field. It never echoes the submitted value, which may be a password or personal data.

type MaintenanceOptions added in v0.2.0

type MaintenanceOptions struct {
	// Enabled turns maintenance mode on. Nil never turns it on.
	Enabled config.Value[bool]
	// Message is the problem detail clients see; nil or empty sends
	// DefaultMaintenanceMessage.
	Message config.Value[string]
	// RetryAfter is sent in the Retry-After header, in whole seconds; nil or
	// less than a second sends none.
	RetryAfter config.Value[time.Duration]
	// Open are the paths that keep being served while maintenance mode is
	// on. A path ending in a slash covers everything under it, so "/ops/"
	// keeps /ops/settings open; other paths match exactly. Keep health
	// checks open, or load balancers take every instance out of rotation.
	Open []string
}

MaintenanceOptions configures Maintenance. The values are read on every request, so runtime settings (config.Value) switch maintenance mode on every instance without a restart.

Example
package main

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

	"gorbital.dev/config"
	"gorbital.dev/httpx"
)

func main() {
	opts := httpx.MaintenanceOptions{
		Enabled: config.Static(true),
		Message: config.Static("Back at 10:00 UTC"),
		// Health checks exactly, and everything under /ops/.
		Open: []string{"/livez", "/readyz", "/ops/"},
	}
	rec := httptest.NewRecorder()
	httpx.Maintenance(opts)(http.NotFoundHandler()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/books", nil))
	fmt.Println(rec.Code, rec.Header().Get("Content-Type"))
}
Output:
503 application/problem+json

type Mapper

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

Mapper turns errors into problems using application-owned mappings. Errors without a mapping become a generic 500 and are logged once with the request ID. It is safe for concurrent use.

func NewMapper

func NewMapper(logger *slog.Logger, mappings ...Mapping) (*Mapper, error)

NewMapper returns a mapper with the given mappings.

func (*Mapper) Add

func (m *Mapper) Add(mappings ...Mapping) error

Add registers mappings. Each needs a non-nil error, a 4xx or 5xx status and a snake_case code. Each error is mapped once; several errors may share a code only with the same status, such as "unauthenticated" returned by different modules.

func (*Mapper) Match

func (m *Mapper) Match(err error) (*Problem, bool)

Match returns the problem for err if err is (or wraps) a *Problem or a mapped error. It never logs.

func (*Mapper) Problem

func (m *Mapper) Problem(ctx context.Context, err error) *Problem

Problem returns the problem for err. Unmapped errors become 500 "internal_error" with a generic detail and are logged.

func (*Mapper) Write

func (m *Mapper) Write(w http.ResponseWriter, r *http.Request, err error)

Write maps err and writes the problem response.

type Mapping

type Mapping struct {
	Err    error
	Status int
	Code   string
	Detail string
}

A Mapping maps a sentinel error (matched with errors.Is) to an HTTP status and stable code. Detail defaults to the error's message.

type Middleware

type Middleware func(http.Handler) http.Handler

Middleware wraps an http.Handler.

func AccessLog

func AccessLog(logger *slog.Logger) Middleware

AccessLog logs one line per request: method, path, route pattern, status, duration, response size, request ID and what later middleware noted with AccessNoteFrom (the authenticated user). Query strings and bodies are never logged.

func BodyLimit

func BodyLimit(n int64) Middleware

BodyLimit rejects request bodies larger than n bytes with a 413 problem. Bodies without a declared length are cut off at n bytes while reading.

func CORS

func CORS(opts CORSOptions) (Middleware, error)

CORS returns middleware allowing cross-origin requests from an explicit allowlist. A "*" origin is rejected when credentials are allowed.

func CrossOrigin

func CrossOrigin(trustedOrigins ...string) (Middleware, error)

CrossOrigin protects against cross-site request forgery using the browser's Sec-Fetch-Site and Origin headers (http.CrossOriginProtection). Non-browser clients, which send neither header, are allowed. Denied requests receive a 403 problem with code "cross_origin_request_denied".

func Maintenance added in v0.2.0

func Maintenance(opts MaintenanceOptions) Middleware

Maintenance answers every request with 503 and the problem code maintenance while opts.Enabled is on, except requests for opts.Open paths (ADR-0051). It reads the values from opts on each request and does no other work, so it costs no database query when the values are runtime settings.

Example
package main

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

	"gorbital.dev/config"
	"gorbital.dev/httpx"
)

func main() {
	books := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "books") })
	// In an app, Enabled, Message and RetryAfter are runtime settings, so
	// operators switch every instance without a restart.
	h := httpx.Maintenance(httpx.MaintenanceOptions{
		Enabled:    config.Static(true),
		RetryAfter: config.Static(5 * time.Minute),
		Open:       []string{"/livez", "/readyz"},
	})(books)

	for _, path := range []string{"/v1/books", "/readyz"} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
		fmt.Println(path, rec.Code, rec.Header().Get("Retry-After"))
	}
}
Output:
/v1/books 503 300
/readyz 200

func Recover

func Recover(logger *slog.Logger) Middleware

Recover turns a panic into a logged error and a 500 problem response. http.ErrAbortHandler panics are re-raised, as net/http expects.

func RequestID

func RequestID() Middleware

RequestID generates a request ID, stores it in the request context and echoes it in the response header. It ignores incoming X-Request-ID headers, so clients can't give their requests another request's ID in logs, audit events and jobs; use RequestIDFrom to accept them from trusted callers.

func RequestIDFrom

func RequestIDFrom(trusted []netip.Prefix) Middleware

RequestIDFrom is RequestID that accepts a valid incoming X-Request-ID from requests whose client address is in trusted: gateways and internal services that assign request IDs. Match addresses as this middleware sees them, after TrustedProxies. Other requests get a generated ID.

func SecureHeaders

func SecureHeaders(opts SecureHeadersOptions) Middleware

SecureHeaders sets security headers suitable for a JSON API. HTML routes such as /docs set their own Content-Security-Policy.

func TrustedProxies

func TrustedProxies(trusted []netip.Prefix) Middleware

TrustedProxies sets each request's RemoteAddr to its client's address when it arrives through trusted proxies (ADR-0052). For a request whose peer is in trusted, it walks X-Forwarded-For from the right, skips trusted addresses, and uses the first untrusted one. Requests from other peers keep their address and their forwarding headers are ignored, so clients can't choose their IP. With no trusted ranges it changes nothing.

Install it first, after recovery, so logs, audit events and rate limits see the client.

It canonicalises the ranges as ParseTrustedProxies does, and skips one that can't be: build the list with ParseTrustedProxies, which reports those as errors, rather than passing prefixes straight in.

type Problem

type Problem struct {
	Type      string       `json:"type,omitempty" doc:"URI identifying the problem type"`
	Title     string       `json:"title" doc:"Short summary of the problem type" example:"Conflict"`
	Status    int          `json:"status" doc:"HTTP status code" example:"409"`
	Code      string       `json:"code" doc:"Stable machine-readable error code" example:"project_name_taken"`
	Detail    string       `json:"detail,omitempty" doc:"Human-readable explanation" example:"project name is already taken"`
	RequestID string       `json:"request_id,omitempty" doc:"Correlates with server logs and traces" example:"req_9f86d081884c7d65"`
	Errors    []FieldError `json:"errors,omitempty" doc:"Field-level validation errors"`
}

Problem is an RFC 9457 problem details response extended with a stable machine-readable code and the request ID. Codes are public API; titles and details are not (ADR-0015).

func NewProblem

func NewProblem(status int, code, detail string) *Problem

NewProblem returns a problem with the standard title for status.

func (*Problem) ContentType

func (p *Problem) ContentType(ct string) string

ContentType returns application/problem+json for JSON responses.

func (*Problem) Error

func (p *Problem) Error() string

Error returns the detail, or the title when detail is empty.

func (*Problem) GetStatus

func (p *Problem) GetStatus() int

GetStatus returns the HTTP status. It lets frameworks such as Huma use a Problem as a status error.

type SecureHeadersOptions

type SecureHeadersOptions struct {
	// HSTSMaxAge enables Strict-Transport-Security when positive. Enable it
	// only when the app is served exclusively over HTTPS.
	HSTSMaxAge time.Duration
}

SecureHeadersOptions configure SecureHeaders.

type Server

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

Server is an HTTP server that implements app.Runner.

func NewServer

func NewServer(addr string, handler http.Handler, opts ...ServerOption) *Server

NewServer returns a server for handler listening on addr.

func (*Server) Addr

func (s *Server) Addr(ctx context.Context) (string, error)

Addr returns the listening address once Server.Run has started listening, blocking until then or until ctx is done. It is useful with port 0 in tests.

func (*Server) Run

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

Run listens and serves until ctx is done, then shuts down gracefully: it stops accepting connections and waits for in-flight requests up to the shutdown timeout. Run returns nil after a graceful shutdown.

type ServerOption

type ServerOption interface {
	// contains filtered or unexported methods
}

A ServerOption configures a Server.

func WithErrorLogger

func WithErrorLogger(logger *slog.Logger) ServerOption

WithErrorLogger routes the server's internal errors (for example TLS handshake failures) to logger.

func WithShutdownTimeout

func WithShutdownTimeout(d time.Duration) ServerOption

WithShutdownTimeout sets how long graceful shutdown may take. Default: DefaultShutdownTimeout.

func WithTimeouts

func WithTimeouts(readHeader, read, write, idle time.Duration) ServerOption

WithTimeouts overrides read-header, read, write and idle timeouts. Zero values keep the defaults.

Directories

Path Synopsis
Package ipfilter refuses requests by client address with 403 ip_not_allowed (ADR-0085):
Package ipfilter refuses requests by client address with 403 ip_not_allowed (ADR-0085):
Package timeout gives each request a deadline and answers 503 request_timeout when the handler hasn't started its response by then (ADR-0085):
Package timeout gives each request a deadline and answers 503 request_timeout when the handler hasn't started its response by then (ADR-0085):

Jump to

Keyboard shortcuts

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