Documentation
¶
Overview ¶
Package httpf holds the HTTP glue a service needs on both sides of the wire.
Inbound, it assembles a REST server from its REST_* configuration: the standard middleware chain, rate limits, and the server itself, which Serve runs under a bounded shutdown lifecycle, over TLS once the configuration names a key pair. Handler responses stay consistent with the service's OpenTelemetry tracing and structured logging: a server fault marks the request span failed, and every error is logged once. Its service token middleware admits only the services allowed to call, and tells the handlers which one called. A route can stream its response as server-sent events, past the request timeout.
Outbound, it builds the pooled, traced client a service calls other systems through, and the transport that sends a service token to another service.
Index ¶
- Constants
- Variables
- func BodyKeyRate(field string) httprate.KeyFunc
- func DecodeJSON(body io.Reader, destination any) error
- func EndOnShutdown(ctx context.Context) func(http.Handler) http.Handler
- func HandleError(ctx context.Context, logger logging.Log, w http.ResponseWriter, ...)
- func LimitConcurrency(maxConcurrent int) func(http.Handler) http.Handler
- func LimitConcurrencyBy(maxConcurrent int, key httprate.KeyFunc) func(http.Handler) http.Handler
- func ListenAndServe(ctx context.Context, config ServerConfig, handler http.Handler) error
- func NewPoolClient(options PoolOptions) *http.Client
- func NewPoolTransport(options PoolOptions) *http.Transport
- func NewRouter(config ServerConfig, tracing otel.Config, logger logging.HTTPConfig, ...) *chi.Mux
- func NewServer(ctx context.Context, config ServerConfig, handler http.Handler) *http.Server
- func SendJSONStatus[Data any](w http.ResponseWriter, span trace.Span, status int, data Data)
- func SendTelemetry(timeout time.Duration) func(http.Handler) http.Handler
- func Serve(ctx context.Context, server *http.Server, shutdownTimeout time.Duration) error
- func ServiceTokenCaller(ctx context.Context) (string, bool)
- func ServiceTokenMiddleware(verifier *servicetoken.Verifier, logger logging.Log, open ...string) func(http.Handler) http.Handler
- func Streaming(limit time.Duration) func(http.Handler) http.Handler
- func VisitorKey(r *http.Request) (string, error)
- func WithRetryAfter(err error, delay time.Duration) error
- func WithTag(err error, key string, value any) error
- type CorsConfig
- type ErrMap
- type Event
- type EventStream
- type Limits
- type PoolOptions
- type Rate
- type RateLimitStore
- type ServerConfig
- type ServerTimeouts
- type ServiceTokenTransport
Constants ¶
const InvalidFieldsTag = "invalidFields"
InvalidFieldsTag is the tag HandleError sets on a client error caused by a validator error. It maps the lower camel path of each invalid field ("email", "roles[0]") to the tag of the rule it broke ("required").
Variables ¶
var ErrInvalidBodyKey = errors.New("invalid rate limit key")
ErrInvalidBodyKey is returned by a BodyKeyRate key for a value too long to count.
var ErrInvalidEvent = errors.New("invalid server-sent event")
ErrInvalidEvent is returned by EventStream.Send for an event a client cannot read: one without data, or whose ID or type holds a line break or a NUL.
var ErrInvalidRate = errors.New("invalid rate")
ErrInvalidRate is returned by ParseRate for a value not written "<requests>/<window>".
var ErrJSONMultipleValues = errors.New("JSON request body contains multiple values")
ErrJSONMultipleValues is returned when a request body contains more than one JSON value.
var ServerConfigDefault = ServerConfig{ Port: 8080, Liveness: "/ping", Timeouts: ServerTimeouts{ Read: 15 * time.Second, ReadHeader: 3 * time.Second, Write: 30 * time.Second, Idle: 60 * time.Second, Request: 60 * time.Second, Shutdown: 30 * time.Second, }, MaxRequestSize: 16 << 10, RateLimitsEnabled: true, RateLimit: Rate{Requests: 5, Window: time.Second}, Cors: CorsConfig{ AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"*"}, AllowedMethods: []string{ http.MethodHead, http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, }, MaxAge: 3600, }, }
ServerConfigDefault holds the values LoadServerConfig keeps for unset variables. A service overrides a field on a copy before loading.
Functions ¶
func BodyKeyRate ¶ added in v0.43.0
BodyKeyRate keys a rate limit by a top-level field of the JSON body, so many callers cannot gang up on one target such as a mailbox. A string counts trimmed and case-folded, its escapes decoded so they cannot spell one target many ways. Another value counts as written. It puts the body back for the handler.
A body it cannot read or decode shares the budget of the empty value until the handler refuses it. A value over 1 KiB is refused with 400, since nothing has validated it yet.
func DecodeJSON ¶ added in v0.35.0
DecodeJSON decodes the one JSON value body holds into destination, with encoding/json's rules, so an unknown field passes. It returns an error when body is empty, malformed, or holds a second value. The handler still validates destination and writes the error response.
var request requestCreate
if err := httpf.DecodeJSON(r.Body, &request); err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
func EndOnShutdown ¶ added in v0.46.0
EndOnShutdown ends the context of a request once ctx is done. Pass as ctx the process context the server drains under. Mount it on a long request that keeps taking on work, such as a batch. The request then stops at shutdown and answers within the drain. Other requests keep their context until they finish.
func HandleError ¶
func HandleError( ctx context.Context, logger logging.Log, w http.ResponseWriter, span trace.Span, errMap ErrMap, err error, )
HandleError writes a response for a failed handler. It matches err against errMap (with errors.Is) to pick a status, logs err, and writes the status text as the body.
The body never carries err, which can hold internal detail such as a database address. The log and the trace keep it. When err carries tags from WithTag, or a client error comes from a validator, the body is an RFC 9457 problem details object holding those tags instead.
A server error logs at error level and marks the span failed. Under a router from NewRouter, the request's server span names its type through otel.ErrorType. A client error logs at warning level and leaves the span status unset, since the handler answered it.
A 429 or 503 answers with the Retry-After header (RFC 9110 §10.2.3) when err names a delay, which WithRetryAfter describes.
A gRPC dependency's planned downtime refusal anywhere in err wins over errMap: the response is the one downtime.Middleware gives, logged as a warning, since the server did nothing wrong.
func LimitConcurrency ¶ added in v0.43.0
LimitConcurrency caps the requests a route handles at once, per instance, answering any beyond it with 429 and a one-second Retry-After: a slot frees up as soon as one request returns. Use it on a route whose calls are slow or costly. Unlike Limits, test stacks never turn it off.
func LimitConcurrencyBy ¶ added in v0.58.0
LimitConcurrencyBy caps the requests sharing a key that its routes handle at once, per instance, answering any beyond it with 429 and a one-second Retry-After. Use it on routes whose requests last, such as streams, so one caller cannot hold every connection. Mount one value on several routes and they share the cap. A request whose key cannot be computed answers 400.
func ListenAndServe ¶ added in v0.44.0
ListenAndServe serves handler on the configured port until ctx is canceled, then drains active requests within the configured shutdown timeout. See Serve.
The port serves TLS once config.TLS names a key pair, which servertls.Config loads and reloads at renewal. A config.PlaintextPort also serves plaintext beside it. The listeners stop together: one that fails stops the other.
func NewPoolClient ¶ added in v0.30.0
func NewPoolClient(options PoolOptions) *http.Client
NewPoolClient returns an HTTP client for calls to other systems. It differs from http.DefaultClient in two ways:
- options size its connection pool;
- otelhttp opens a span per request and carries the trace context to the far end.
Like http.DefaultClient, it sets no timeout, so a response that takes minutes to arrive survives.
Build one per process and inject it: sharing it is what makes the pool useful.
func NewPoolTransport ¶ added in v0.30.0
func NewPoolTransport(options PoolOptions) *http.Transport
NewPoolTransport returns the transport NewPoolClient runs on, untraced. Use it to read the pool settings back, or to wrap the transport before building a client.
The transport is a clone of http.DefaultTransport, so its proxy, dial, TLS and HTTP/2 settings follow the standard library. When a program has replaced the default with another http.RoundTripper, the transport starts from the zero http.Transport instead.
func NewRouter ¶ added in v0.42.0
func NewRouter( config ServerConfig, tracing otel.Config, logger logging.HTTPConfig, downtimeStart *time.Time, ) *chi.Mux
NewRouter returns a router with the standard middleware, outermost first: panic recovery, the visitor IP, the request timeout, the body limit, tracing, the report of faults on the request's span, CORS, request logs, the planned downtime, the standard rate limit and HEAD routing. The liveness path answers during a downtime and is never rate-limited. A route under Streaming answers past the request timeout.
Every GET route answers HEAD too, as RFC 9110 §9.1 requires: the GET handler runs, and net/http drops the body it writes. One deviation is deliberate: the Allow header of a 405 lists the methods routes register, so a GET route's leaves out HEAD (§15.5.6). Listing it would wrap every response, or match the tree again, which mounted subrouters defeat.
The request's span takes the method of the request and the route chi matched, such as "GET /v2/session/{id}", and the route pattern as its http.route. A request that reaches no route keeps its method alone, such as one the rate limit refuses. Under a subrouter, an unknown path takes the subrouter's pattern, such as "GET /v2/*". A path never names a span.
A nil logger writes no request log, for a server whose spans record each request.
func NewServer ¶ added in v0.42.0
NewServer returns a server for handler on the configured port, with the configured timeouts.
Every request context carries the values of ctx, but not its cancellation. ctx is usually the process context that Serve drains the server under. A request in flight when shutdown begins then finishes within the drain. EndOnShutdown ends such a request at shutdown instead.
func SendJSONStatus ¶ added in v0.26.0
SendJSONStatus encodes data as JSON to w under status. It leaves the span status to the handler, which knows whether an error status stems from a fault.
It writes the status line itself, after the Content-Type header, since net/http freezes the headers once the status goes out. Call it on a writer that has written nothing yet.
An encoding failure is reported on the span. The status line is already sent by then, so the body may be truncated.
func SendTelemetry ¶ added in v0.53.0
SendTelemetry sends the spans and logs of a request before its answer completes, within timeout. Wrap the whole router with it, outside its tracing, so the request's own span leaves too. It suits a server no consumer waits on, billed per request: its platform withdraws the CPU once it answers, so telemetry left to the background would wait for the next request.
func Serve ¶ added in v0.33.0
Serve listens on server.Addr until ctx is canceled or the server stops. Cancellation drains active requests within shutdownTimeout, then closes remaining connections. A forced shutdown returns an error wrapping context.DeadlineExceeded. A server with a TLSConfig serves TLS, with the certificate its configuration presents.
http.ErrServerClosed is a normal result. A serving error that races with shutdown is joined with the shutdown error so callers can inspect both with errors.Is.
A command can connect process signals directly:
processCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := httpf.Serve(processCtx, server, 30*time.Second); err != nil {
log.Printf("run HTTP server: %v", err)
}
func ServiceTokenCaller ¶ added in v0.59.0
ServiceTokenCaller returns the caller of the request of ctx, such as system:serviceaccount:studio:web, once ServiceTokenMiddleware verified its service token. It reports false for a request the middleware did not verify: one to an open path, or one to a server that runs without the middleware.
func ServiceTokenMiddleware ¶ added in v0.55.0
func ServiceTokenMiddleware( verifier *servicetoken.Verifier, logger logging.Log, open ...string, ) func(http.Handler) http.Handler
ServiceTokenMiddleware refuses a request unless verifier accepts its one service token, which a client sends through ServiceTokenTransport. The handlers then read the token's caller with ServiceTokenCaller. A request to a path listed in open passes unchecked, such as the liveness route the kubelet probes without a token. HandleError answers each refusal and logs it through logger.
A refused token answers 401 with the WWW-Authenticate challenge RFC 9110 §15.5.2 requires, which [serviceTokenChallenge] describes.
func Streaming ¶ added in v0.58.0
Streaming returns the middleware of a route that streams its response, such as an EventStream. The route answers for up to limit, past the router's request timeout and the server's write timeout. The request still ends when the client disconnects.
Mount it before any middleware that ends the request, such as EndOnShutdown: it replaces the context the router's timeout set.
func VisitorKey ¶ added in v0.42.0
VisitorKey keys a limit by the visitor IP. An IPv6 visitor counts per /64, the block one client usually controls.
func WithRetryAfter ¶ added in v0.62.2
WithRetryAfter attaches delay to err, the time after which the refused request may run again. When HandleError answers err with 429 Too Many Requests or 503 Service Unavailable, it sends delay as the Retry-After header of RFC 9110 §10.2.3, in seconds rounded up. Under any other status, the delay is left out.
Call it in the handler, on the error it is about to pass to HandleError. An error that knows its own delay needs no wrapping: HandleError reads the delay of the first error in the chain with a method RetryAfter() time.Duration.
func WithTag ¶ added in v0.38.0
WithTag attaches a tag to err, which HandleError returns to the client in the response body. A nil value sends key as true. Any other value is sent as is, so it must hold only data the client may see. Tagging an error that already carries tags adds to them.
Call it in the handler, on the error it is about to pass to HandleError.
Types ¶
type CorsConfig ¶ added in v0.42.0
type CorsConfig struct {
AllowedOrigins []string `json:"allowedOrigins" yaml:"allowedOrigins"`
AllowedHeaders []string `json:"allowedHeaders" yaml:"allowedHeaders"`
AllowedMethods []string `json:"allowedMethods" yaml:"allowedMethods"`
AllowCredentials bool `json:"allowCredentials" yaml:"allowCredentials"`
// MaxAge is how long a browser caches a preflight answer, in seconds.
MaxAge int `json:"maxAge" yaml:"maxAge"`
}
CorsConfig configures cross-origin requests.
type ErrMap ¶
ErrMap maps sentinel errors to the HTTP status HandleError returns for them. An error matching several sentinels takes the highest of their statuses, so a server fault never hides behind a client error. A nil key sets the status of unmatched errors, which is 500 Internal Server Error without one.
An http.MaxBytesError anywhere in an error, the router refusing an oversized body, counts as a sentinel mapped to 413 Content Too Large (RFC 9110 §15.5.14), so a handler's 400 fallback never answers it.
type Event ¶ added in v0.58.0
type Event struct {
// ID is the event's position. A client that reconnects sends the last ID it received in the
// Last-Event-ID header, so the stream resumes after it. Empty keeps the previous ID.
ID string
// Type names the event. Empty sends the default type, "message".
Type string
// Data is the event's content, usually JSON. A client drops an event without data.
Data []byte
// Retry, when positive, sets how long the client waits before it reconnects, sent as the retry
// field in whole milliseconds, rounded down (HTML Living Standard §9.2.6). Zero or less sends none
// and keeps the client's current wait.
Retry time.Duration
}
Event is one server-sent event.
type EventStream ¶ added in v0.58.0
type EventStream struct {
// contains filtered or unexported fields
}
An EventStream writes a response as server-sent events, the text/event-stream format of the HTML standard. Every event is flushed as it is sent. A comment goes out at each heartbeat, so proxies keep an idle stream open.
Send and the heartbeat share the writer, under a lock.
func NewEventStream ¶ added in v0.58.0
func NewEventStream(ctx context.Context, w http.ResponseWriter, heartbeat time.Duration) (*EventStream, error)
NewEventStream answers 200 OK as an event stream, and starts its heartbeat. Close the stream before the handler returns. Call it on a writer that has written nothing yet: it returns an error when the writer cannot flush.
func (*EventStream) Close ¶ added in v0.58.0
func (stream *EventStream) Close()
Close stops the heartbeat, and returns once it no longer writes. The response ends once the handler returns.
func (*EventStream) Send ¶ added in v0.58.0
func (stream *EventStream) Send(event Event) error
Send writes event and flushes it. Its fields go straight to the response, under the lock the heartbeat shares.
type Limits ¶ added in v0.42.0
type Limits struct {
// contains filtered or unexported fields
}
Limits builds the rate limits a service adds to NewRouter's standard one, refusing excess requests with 429 and Retry-After. Their counters live in the process, so each instance counts on its own, unless the config names a RateLimitStore.
With a store, the instances share each limit's budget. A store that fails a command, or takes over 100 ms to answer it, sends the limit back to counting in the instance until the store answers again. The span of each request counted that way carries rate_limit.local, set to true.
Mount a limit on a route, with chi's With: it counts per route pattern, so path parameters share one budget.
func NewLimits ¶ added in v0.42.0
func NewLimits(config ServerConfig) Limits
NewLimits returns the limits, which let every request through when the config disables them. It opens no connection, so a store that does not answer yet only starts the limits counting in the instance.
type PoolOptions ¶ added in v0.30.0
type PoolOptions struct {
// MaxIdleConns caps the idle connections kept across every host.
MaxIdleConns int
// MaxIdleConnsPerHost caps the idle connections kept for a single host. Set it to at least the
// number of concurrent requests to that host. The standard library keeps two, and every call
// past that pays a fresh TLS handshake.
MaxIdleConnsPerHost int
}
PoolOptions sizes the connection pool NewPoolClient and NewPoolTransport build.
type Rate ¶ added in v0.42.1
type Rate struct {
Requests int `json:"requests" yaml:"requests"`
Window time.Duration `json:"window" yaml:"window"`
}
Rate is a number of requests allowed per window, written "<requests>/<window>", such as "3/5m".
func ParseRate ¶ added in v0.42.1
ParseRate reads "<requests>/<window>", such as "5/1s" or "3/5m", the window being a time.Duration. Both must be positive. It fits config.LoadEnv as a parser.
type RateLimitStore ¶ added in v0.60.0
type RateLimitStore struct {
// Addr is the server's host:port. Empty keeps the counters in the process.
Addr string `json:"addr" yaml:"addr"`
// Username names the server's ACL user. Empty logs in as the default user.
Username string `json:"username" yaml:"username"`
// Password is the user's credential. It is never serialized.
Password string `json:"-" yaml:"-"`
}
RateLimitStore locates the Valkey or Redis server that holds the counters of Limits. The counters key by route and caller, so each service needs a store of its own.
type ServerConfig ¶ added in v0.42.0
type ServerConfig struct {
// Port is the port the server listens on.
Port int `json:"port" yaml:"port"`
// TLS names the key pair the server presents. Once it does, [ListenAndServe] serves TLS on Port.
TLS servertls.Files `json:"tls" yaml:"tls"`
// PlaintextPort, when not zero, also serves plaintext there, so callers move to a TLS Port
// without a failed call.
PlaintextPort int `json:"plaintextPort" yaml:"plaintextPort"`
// Liveness is the ping path, which answers during a planned downtime and is never rate-limited.
Liveness string `json:"liveness" yaml:"liveness"`
// Timeouts bound each phase of a request.
Timeouts ServerTimeouts `json:"timeouts" yaml:"timeouts"`
// MaxRequestSize caps a request body, in bytes. chi cannot raise the router's limit for one
// route, so a route that needs more must sit outside it.
MaxRequestSize int64 `json:"maxRequestSize" yaml:"maxRequestSize"`
// ClientIPHeader names the header a trusted proxy sets to the visitor IP. Empty keeps the TCP
// peer address. Name one only when every request reaches the server through that proxy.
ClientIPHeader string `json:"clientIPHeader" yaml:"clientIPHeader"`
// RateLimitsEnabled turns the rate limits on. Only test stacks turn them off.
RateLimitsEnabled bool `json:"rateLimitsEnabled" yaml:"rateLimitsEnabled"`
// RateLimit caps each visitor's requests on each route, through the standard limit [NewRouter]
// installs.
RateLimit Rate `json:"rateLimit" yaml:"rateLimit"`
// RateLimitStore holds the counters of the limits [Limits] builds, so every instance shares their
// budgets. Its zero value keeps them in each instance, as the standard limit always does.
RateLimitStore RateLimitStore `json:"rateLimitStore" yaml:"rateLimitStore"`
// Cors configures cross-origin requests.
Cors CorsConfig `json:"cors" yaml:"cors"`
}
ServerConfig configures a REST server: its ports and TLS, request lifecycle, body and rate limits, and CORS. LoadServerConfig reads it from the REST_* environment variables.
func LoadServerConfig ¶ added in v0.42.0
func LoadServerConfig(getenv func(name string) string, defaults ServerConfig) ServerConfig
LoadServerConfig reads the REST_* variables through getenv, which applies the service's prefix, and keeps defaults for unset ones. It panics on a value it cannot parse.
type ServerTimeouts ¶ added in v0.42.0
type ServerTimeouts struct {
Read time.Duration `json:"read" yaml:"read"`
ReadHeader time.Duration `json:"readHeader" yaml:"readHeader"`
Write time.Duration `json:"write" yaml:"write"`
Idle time.Duration `json:"idle" yaml:"idle"`
// Request bounds handling one request, after its headers are read.
Request time.Duration `json:"request" yaml:"request"`
// Shutdown bounds the drain of active requests before remaining connections close.
Shutdown time.Duration `json:"shutdown" yaml:"shutdown"`
}
ServerTimeouts bound each phase of a request.
type ServiceTokenTransport ¶ added in v0.55.0
type ServiceTokenTransport struct {
// ServiceTokenPath is the file the kubelet projects the caller's service token into. Each request
// reads it again, so a rotated token takes effect on the next request.
ServiceTokenPath string
// Base carries the requests, such as the transport of a client from [NewPoolClient].
Base http.RoundTripper
}
ServiceTokenTransport sends the caller's service token with every request it carries, which the callee checks with ServiceTokenMiddleware. Wrap the transport of a client that calls services of the same cluster, and no other: whoever receives the token can replay it against the service it was projected for.