Servers, Binders, and the Routes You Get for Free

Routes as data returned by a method, a constructor that refuses a misconfigured server, six operational endpoints you do not write, and the question of whether the standard library router would do.


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

Diagram in two parts: what New validates and registers at construction, and the order of the middleware chain around a matched route at request time. Both are described in the sections below.

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)
}
SentinelCondition
ErrNilBinderNo binder supplied
ErrInvalidRouteMethodEmpty or non-uppercase method
ErrInvalidRoutePathEmpty path, or one without a leading /
ErrNilRouteHandlerA route declared with no handler
ErrNilRouteMiddlewareA nil entry in a middleware list
ErrDuplicateRouteTwo routes sharing a method and path
ErrRouteRegistrationThe router rejected the pattern
ErrUnknownDefaultRouteAn unrecognised default route identifier
ErrInvalidTLSConfigA 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.

IdentifierPathPurpose
IndexRoute/Generated inventory of registered routes
IPRoute/ipThe instance’s public IP, via an outbound call
MetricsRoute/metricsThe metrics scrape endpoint
PingRoute/pingLiveness
PprofRoute/pprof/*Go’s profiling endpoints
StatusRoute/statusDependency 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:

SettingDefaultWhat it bounds
ReadHeaderTimeout1 minuteReading the request headers
ReadTimeout1 minuteReading headers and body
WriteTimeout1 minuteWriting the response
IdleTimeout1 minuteA keep-alive connection between requests
requestTimeoutunsetThe handler, via TimeoutHandler
shutdownTimeout30 secondsDraining 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.