After switching a service's request counters from r.URL.Path to r.Pattern, the label map still grows without bound. How do you find and fix what is left?
answer
- which requests never match a route
- the 404 path is caller-controlled
- look at inuse_space, not the CPU profile
- where does the fallback value come from?
- an allowlist built when you register routes
basics
~10 sUnmatched requests carry an empty r.Pattern, and a fallback to the raw path there is still caller-controlled. Confirm with a heap profile, then emit only patterns you registered and one fixed bucket otherwise.
solid answer
~50 sTake a heap profile and look at inuse_space: if the counter map and its key strings dominate live heap and keep climbing while request concurrency is flat, the label set is still open. The usual culprit is the not-found path. `r.Pattern` is empty for any request that matched no registered pattern, so a fallback like `if key == "" { key = r.URL.Path }` puts an unbounded label straight back on the one code path outsiders fully control — a scanner walking random URLs adds a permanent map entry per URL. Fix it at the label site: record every pattern you register into a set at startup, and at request time emit `r.Pattern` only if it is in that set, otherwise a single fixed value such as `"other"`. Then the map is bounded by the routing table, and the map itself will not shrink, so a restart or a rebuild clears what has already accumulated.
code
go · 13 linesvar mu sync.Mutex
var counts = map[string]int64{}
func observe(r *http.Request) {
key := r.Pattern
if key == "" {
// Every scanned URL adds a permanent entry.
key = r.URL.Path
}
mu.Lock()
counts[key]++
mu.Unlock()
}go deeper
Know that r.Pattern is empty when no route matched, and that whatever you put in its place becomes a label value. Do not reach for the raw path as the substitute.
Explain the mechanism end to end: an open label set means one map entry and one retained key string per distinct value, the not-found path is the open one, and the allowlist captured at registration closes it.
Demonstrate the investigation: two heap profiles compared in inuse_space, keys inspected, the label site identified, the fix applied where the label is chosen, and an honest statement that the existing map will not shrink without being replaced.
Turn the incident into a rule the estate can follow — a label value must come from a set fixed at build or startup time — and decide where the enforcement lives so no future service has to rediscover this.
## The symptom A long-running HTTP service shows live heap climbing steadily over days. It is not request concurrency: goroutine counts and in-flight requests are flat. It is not a cache with a documented size. Restarting fixes it, and it comes back at the same slope, which is a strong hint that something accumulates once per distinct *input value* rather than once per concurrent operation. ## Confirming it Take a heap profile — `runtime/pprof`'s heap profile, however you expose it — and read it in the `inuse_space` view, which shows bytes still live rather than bytes ever allocated. A metric-label leak has a very recognisable shape: the allocation sites at the top are map growth and string allocation, and their call stacks run back into the function that builds the label, not into anything that looks like business logic. Two profiles taken an hour apart, compared, show that same site growing. The next question is which label is open, and the cheapest way to answer it is to look at the map itself — dump a sample of keys behind a debug endpoint, or count entries. If the keys look like `/items/8213`, `/wp-login.php`, `/.env`, `/items/9942`, the diagnosis is finished. ## Why the pattern alone did not fix it Switching the label source from `r.URL.Path` to `r.Pattern` bounds the label set only for requests that matched a registered pattern. `r.Pattern` is the empty string when nothing matched, and code that treats that as "missing data to be filled in from somewhere better" typically writes: ```go key := r.Pattern if key == "" { key = r.URL.Path // one new map entry per URL anyone invents } ``` That single fallback is worse than the original bug, because the unmatched path is precisely the path an outsider chooses freely. Legitimate traffic hits your handful of routes; a scanner hits thousands of URLs you have never heard of, each one now a permanent entry in a map that only ever grows. The same hole opens if the fallback is the `Host` header, the `User-Agent`, or the first path segment. ## The fix: a closed set decided at startup The label must be drawn from a set your program knows in advance. You already have that set — it is the list of patterns you registered — so capture it as you register them: ```go var known = map[string]bool{} func handle(mux *http.ServeMux, pattern string, h http.Handler) { known[pattern] = true mux.Handle(pattern, h) } func routeLabel(r *http.Request) string { if known[r.Pattern] { return r.Pattern } return "other" } ``` Three properties are worth naming. First, the map is written only during start-up and read-only afterwards, so no lock is needed on the hot path. Second, the number of label values is now `len(known) + 1` — it grows when someone adds a route, that is, when someone deploys, which is exactly the property you wanted. Third, `"other"` is still counted, so a flood of unmatched traffic is visible as a rising `other` count instead of vanishing. The membership check may look redundant, since `r.Pattern` can only ever be a pattern the mux knows. Keep it anyway: it is the thing that makes the bound *checkable* rather than assumed, and it survives a later refactor where somebody starts passing the label in from elsewhere. ## Cleaning up what already accumulated A Go map does not release memory to the allocator when you delete keys — the bucket array stays at its high-water mark, so a map that grew to a million entries keeps that footprint even when emptied. If the accumulated map is large enough to matter, replace it with a fresh one (`counts = map[string]int64{}`) rather than deleting entries, or accept that a restart clears it. Either way, deleting entries on a timer is not a fix, because the same keys arrive again on the next scan. ## Related holes to check while you are there - A wildcard value used as a label anywhere else in the handler, via `r.PathValue`. - The status code as an exact integer combined with a route — bounded but multiplicative; a status *class* is usually enough. - Any error string used as a label. Error text often embeds an id or an address, which makes it as open as the raw path. - A label built from a header the client sets. ## What you say to the team The rule that generalises: a label value must come from a set the code fixes at build or startup time. If you cannot write down the complete list of values a label can take without looking at production traffic, it is not a label — it is a log field.
- You delete map entries that have not been touched for an hour. Why is that not a fix?The keys come back. A scanner produces fresh URLs continuously, so eviction turns a growing map into a churning one and buys only a lower ceiling, at the price of counters that reset and dashboards that lie. It also does not return memory: a Go map keeps its bucket array at the high-water mark, so you must replace the map to actually shrink it.
- How do you stop this class of bug from coming back after you fix this one instance?Make the safe path the only path: expose one helper that takes the request and returns a label, keep the route allowlist inside it, and give callers no way to pass an arbitrary string. If a label must be added, it is declared with its complete value set at startup so the bound is checkable there rather than discovered in a heap profile.
- Which profile would you take, and why not the CPU profile?The heap profile, read as inuse_space, because the question is what is still live and growing. A CPU profile shows where time goes; hashing a few extra strings per request costs almost nothing, so the leak is invisible there. Comparing two heap profiles an hour apart isolates the growing site immediately.
saying these in an interview costs you the question
- Assumes r.Pattern is always non-empty once routing works
- Falls back to the raw path or the Host header for unmatched requests
- Proposes evicting idle label keys on a timer as the fix
- Reaches for the CPU profile to diagnose growing live memory
- Believes deleting map entries returns the memory to the allocator
- Truncates the label string instead of closing the value set