Reverse-proxy example
A production-shaped HTTP gateway built on top of MuxMaster and the standard library's httputil.ReverseProxy: catch-all path routing, round-robin load balancing with a lock-free atomic counter, per-route gating for an admin upstream, and the opt-in PoolRequestBundle for zero-allocation dispatch on every proxied request.
Why this example is pool-safe
PoolRequestBundle recycles the per-request bundle the instant the handler returns. httputil.ReverseProxy synchronously forwards the request and waits for the upstream response before returning — it never spawns a goroutine that survives ServeHTTP. The Rewrite hook mutates the bundle's r.URL inline, never captures r, and returns before the proxy hands the response back to the client. The contract is satisfied; pooling is safe.
The original *http.Request is also never mutated: Rewrite writes through pr.Out, the fresh outbound request the proxy will send upstream.
Step 1 — Construct the gateway router
mux := mm.New()
mux.PoolRequestBundle = true
mux.Pre(mw.RequestID(), mw.RecovererWithLogger(log))
Pre runs once per request, before routing. RequestID correlates every gateway log line with the proxied response; RecovererWithLogger protects the proxy itself against a panic in any per-route wrapper.
Step 2 — Build the upstream targets
The example wires three upstreams: a static server on :9001, an admin server on :9002, and a /api group that fans out across both. Targets are parsed once at boot.
upstream1 := mustURL("http://127.0.0.1:9001")
upstream2 := mustURL("http://127.0.0.1:9002")
staticProxy := newProxy("static", log, upstream1)
adminProxy := newProxy("admin", log, upstream2)
apiBalanced := newRoundRobin("api", log, upstream1, upstream2)
Step 3 — newProxy — a single-target proxy with Rewrite
Rewrite is the post-Go-1.20 way to retarget a ReverseProxy. It receives a *httputil.ProxyRequest whose pr.Out field is the fresh outbound request — mutating it is what redirects traffic at the upstream layer.
func newProxy(name string, log *slog.Logger, target *url.URL) http.HandlerFunc {
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.Out.URL.Scheme = target.Scheme
pr.Out.URL.Host = target.Host
// The catch-all param "path" contains the captured suffix.
// E.g. for the route "/static/*path", a request to
// "/static/css/app.css" sets path = "/css/app.css".
pr.Out.URL.Path = mm.PathParam(pr.In, "path")
if pr.Out.URL.Path == "" {
pr.Out.URL.Path = "/"
}
pr.Out.Host = target.Host
pr.SetXForwarded()
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Error("proxy error", "name", name, "path", r.URL.Path, "err", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
},
}
return rp.ServeHTTP
}
pr.SetXForwarded() populates the canonical X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto headers from the inbound request — the receiving upstream sees who originally connected. ErrorHandler converts transport failures into a 502 Bad Gateway so a downed upstream never leaks a misleading 5xx from net/http.
Step 4 — newRoundRobin — lock-free load balancing
Round-robin distribution across N upstreams using atomic.Uint64 — no mutex on the hot path. Each backend gets its own newProxy-wrapped handler so logs and error counters are per-target.
func newRoundRobin(name string, log *slog.Logger, targets ...*url.URL) http.HandlerFunc {
proxies := make([]http.HandlerFunc, len(targets))
for i, t := range targets {
proxies[i] = newProxy(fmt.Sprintf("%s[%d]", name, i), log, t)
}
var counter atomic.Uint64
return func(w http.ResponseWriter, r *http.Request) {
idx := counter.Add(1) % uint64(len(proxies))
proxies[idx](w, r)
}
}
atomic.Uint64.Add is wait-free on every architecture MuxMaster supports — the gateway scales linearly with concurrent connections.
Step 5 — Wire the routes
Catch-all parameters (*path) capture the full suffix and pass it through to the upstream. The same apiBalanced handler is registered for every relevant HTTP method.
// /api/* — round-robin to :9001 + :9002
mux.GET("/api/*path", apiBalanced)
mux.POST("/api/*path", apiBalanced)
mux.PUT("/api/*path", apiBalanced)
mux.DELETE("/api/*path", apiBalanced)
// /static/* — always to :9001
mux.GET("/static/*path", staticProxy)
mux.HEAD("/static/*path", staticProxy)
// /admin/* — :9002, gated behind a token
admin := mux.Group("/admin")
admin.Use(adminAuth)
admin.GET("/*path", adminProxy)
The admin group uses Use(adminAuth) to apply the X-Admin-Token: letmein gate at registration time — the gate is wrapped into the registered handler, so it costs nothing on a request that already missed the gate.
Try it
# Terminal 1: a fake backend on :9001
go run . backend 9001
# Terminal 2: a second fake backend on :9002
go run . backend 9002
# Terminal 3: the gateway on :8080
go run .
# In another shell
curl http://localhost:8080/api/users # round-robin → :9001 or :9002
curl http://localhost:8080/static/x.png # → :9001
curl -H 'X-Admin-Token: letmein' \
http://localhost:8080/admin/dashboard # → :9002
Frequently asked questions
Why is the proxy pool-safe when httputil.ReverseProxy may stream the response back?
httputil.ReverseProxy.ServeHTTP is synchronous — it issues the upstream request, copies the response back to the client, and returns. It does not spawn a goroutine that survives the call. The Rewrite hook mutates pr.Out (the fresh outbound request), never r itself. The lifetime contract for PoolRequestBundle is satisfied, so the bundle recycles cleanly the instant ServeHTTP returns.
Where do I add per-upstream timeouts?
Set the Transport field on each httputil.ReverseProxy. The stdlib http.Transport supports DialContext, ResponseHeaderTimeout, IdleConnTimeout, and a per-request *http.Request.Context() deadline. For the gateway level, wrap each call with the mw.Timeout(d) middleware from middleware/timeout.go — it cancels the request context after d and returns 503 Service Unavailable if the upstream is still in flight.
Can I weight the round-robin?
Replace newRoundRobin with a weighted scheme. The simplest is a "ticket" slice ([]http.HandlerFunc where heavier upstreams appear more than once) plus the same atomic.Uint64.Add counter — no mutex, still wait-free. For dynamic weights based on health, use a smoothed weighted round robin (SWRR) implementation; both are independent of MuxMaster's routing.
Upstream source
Every code excerpt above is lifted verbatim from examples/reverse-proxy/main.go at the v1.1.0 tag. The upstream file also contains the runBackend fake-backend helper used to test the gateway locally, the index page, and the graceful-shutdown wiring — follow the link for the full program.
See also
- Maximum performance — why
Rewriteis pool-safe (the proxy returns beforeServeHTTPexits). - Routing documentation — the catch-all
*pathparameter used by every proxy. - Server-sent events example — another pool-safe streaming pattern.