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 |
0 — OS-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:
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.
| 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¶
| 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¶
- 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.
- Bearer alone → verified with the bearer verifier.
- API-key header alone → verified with the API-key verifier.
- Cookie → consulted only when no header credential was presented.
- mTLS → only when no header and no cookie credential was presented, and the connection carries at least one verified chain.
- 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¶
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 defaultmessageevent. - Multi-line data becomes consecutive
data:lines, and\r\n/\rare normalised to\nfirst, 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.