Skip to content

Limits and Precedence

wiki edited this page Sep 4, 2026 · 1 revision

Limits and precedence

Three levels, resolved once per route when the route table is built:

per-endpoint  →  per-router  →  global

The most specific one that exists wins. A route with no endpoint limit on a router with no router limit gets the global one; if there is no global one either, it is unlimited.

Global

ratelimit.WithGlobalLimit(600, time.Minute)

Applies to every route on every router.

Per router

ratelimit.WithRouterLimit("partner", 100, time.Minute)
ratelimit.WithRouterLimit("internal", 10_000, time.Minute)

Useful because a router usually corresponds to an audience — a partner listener and the public one rarely deserve the same budget.

Per endpoint

type LoginRoute struct{ rxroute.Route }

func (r *LoginRoute) RateLimit() ratelimit.LimitConfig {
	return ratelimit.LimitConfig{Rate: 5, Window: time.Minute}
}

app.RegisterRoute(&LoginRoute{Route: rxroute.New("POST", "/login", login)})

⚠ Pointer receiver → register as a pointer, or the assertion fails and the route falls through to the router or global limit.

This is the level that matters most: a login endpoint, a password reset, a verification-code check and an expensive search each deserve a much tighter budget than the API as a whole.

Dynamic limits

For tiered limits, where the budget depends on who is calling:

func (r *SearchRoute) RateLimitByContext(ctx context.Context) ratelimit.LimitConfig {
	user, ok := security.GetPrincipalAs[*User](/* request */ nil)
	if !ok {
		return ratelimit.LimitConfig{Rate: 10, Window: time.Minute} // anonymous
	}
	switch user.Tier {
	case "enterprise":
		return ratelimit.LimitConfig{Rate: 10_000, Window: time.Minute}
	case "pro":
		return ratelimit.LimitConfig{Rate: 1_000, Window: time.Minute}
	default:
		return ratelimit.LimitConfig{Rate: 100, Window: time.Minute}
	}
}

The context carries whatever upstream middleware stored — including the authenticated principal, because rate limiting runs before authentication for the refusal decision but the provider is consulted with the request's context.

RateLimitByContext takes precedence over RateLimit when a route implements both.

The same is available globally and per router:

ratelimit.WithGlobalLimitFunc(func(ctx context.Context) ratelimit.LimitConfig {
	if underLoad() {
		return ratelimit.LimitConfig{Rate: 100, Window: time.Minute}
	}
	return ratelimit.LimitConfig{Rate: 600, Window: time.Minute}
})

A dynamic provider is called on every request — keep it cheap. Reading a value out of the context is fine; a database lookup is not.

The providers

type LimitProvider interface {
	Limit(ctx context.Context) LimitConfig
}

ratelimit.FixedLimit(600, time.Minute)
ratelimit.DynamicLimit(func(ctx context.Context) ratelimit.LimitConfig { … })

How precedence is resolved

RateLimitFactory is a PerRouteMiddleware factory. The framework calls it once per route, with the route and the router it is registered on — which is exactly what the precedence chain needs.

Resolution used to happen per request, from the live URL:

provider := resolveProvider(r.Method, r.URL.Path, routerName, cfg, index)

against an index keyed at registration by the route's pattern. Those never match for a parameterized route, so per-endpoint limits silently never applied to any route with a path parameter — every such route fell through to the router or global limit.

The router name was the second half of the problem: with only the route available, the extension hardcoded the default router's name, so a per-router limit configured for any other router was never consulted.

The factory is handed both. There is no index and no lookup, and the pattern/URL mismatch cannot arise because neither is consulted.

RateLimitMiddleware remains for an application composing its own chain, and is deprecated.

The algorithm

A sliding window counter: requests are counted across a moving window rather than reset at a fixed boundary. A fixed window lets a client send its whole budget in the last second of one window and again in the first second of the next — twice the intended rate across that boundary.

Choosing limits

  • Authentication endpoints: single digits per minute per client. The attacker's cost is what you are raising.
  • Write endpoints: tens per minute. Legitimate clients rarely burst.
  • Read endpoints: hundreds. Be generous; the cost is low and a limit that fires on normal use trains clients to retry harder.
  • The global limit is a backstop, not the main control. Set it well above normal aggregate traffic.

Watch X-RateLimit-Remaining in access logs before tightening anything.

Clone this wiki locally