Table of contents
The server page 02 built from net.Listen,
http.Server and a ServeMux works. What it lacks is timeouts set to something
other than infinity, a route list that cannot contain a route nothing can reach,
operational endpoints, and a shutdown that drains rather than drops.
Routes are data
type Route struct {
Method string
Path string
Description string
Handler http.HandlerFunc
Middleware []MiddlewareFn
DisableLogger bool
Timeout time.Duration
}
and a handler package returns a list of them:
func (h *HTTPHandlerPublic) BindHTTP(_ context.Context) []httpserver.Route {
return []httpserver.Route{
{
Method: http.MethodGet,
Path: "/uid",
Description: "Generates a random UID",
Handler: h.handleGenUID,
},
}
}
That is the Binder interface, which has exactly one method.
The usual arrangement hands the handler package a router and calls
router.GET("/uid", h.handleGenUID). Registration by side effect works and costs
three things. Testing a route list then means constructing a router and inspecting
its internals, or sending requests through it, where here it means calling a
function and comparing a slice:
routes := h.BindHTTP(ctx)
require.Len(t, routes, 1)
require.Equal(t, http.MethodGet, routes[0].Method)
require.Equal(t, "/uid", routes[0].Path)
The handler package also stops depending on the router: httphandlerpub imports
httpserver for the Route type and knows nothing about how routing happens, so
swapping the router is a change in one file. And the server sees every route
before registering any. Duplicate detection takes the whole list, and so does
the generated index.
Description is what the / index route prints, so the monitoring listener
serves an inventory of the service’s endpoints generated from the same data that
registered them, incapable of drifting from it.
Fail in New, not at the first request
httpserver.New does the whole of the top half before it returns. Everything it
can check, it checks, and it reports problems as wrapped sentinel errors:
srv, err := httpserver.New(ctx, binder, opts...)
if err != nil {
return fmt.Errorf("error creating monitoring HTTP server: %w", err)
}
| Sentinel | Condition |
|---|---|
ErrNilBinder | No binder supplied |
ErrInvalidRouteMethod | Empty or non-uppercase method |
ErrInvalidRoutePath | Empty path, or one without a leading / |
ErrNilRouteHandler | A route declared with no handler |
ErrNilRouteMiddleware | A nil entry in a middleware list |
ErrDuplicateRoute | Two routes sharing a method and path |
ErrRouteRegistration | The router rejected the pattern |
ErrUnknownDefaultRoute | An unrecognised default route identifier |
ErrInvalidTLSConfig | A TLS configuration carrying no certificate material |
The uppercase rule has a concrete failure behind it: the router matches methods
case-sensitively, so a route registered as get binds successfully and matches
nothing a standard client sends. An endpoint that exists and cannot be reached is
a bug that survives code review and lands in an integration test at best.
The TLS check is the same shape. tls.NewListener performs no validation, so a
configuration with no certificate binds happily and fails every handshake at
runtime.
Route registration runs under a recover, because the router panics on
conflicting wildcards and malformed patterns, so a route conflict is an error
value with a message rather than a stack trace on stderr. After New returns,
every route is registered, the pattern set is consistent, the TLS material is
usable, and the port is held.
The listener is bound in New
New calls net.Listen itself. On a successful return the address is taken.
That creates one obligation. The standard library’s Shutdown only knows about
listeners Serve handed it, so a server built and never started would sit on its
port until the process exits. Shutdown here closes the listener itself to cover
that path.
Page 05 has that code.
The case that hits it is a startup that fails partway: three servers created in
sequence, the third address already in use, bind returning an error. Without the
explicit close, the first two hold their ports while the process tears down, and a
supervisor restarting immediately hits “address already in use” on a port nothing
is serving.
The middleware chain, in order
commonMiddleware builds it, and the order is fixed:
if c.maxRequestBodyBytes > 0 {
middleware = append(middleware, maxBodyMiddlewareFn)
}
if !c.disableRouteLogger && !noRouteLogger {
middleware = append(middleware, LoggerMiddlewareFn)
}
if timeout > 0 {
middleware = append(middleware, timeoutMiddlewareFn)
}
return append(middleware, c.middleware...)
Then the per-route middleware is appended after that, and ApplyMiddleware
wraps in reverse so the first entry ends up outermost.
The body cap is outermost so it wraps the original ResponseWriter and lets
net/http handle a too-large request by closing the connection
properly. It also caps the debug request dump that the logger performs, which
would otherwise buffer an unbounded body into memory in order to log it.
The logger sits above the timeout. A request that times out still produces a log line with a 503, because the logging middleware is outside the thing that gave up. Reversed, a timed-out request would log nothing, which is the case you most want a record of.
The timeout is http.TimeoutHandler. It runs the handler in a goroutine,
buffers the response, and if the deadline passes first it discards the buffer and
writes 503. The buffering makes it wrong for streaming endpoints, so
DisableTimeout exists as a per-route escape:
{
Method: http.MethodGet,
Path: "/events",
Handler: h.streamEvents,
Timeout: httpserver.DisableTimeout,
}
And the handler goroutine is not killed. It keeps running until it notices its
context is done, which is the argument from
page 02 for passing ctx into every call
that can block, made concrete: without it, the 503 goes out and the query
continues to hold a connection.
WithMiddlewareFn adds the global layer. The reference service uses it for
metrics:
middleware := func(args httpserver.MiddlewareArgs, next http.Handler) http.Handler {
return m.InstrumentHandler(args.Path, next.ServeHTTP)
}
args.Path is the registered route pattern rather than the request URL, which
is the difference between a metric with one time series per endpoint and a
metric with one per item ID. Page 13
comes back to that.
Panic recovery
Panic handling is at the router, outside the whole chain, which is where it has to be to catch a panic raised anywhere inside it. The handler logs the panic with a stack trace and answers through the configured panic handler, so a client gets a well-formed 500 in the service’s normal response shape rather than a dropped connection.
One exception:
perr, ok := p.(error)
if ok && errors.Is(perr, http.ErrAbortHandler) {
panic(p)
}
http.ErrAbortHandler is the standard library’s documented way for a handler to
abort a response without logging, and re-raising it preserves that contract.
Swallowing it would turn a deliberate abort into a 500.
The routes you get for free
httpserver.WithEnableAllDefaultRoutes()
on the monitoring server, and
httpserver.WithEnableDefaultRoutes(httpserver.PingRoute)
on the private and public ones.
| Identifier | Path | Purpose |
|---|---|---|
IndexRoute | / | Generated inventory of registered routes |
IPRoute | /ip | The instance’s public IP, via an outbound call |
MetricsRoute | /metrics | The metrics scrape endpoint |
PingRoute | /ping | Liveness |
PprofRoute | /pprof/* | Go’s profiling endpoints |
StatusRoute | /status | Dependency health |
They are enabled selectively. /pprof serves goroutine stacks, heap layouts and
CPU profiles, / enumerates every endpoint, /metrics describes the service’s
internals, and /ip makes an outbound call to a third party on request, which is
an amplification primitive if it is publicly reachable.
Page 17 goes through what each one
leaks.
/metrics is 501 Not Implemented until a handler is supplied:
httpserver.WithMetricsHandlerFunc(m.MetricsHandlerFunc())
so the package does not depend on Prometheus, and a service using statsd gets a metrics route that reports plainly that it has nothing to serve there.
/status starts as a static “the process is up” answer and is replaced with a
dependency-aware one when there are dependencies to check:
httpserver.WithStatusHandlerFunc(statusHandler)
Page 13 covers the upgrade.
Two servers from one function
The private and public servers differ by name, address, timeout and binder. Everything else is identical, so they share a constructor:
func startServiceServer(
ctx context.Context,
name string,
binder httpserver.Binder,
srv cfgServer,
l *slog.Logger,
middleware httpserver.MiddlewareFn,
logRedactor *redact.Redactor,
wg *sync.WaitGroup,
sc chan struct{},
) error {
opts := []httpserver.Option{
httpserver.WithLogger(l),
httpserver.WithServerAddr(srv.Address),
httpserver.WithRequestTimeout(time.Duration(srv.Timeout) * time.Second),
httpserver.WithMiddlewareFn(middleware),
httpserver.WithTraceIDHeaderName(traceid.DefaultHeader),
httpserver.WithEnableDefaultRoutes(httpserver.PingRoute),
httpserver.WithRedactFn(logRedactor.BytesToString),
httpserver.WithShutdownWaitGroup(wg),
httpserver.WithShutdownSignalChan(sc),
}
server, err := httpserver.New(ctx, binder, opts...)
if err != nil {
return fmt.Errorf("error creating %s HTTP server: %w", name, err)
}
server.StartServer()
return nil
}
Nine parameters is a lot, and it is the true count of what a server depends on. A struct of dependencies trades the long signature for something easy to leave partly populated. The monitoring server does not use this helper, because it differs in almost every option, and forcing it through would mean a parameter per difference.
Timeouts, and the ones that are set for you
The defaults:
| Setting | Default | What it bounds |
|---|---|---|
ReadHeaderTimeout | 1 minute | Reading the request headers |
ReadTimeout | 1 minute | Reading headers and body |
WriteTimeout | 1 minute | Writing the response |
IdleTimeout | 1 minute | A keep-alive connection between requests |
requestTimeout | unset | The handler, via TimeoutHandler |
shutdownTimeout | 30 seconds | Draining on shutdown |
Every one of these is infinite in a bare http.Server. A service written from
the net/http documentation is one slow client away from holding a connection
forever, and ReadHeaderTimeout is the defence against headers sent one byte at
a time. The request timeout comes from configuration, 60 seconds by
default, and is the outer bound on
page 09’s budget.
Why not the standard library router
Go 1.22 gave http.ServeMux method matching and path wildcards, so the gap
narrowed a great deal without closing.
Precedence. ServeMux resolves overlapping patterns by specificity, with a
documented rule and a runtime panic when two patterns conflict without one being
more specific. httprouter uses a radix tree that rejects conflicting wildcards
at registration. Both catch conflicts. ServeMux’s specificity rule is more
flexible and takes longer to predict; the tree is stricter and easier to reason
about: /items/{id} and /items/search is a conflict here and an ordered
resolution there.
Trailing slashes. ServeMux treats a pattern ending in / as a subtree
root, and registering /items/ makes it match /items/anything.
httprouter issues a 301 redirect between /items and /items/ when only
one is registered. Both are defensible and different, and the redirect happens
before the middleware chain runs, so those responses never appear in the request
log. That is documented on New, and it is the answer when a log line seems to be
missing.
Allocation. httprouter matches without allocating and stores path
parameters on the request context. ServeMux’s wildcard matching allocates.
On the scale of a service that also talks to a database, this is not the reason
to choose either.
What the toolkit needs from it. NotFound, MethodNotAllowed and
PanicHandler as replaceable fields. ServeMux has no equivalent of the last
two, so a ServeMux-based version would implement method-not-allowed and panic
recovery as middleware.
Swapping it back is a real option, via WithRouter, and the cost is that per-route
patterns and the three router-level handlers become your problem. chi and echo
are the other common answers, and chi in particular is close in spirit to this
arrangement: http.Handler all the way down, no framework context type.
Routing is a solved problem and the choice is reversible. It is not the interesting decision in the service.
Next: The Middleware You Will Actually Need, the layer the reference service deliberately leaves to you.