Skip to content

Troubleshooting

wiki edited this page Sep 4, 2026 · 1 revision

Troubleshooting

Every client shares one bucket

TrustedProxies is not configured and something sits in front of the application. RemoteAddr is then the proxy's address — identical for every client — so the per-client limiter is a single global one.

ratelimit.WithTrustedProxies(ratelimit.PrivateNetworks()...)

This is the most common misconfiguration, and it is silent: the limiter fires, just for everyone at once.

The limiter is bypassable with a header

You are trusting a hop you should not, or an implementation is reading X-Forwarded-For from the left. Test it:

curl -si -H 'X-Forwarded-For: 1.2.3.4' https://api.example.com/health | grep -i x-ratelimit

If X-RateLimit-Remaining resets, the client-supplied value is being used as the key. Narrow TrustedProxies to the actual ingress subnet.

The correct walk is right to left, skipping trusted hops — because a proxy appends, so the leftmost entry is whatever the client sent. See Behind a Proxy.

A per-endpoint limit is ignored

  1. Pointer receiver, value registration. func (r *LoginRoute) RateLimit() needs app.RegisterRoute(&LoginRoute{…}).
  2. The route implements RateLimitByContext too — the dynamic variant takes precedence.
  3. You are on an old version. Per-endpoint limits used to be resolved per request from the live URL against an index keyed by the route's pattern, so they silently never applied to any route with a path parameter. If /users/{id} ignores its limit but /health respects one, that is the symptom.

A per-router limit is ignored

Older versions hardcoded the default router's name when resolving the chain, so a limit configured for any other router was never consulted. Current versions receive the router name in RouteInfo.

Also check the name matches exactly — WithRouterLimit("partner", …) against a router created as "partner-api" matches nothing, silently.

Legitimate clients are being limited

  • Look at the level. A global limit that fires on normal aggregate traffic is set too low; it is meant as a backstop.
  • Check whether every client is sharing a bucket — see the first entry.
  • Check the window. A sliding window smooths bursts, but a client that legitimately bursts (a page load issuing twenty parallel requests) needs a budget that accommodates the burst, not the average.

Nothing is being limited

  • No limit is configured at any level, and no route declares one.
  • The extension is registered but WithRateLimit() was called with no options.

Check the headers — X-RateLimit-Limit is absent when no limit applies to the route.

X-RateLimit-* headers are not visible to my browser client

They must be exposed through CORS. rextension-cors does that by default, but WithExposedHeaders replaces the defaults — if you set it, list them again:

cors.WithExposedHeaders(
	"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", "Retry-After",
)

Memory grows and does not come back

The bucket store is filling. Check:

  • Is the key function unbounded? Anything client-supplied and unhashed makes the store's size the client's choice.
  • Is TrustedProxies too wide? Trusting an untrusted hop makes X-Forwarded-For the key, which a client can rotate freely.
  • Has MaxBuckets been disabled? A negative value removes the cap.

Idle eviction alone does not bound the store: buckets are removed after ten minutes idle, evaluated every five, so a client creating them faster than that holds (creation rate × 10 minutes) buckets indefinitely.

Limits differ between replicas

They are per process. Two replicas mean each client gets two budgets. The extension has no shared store — rate limit at the ingress if you need a global budget, or divide the intended limit by the replica count and accept that it is approximate during a rolling deploy.

The application panicked at startup on a CIDR

MustParsePrefixes panics on a malformed prefix, on purpose: silently producing an empty trust list would look like working code behind a proxy while actually rate limiting every client as one.

For prefixes from configuration, use netip.ParsePrefix and return the error.

A dynamic limit function is slow

It is called on every request. Read values out of the context; do not do I/O. If the tier lookup needs a database, cache it on the principal at authentication time and read it from the context here.

Rate limiting runs after authentication

It should not — PriorityRateLimit is 300 and PriorityAuth is 400, so the limiter is outside. If it is not, something registered the middleware by hand at a different priority. Rate limiting after authentication means every flooded request costs a token verification before being rejected.