Skip to content

HTTP server reference

Everything gitlab.com/phpboyscout/go/transport/http exposes: the typed settings, the two option families, the defaults they override, and the errors each entry point returns.

Constructors and lifecycle functions

func NewServer(ctx context.Context, settings ServerSettings, handler http.Handler,
    opts ...ServerOption) (*http.Server, error)

func Register(ctx context.Context, id string, controller controls.Controllable,
    logger *slog.Logger, handler http.Handler, settings ServerSettings,
    opts ...any) (*http.Server, error)

func StartWithTLSPair(logger *slog.Logger, srv *http.Server, tlsPair gtls.Pair) controls.StartFunc
func Stop(logger *slog.Logger, srv *http.Server) controls.StopFunc
func Status(srv *http.Server) controls.StatusFunc

There is no exported Start in transport/http — the HTTP start function is StartWithTLSPair, which takes the TLS pair explicitly. (transport/grpc does export Start; the two packages are not symmetrical here.)

Register is the supervised path: it builds the server, mounts the health endpoints, applies the body cap, and calls controller.Register(id, …) with start, stop and status functions wired in. NewServer is the bare path: it returns a configured *http.Server and nothing else.

A registered server is single-use

Do not give this service a controls.RestartPolicy. The server is built once and captured by the start, stop and status functions; a restart rebuilds the listener and hands it to a server that can no longer serve, and the error saying so is filtered, so the restart reports success while nothing listens. See Can a registered server be restarted in-process?

What NewServer does not do that Register does

NewServer returns a *http.Server; it does not mount health endpoints, does not apply the request-body cap, does not apply a middleware chain, and cannot serve TLS from a gtls.Pair. Those are all Register behaviour:

Behaviour NewServer Register
/healthz, /livez, /readyz mounted no yes
Request-body cap applied no yes (1 MiB default)
WithMiddleware chain applied no — not accepted yes
WithTLSPair honoured no — not accepted yes
WithServedCertificate honoured yes yes
Registered with a controls supervisor no yes

WithTLSPair and WithMiddleware are RegisterOption values, and NewServer only accepts ServerOption. Passing a pair to a server you start yourself therefore does not compile; serve TLS from NewServer either through StartWithTLSPair or with WithServedCertificate.

ServerSettings fields

type ServerSettings struct {
    Host           string `mapstructure:"host"             yaml:"host"             json:"host"`
    Port           int    `mapstructure:"port"             yaml:"port"             json:"port"`
    MaxHeaderBytes int    `mapstructure:"max_header_bytes" yaml:"max_header_bytes" json:"max_header_bytes"`
}
Field Default (zero value) Effect When it is wrong
Host ""binds every interface (:port) The interface the listener binds. "127.0.0.1" restricts it to loopback. An unresolvable or unowned address fails at start with failed to listen, not at construction.
Port 0OS-assigned ephemeral port The listen port. Not range-checked. A value outside 0–65535 reaches net.Listen and fails at start with failed to listen. Only WithPort validates.
MaxHeaderBytes 0 → falls back to 1 MiB (1 << 20) http.Server.MaxHeaderBytes. There is no way to express "zero header bytes"; 0 always means "use the default".

A zero Port is deliberately permissive rather than an error — it is how you ask for an ephemeral port in a test. The start log line reports the address the listener actually bound, so the assigned port is visible there.

ServerOption: construction settings

Accepted by both NewServer and Register.

Option Default Effect
WithPort(int) ServerSettings.Port Overrides the settings port. Validated: outside 0–65535 returns http: invalid port <n> (must be 0-65535) from NewServer/Register.
WithHost(string) ServerSettings.Host Overrides the bind interface. "" restores all-interfaces.
WithBindAddress(string) Exact alias of WithHost, for callers who think in bind addresses.
WithMaxHeaderBytes(int) 1 MiB Overrides ServerSettings.MaxHeaderBytes and the built-in default. 0 is treated as unset.
WithReadTimeout(time.Duration) 5s http.Server.ReadTimeout. 0 disables it for every route.
WithWriteTimeout(time.Duration) 10s http.Server.WriteTimeout. 0 disables it for every route.
WithIdleTimeout(time.Duration) 120s http.Server.IdleTimeout — keep-alive idle between requests.
WithServerTLSConfig(*tls.Config) gtls.DefaultConfig() Replaces the hardened default config. A gtls.Pair is later merged into it, not substituted.
WithServedCertificate(*tls.Certificate) none Serves TLS from an in-memory certificate with no pair. The certificate is appended to a clone of the TLS config, so a caller-supplied config is never mutated.

WithServedCertificate is the one option validated at construction: a certificate with an empty chain or a nil private key returns http: WithServedCertificate requires a certificate with a chain and a private key.

RegisterOption: registration-only settings

Accepted by Register only.

Option Default Effect
WithTLSPair(gtls.Pair) zero pair (Enabled: false) Serves TLS from certificate files at start. A pair with Enabled: false means plain HTTP, so a config-driven caller can pass a disabled pair unconditionally.
WithMiddleware(transithttp.Chain) none Wraps the caller's handler. The health endpoints are mounted outside the chain and never see it.
WithMaxRequestBodyBytes(int64) DefaultMaxRequestBodyBytes = 1 MiB (1 << 20) Caps every request body via http.MaxBytesReader. Server-wide; use WithBodyLimit for one route.

Register takes opts ...any and type-switches. An argument that is neither a ServerOption nor a RegisterOption is rejected rather than ignored:

http: Register received an unsupported option of type <T> (want ServerOption or RegisterOption)

What happens when a request body exceeds the limit

MaxBytesMiddleware wraps the body in http.MaxBytesReader. Reading past the limit returns the error http: request body too large to your handler. The server does not write a status for you — the response status is whatever your handler writes, so a handler that ignores the read error still returns 200. Translate the error yourself if you want a 413.

Setting a different limit for one route

WithBodyLimit(w, r, n) returns a copy of the request whose body is bounded by n instead of the server-wide cap. It sets the limit rather than only raising it: below the server cap tightens that request, above it loosens that request, and zero or less removes the bound for that request. Every other request keeps the server default.

r, err := transporthttp.WithBodyLimit(w, r, 10<<20)
condition result
request passed through MaxBytesMiddleware new request with the limit applied, nil error
it did not (hand-wired server, direct handler test) the original request unchanged, ErrNoBodyLimitStash

You must use the returned request — the one passed in is not modified. Discarding the result leaves the server-wide cap in force. Loosening fails safe (the oversized body is still rejected, so you notice); tightening fails open (the looser cap stays and your stricter limit is never applied).

Generated handlers such as a grpc-gateway mux consume the body themselves, so call this from middleware wrapped around them rather than trying to call it inside.

WithMaxRequestBodyBytes(0) disables the cap entirely, the same as a negative value: the middleware only wraps the body when the limit is greater than zero. If you meant "reject every body", zero is not it — there is no way to express a zero-byte limit through this option.

Health endpoint paths, statuses and bodies

Register mounts three endpoints on its own mux, ahead of the caller's handler:

Path Source 200 when 503 when
/healthz controller.Status() OverallHealthy otherwise
/livez controller.Liveness() OverallHealthy otherwise
/readyz controller.Readiness() OverallHealthy otherwise

Each writes Content-Type: application/json and the JSON-encoded controls.HealthReport. The same three handlers are exported — HealthHandler, LivenessHandler, ReadinessHandler — as http.HandlerFunc values you can mount yourself on the NewServer path.

The paths are not configurable, and they are registered as exact patterns ahead of the catch-all /. A caller's handler that also serves /healthz is shadowed on the Register path.

Health endpoints sit outside the middleware chain, so a probe is never gated by authentication. They are still inside the request-body cap.

AuthMiddleware options

func AuthMiddleware(opts ...AuthOption) (transithttp.Middleware, error)
Option Effect
WithBearerVerifier(authn.Verifier) Reads Authorization: Bearer <token> (scheme match is case-insensitive).
WithAPIKeyHeader(header string, authn.Verifier) Reads the named header, e.g. X-API-Key.
WithCookieVerifier(name string, authn.Verifier) Reads the named cookie — the ambient credential.
WithMTLSVerifier(authn.CertVerifier) Authenticates from r.TLS.VerifiedChains.
WithAuthorize(authn.AuthorizeFunc) Predicate run after verification; a false result is a 403.
WithAuthLogger(*slog.Logger) Logger for redacted failure logging. Default: a discard logger — failures are silent.
WithAuthSkipper(func(*http.Request) bool) Skips auth for matching requests.

With no verifier at all, construction fails with http: AuthMiddleware requires at least one verifier (fail-closed). It never degrades to a pass-through.

Which credential wins when a request presents several

  1. Bearer and API-key header together → rejected as ambiguous (401). This is deliberate: guessing which one the caller meant is how a weaker credential gets used.
  2. Bearer alone → verified with the bearer verifier.
  3. API-key header alone → verified with the API-key verifier.
  4. Cookie → consulted only when no header credential was presented.
  5. mTLS → only when no header and no cookie credential was presented, and the connection carries at least one verified chain.
  6. Nothing presented → 401.

The cookie sits below the headers because a browser attaches it to every request, including <img>/<audio>/<video> sub-resource loads that cannot carry an Authorization header. That is what the cookie is for; it must not override an explicit API credential.

What a failed request sees

Outcome Status Body Header
Authentication failed 401 {"error":"unauthorized"} WWW-Authenticate: Bearer — only when a bearer verifier is configured
Authorization denied 403 {"error":"forbidden"}

The body never says why. The specific cause is logged once at WARN with the credential passed through redact.Error. Read the verified identity downstream with IdentityFromContext(ctx), which shares its context key with the gRPC interceptor.

SecurityHeadersMiddleware defaults

func SecurityHeadersMiddleware(opts ...SecurityHeadersOption) transithttp.Middleware

Set on every response, before the wrapped handler runs, so a handler that writes its own response still emits them. A handler may override any of them.

Header Default Option Empty value means
X-Content-Type-Options nosniff WithContentTypeOptions(string) header omitted (not recommended)
X-Frame-Options DENY WithFrameOptions(string) header omitted; the CSP frame-ancestors directive is unaffected
Referrer-Policy no-referrer WithReferrerPolicy(string) header omitted
Content-Security-Policy frame-ancestors 'none' WithContentSecurityPolicy(string) falls back to the frame-ancestors default — the clickjacking control is never silently dropped
Strict-Transport-Security not set WithHSTS(maxAge, includeSubdomains, preload) a non-positive maxAge leaves HSTS off

A non-empty CSP replaces the default wholesale; the caller then owns the complete policy, including re-stating frame-ancestors if they still want it.

HSTS is off by default because it is only meaningful over TLS. Advertising it from a server that is also reachable over plain HTTP can wedge clients onto HTTPS for hosts that do not serve it. The value is assembled as max-age=<seconds> plus ; includeSubDomains and ; preload when requested.

Server-Sent Events helpers

Symbol What it is
EventStreamContentType The constant "text/event-stream". Set it as Content-Type before the first event.
WriteEvent(w, event string, data []byte) error Writes one SSE frame and flushes it through http.NewResponseController.
ErrInvalidEventName Returned when the event name contains \r or \n.

WriteEvent behaviour worth knowing before you call it:

  • An empty event name writes a frame with no event: line, which clients receive as the default message event.
  • Multi-line data becomes consecutive data: lines, and \r\n/\r are normalised to \n first, so a payload containing a newline cannot forge extra fields.
  • Zero-length data still writes one empty data: line, so the frame is well-formed rather than a bare terminator.
  • A newline in the event name is rejected outright rather than stripped: silently stripping would deliver an event under a name the caller did not choose.
  • It does not touch the write deadline. See Stream a response for the per-request opt-out and why it is not done for you.

Error strings this package returns

Error Raised by Cause
http: invalid port <n> (must be 0-65535) NewServer, Register WithPort outside the valid range.
http: WithServedCertificate requires a certificate with a chain and a private key NewServer, Register Certificate with no chain, or no private key.
http: Register received an unsupported option of type <T> (want ServerOption or RegisterOption) Register An option value from another package's option family.
http: AuthMiddleware requires at least one verifier (fail-closed) AuthMiddleware No verifier configured.
http: SSE event name must not contain a newline (ErrInvalidEventName) WriteEvent \r or \n in the event name.
loading TLS configuration: … the start function The pair's certificate or key could not be loaded. Raised synchronously at start, so a bad certificate fails the controller rather than dying inside the serve goroutine while /healthz still reports healthy.
failed to listen: … the start function The address is in use, out of range, or not ownable.

Shutdown behaviour

Stop calls srv.Shutdown(ctx) to drain in-flight requests. If that context expires — or Shutdown errors for any other reason — the server is force-closed with srv.Close(), so a hung handler cannot hold the listener and its connections open. The shutdown deadline comes from the controls supervisor, not from this package.

The per-request context is deliberately detached from the construction context with context.WithoutCancel, so cancelling the context you passed to NewServer/Register does not cancel requests that are already in flight. Values on that context are preserved.

Status reports the serve goroutine's exit error on the Register path, so a server whose serve loop has died stops being reported as healthy. The exported Status(srv) — for a hand-wired server — only reports an error when srv is nil; it cannot detect serve-goroutine death.

HTTP protocol versions

Over TLS the server negotiates HTTP/2 via ALPN: ServeTLS advertises h2, http/1.1 and an HTTP/2-capable client gets HTTP/2. Over plain HTTP the server speaks HTTP/1.1 only — cleartext HTTP/2 (h2c) is not wired up, and there is no option to enable it.