Reusable HTTP building blocks for Go backend services — deliberately spanning both inbound request plumbing (middleware, metrics, SPA serving, RFC 9457 errors) and outbound calls to third-party APIs (a throttled, retrying client).
The packages are independent: each stands alone, and you only compile the dependencies
of the packages you actually import — Prometheus comes in only with middleware/metrics,
golang.org/x/time/rate only with outbound.
go get github.com/go-bumbu/http
Composable HTTP middleware using the standard func(next http.Handler) http.Handler pattern.
The combined Middleware struct is the primary entry point — it orchestrates logging, metrics,
and panic recovery in a single handler:
import (
"log/slog"
"github.com/go-bumbu/http/middleware"
"github.com/go-bumbu/http/middleware/metrics"
)
obs, err := metrics.NewObserver(metrics.Cfg{Prefix: "requests"})
// handle err
m := middleware.New(middleware.Cfg{
PanicRecover: true,
Logger: slog.Default(),
Metrics: obs,
})
mux.Handle("/", m.Wrap(handler))It depends only on the standard library; Prometheus is pulled in only when you import middleware/metrics.
Panics are logged and recorded once, then handled like net/http does:
PanicRecover: true, response not started yet: the client gets a500 Internal Server ErrorandWrapreturns normally.PanicRecover: true, response already started (a status written, body bytes written, flushed, or hijacked): a 500 can no longer be sent, soWrapre-panics withhttp.ErrAbortHandlerand net/http aborts the connection, letting the client detect the truncated reply. A handler's ownhttp.ErrAbortHandlerpanic (e.g. fromhttputil.ReverseProxy) is passed through the same way.PanicRecover: false: nothing is written; the panic is recorded (as a 500 if no status was sent) and carries on out ofWrap.
So a panic can escape Wrap either way: an outer recoverer will see it, and a test that calls the handler directly with httptest.ResponseRecorder must expect the panic rather than a normal return.
The logged url field is the request path only: the query string is never logged, since it often carries tokens or signatures.
The logged ip field is the direct peer by default; forwarding headers are ignored because any client can set them. Behind a reverse proxy, tell the middleware which proxies to trust so it logs the real client:
middleware.Cfg{
Logger: slog.Default(),
ClientIP: middleware.ClientIPFromXFF(netip.MustParsePrefix("10.0.0.0/8")),
}ClientIPDirect— the peer address, no headers (default)ClientIPFromRealIP(trusted...)—X-Real-Ip, when the peer is a trusted proxyClientIPFromXFF(trusted...)—X-Forwarded-For, walked right to left past trusted proxiesClientIPFromXFFHops(n)—X-Forwarded-Forby position, for proxies without a stable address range
ReqDelay is a standalone, development-only helper: middleware.ReqDelay{...}.Delay adds a random delay between a min and max duration to simulate slow backends.
Prometheus-backed metrics: NewObserver builds a registered request-duration
histogram and wraps it as a middleware.Observer for middleware.Cfg.Metrics
(NopObserver returns an explicit no-op). Request labels are bounded to closed sets
to avoid time-series cardinality blow-ups. This is the only package that imports the
Prometheus client, so importing middleware alone stays dependency-free.
Metrics are labelled by route template (/users/{id}), never by raw path, so
/users/1 and /users/2 share one series. The middleware resolves the route after the
handler runs, by one rule: the innermost reporter wins. A reporter is a
middleware.SetRoute call or a middleware.CaptureRoute whose mux matched, and
"innermost" means nested inside the most CaptureRoutes, whether it reports before or
after the handler. At equal depth SetRoute wins; empty reports (a mux miss,
SetRoute(r, "")) are ignored. With no report the middleware uses the http.ServeMux
pattern it sees; anything else records as unmatched. Cfg.RouteGroups is applied to
the result.
With http.ServeMux directly under the middleware nothing is needed. When another
wrapper sits in between and passes a request copy down (r.WithContext in an auth or
tracing middleware, http.TimeoutHandler, http.MaxBytesHandler, http.StripPrefix),
the mux writes its pattern onto a copy the middleware never sees, and every request
records as unmatched. Wrap the mux in CaptureRoute, directly around it:
http.ListenAndServe(":8080", m.Wrap(auth(middleware.CaptureRoute(mux))))For a mux mounted under another router or mux with http.StripPrefix, wrap the inner
mux in CaptureRoute: its pattern wins over the outer template (/api/, gorilla's
/api/{version}/, chi's /api/*), and the outer one is kept when the inner mux has no
match. The inner pattern is relative to the stripped path (GET /users/{id}, not
/api/users/{id}), so mounting the same mux under /v1 and /v2 shares its series.
To keep the full path, register the inner mux with full patterns
(GET /api/users/{id}) and skip StripPrefix.
This rule changed label values in mixed router setups: an outer SetRoute used to win
over an inner CaptureRoute, so such routes now record the inner, relative pattern
instead of the outer template. Check dashboards and RouteGroups after upgrading: an
/api group does not match a relative inner pattern like GET /users/{id}.
Other routers report their match with SetRoute, from a router-level middleware.
gorilla/mux:
// Reports gorilla's matched template. gorilla runs Use middlewares after matching.
setGorillaRoute := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if route := mux.CurrentRoute(r); route != nil {
if tmpl, err := route.GetPathTemplate(); err == nil {
middleware.SetRoute(r, tmpl)
}
}
next.ServeHTTP(w, r)
})
}
m := middleware.New(middleware.Cfg{Metrics: obs})
router := mux.NewRouter()
// A) wrap the whole router: every request is seen, including 404/405.
router.Use(setGorillaRoute)
http.ListenAndServe(":8080", m.Wrap(router))
// B) or inside the router. Order matters: m.Wrap first, so the setter runs inside it.
// gorilla skips Use middlewares for 404/405, so wrap those handlers too.
router.Use(m.Wrap, setGorillaRoute)
router.NotFoundHandler = m.Wrap(http.NotFoundHandler())
router.MethodNotAllowedHandler = m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}))Use one setup, not both: every request would be logged and counted twice.
chi runs Use middlewares before routing, so read the pattern after the handler:
router := chi.NewRouter()
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
middleware.SetRoute(r, chi.RouteContext(r.Context()).RoutePattern())
})
})
http.ListenAndServe(":8080", m.Wrap(router))Pass SetRoute a template, never the raw path: each distinct value is a new series.
Notes:
- Route shapes differ by router: a
ServeMuxpattern keeps its method when registered with one (GET /users/{id}), a gorilla or chi template has none (/users/{id}). The method is always its own label as well. - With metrics on, handlers receive a copy of the request. The middleware copies
r.Patternback for wrappers outside it, but not the path values:r.PathValueis empty outside the middleware.
Cfg.RouteGroups collapses routes under a prefix into one series:
RouteGroups: []string{"/api"} records GET /api/apple and GET /api/banana as
GET /api. Entries are paths (a method is stripped, a host is not supported); they
match whole segments (/api does not group /apiary) and the longest wins. Groups
match the resolved route, so a relative pattern from an inner CaptureRoute
(GET /users/{id} under a stripped /api) is not in an /api group.
Single Page Application handler that serves files from an fs.FS (typically embed.FS).
Requests for unknown paths fall back to index.html, allowing client-side routing.
spaHandler, err := spa.New(spa.Cfg{FS: embeddedFS, SubDir: "dist", PathPrefix: "/ui"})spa.Cfg:
FS— the filesystem holding the SPA assets (required)SubDir— sub-directory within the FS to serve from (empty for the FS root)PathPrefix— URL path the SPA is mounted under; normalised to a single leading slash, soui,/ui, and/ui/are equivalent (empty mounts at the root)
New returns an error if the served tree (FS after SubDir) has no index.html file,
so a mistyped SubDir or a missing build fails at startup. The entrypoint is served
directly, never as a directory listing.
Dotfiles are not served — GET /.env or /.git/config returns 404 — except the standard
.well-known/ tree. A plain //go:embed dist already keeps dotfiles out of the binary
(prefer it to //go:embed all:dist); the serve-time block is the defense for os.DirFS
and all: embeds.
A throttled, retrying HTTP client for a service's own outbound calls to third-party
APIs, so every such client handles a flaky provider the same way: fair-use rate
limiting, bounded exponential-backoff retries for transient failures (5xx, 429,
timeouts), Retry-After compliance, and a typed *Error carrying both the technical
detail (for logs) and a human-readable sentence naming the service (for the UI).
client := outbound.New(outbound.Cfg{Service: "Cover Art Archive", RPS: 5, Timeout: 8 * time.Second})
resp, err := client.Get(ctx, url, nil)
// on failure: outbound.HTTPStatus(err) and outbound.UserMessage(err, fallback)The Client's timeout (20s by default) bounds each attempt; set Timeout to cap the
whole call — attempts, backoff, and the body read — for user-facing requests. If the
context or budget ends after an upstream failure, the error still carries that
*Error (and errors.Is matches the context error).
This is the only package that pulls in golang.org/x/time/rate; the inbound packages above do not.
Writes RFC 9457 (application/problem+json) error responses — one consistent,
spec-shaped error body (a stable type URI, title/detail, and the escaped
request path as instance) in place of ad-hoc {"error":...} shapes. ModeMasked strips
handler-supplied detail in production while keeping a correlation reference; set
OnProblem to log the unmasked problem (same reference) so the real cause is not lost:
it receives the Details plus the validation field errors, the upstream reason, and the
upstream error, all of which a masked body omits.
pj, err := problemjson.New(problemjson.Cfg{
BaseURI: "https://errors.example.com",
Mode: problemjson.ModeMasked,
RequestID: requestIDFrom, // func(*http.Request) string
OnProblem: func(r *http.Request, p problemjson.Problem) {
slog.Error("problem", "status", p.Status, "detail", p.Detail, "reference", p.Reference,
"fields", p.Errors, "reason", p.Reason, "err", p.Err)
},
})
// handle err
pj.Write(w, r, http.StatusNotFound, problemjson.SlugNotFound, "no such release")
pj.WriteUpstream(w, r, err, "The catalog is unavailable.") // maps an outbound.*ErrorIn dev mode, WriteUpstream adds an extra reason field naming the exact failure
(e.g. unreachable) whenever the error can supply one through an optional
UpstreamReason() string method (outbound.*Error does). ModeMasked strips it from the
body (OnProblem still gets it in Problem.Reason), and
neither package imports the other — the reason travels through the method name alone.