Rate limiting

github.com/angvp/tango/ratelimit is a reusable, host-composable request rate limiter, independent of auth, accounts, admin, and realtime. It composes as ordinary tango.Middleware, so it works on any route — including a WebSocket upgrade route, since an upgrade is just a specially-shaped GET request hitting the same routing layer.

The runnable reference is examples/notes-starter, which wires ratelimit.Middleware onto its note-creation route.

This is deliberately not a replacement for admin/accounts' existing failed-login-attempt limiter (internal/security.RateLimiter): that one counts authentication failures in a sliding window; ratelimit counts every request via a token bucket. The two are not merged — sharing internal machinery only makes sense where the semantics are byte-for-byte identical, and they aren't here.

Constructing a Limiter

limiter, err := ratelimit.NewLimiter(ratelimit.Options{
    Limit:  20,             // bucket capacity — max burst
    Refill: time.Minute/60, // one new token every second
})

Limit and Refill must both be positive — NewLimiter rejects zero or negative values at construction, not at first use. A key seen for the first time starts with a full bucket, so the first requests for any given key may burst up to Limit before being throttled — this is the normal, intended behavior of a token bucket, not a bug to work around.

Options.Clock defaults to time.Now; it exists mainly so ratelimit.Middleware's own tests (and a host's, if it tests its own middleware stack) can inject a fake clock rather than depending on real-time sleeps. Direct Limiter.Take callers always pass an explicit now and don't need Clock at all.

A Limiter opportunistically forgets a key once its bucket has been idle long enough to have fully refilled anyway (Limit * Refill) — freeing the memory rather than retaining every key ever seen for the process's whole lifetime. This never changes what any key observes: a bucket that's been idle that long is already back at full capacity, so evicting it and letting the next request for that key recreate it fresh is indistinguishable from leaving the stale entry in place. The sweep runs inline during Take (no background goroutine), at most once per that same Limit * Refill window, so it doesn't add real cost to the common case of a few actively-used keys.

The Decision

type Decision struct {
    Allowed    bool
    Limit      int
    Remaining  int
    RetryAfter time.Duration
}

Limiter.Take(ctx, key, now, cost) returns a Decision describing whether key had cost tokens available. RetryAfter is derived from the bucket's actual refill state (time until enough tokens accumulate again) — never a hardcoded guess. cost defaults to 1 per request via ratelimit.Middleware (see below); a caller may charge more for an expensive operation via WithCost, or call Take directly with any cost for non-HTTP use. cost must be positive and must not exceed Limit — a bucket never holds more than Limit tokens, so a request costing more than that could never succeed no matter how long it waited; Take rejects it outright with an error rather than returning a Decision with a finite RetryAfter that would never actually resolve.

Key extraction

type KeyFunc func(*http.Request) (string, error)

func RemoteIPKey(trustedProxies ...*net.IPNet) KeyFunc

ratelimit never imports auth, auth/jwt, or accounts — a KeyFunc is entirely the host's choice. RemoteIPKey is the one default extractor: with no trustedProxies given, it only ever uses r.RemoteAddr's host (the same behavior admin/accounts' own key extraction already has). With trusted CIDRs given, X-Forwarded-For is read — but only when the immediate peer address falls inside one of those CIDRs. It is read from the right: entries that are themselves in the trusted CIDRs are skipped, and the first other address, the one your nearest trusted proxy saw, is the key. Whatever a client wrote to the left of it is ignored, so a client cannot choose its key even through a proxy that appends to the header. Each entry must be a bare IP address; anything else, or a list with no usable entry, falls back to the peer's address. A valid address is returned in canonical form. X-Real-IP is never read. From an untrusted peer, the header is ignored even if present. admin and accounts use the same rules for their login limiters (WithTrustedProxies). Never configure RemoteIPKey to trust forwarded headers unconditionally: any client could then spoof its own rate-limit key.

A host wanting to key on identity instead of IP (a JWT claim, a session, a tenant ID) writes its own KeyFunc using whatever it already has on *http.Request/its context.

A KeyFunc returning a non-nil error, or an empty key with a nil error, is never treated as "this caller is over budget" — both go to a distinct ErrorHandler (see below), so an extraction bug can never silently collapse every caller into one shared bucket.

Middleware

func Middleware(limiter *Limiter, key KeyFunc, opts ...MiddlewareOption) tango.Middleware

Composes at any of tanGO's existing middleware attachment tiers (global, group, or route — see routing and middleware). The default rejection response is 429, Content-Type: application/json, a Retry-After header (the bucket's real refill time, rounded up to whole seconds — the HTTP header's delta-seconds form), and body {"error":"rate limit exceeded"}. The default extraction-failure response is 400, JSON, {"error":"rate limit key extraction failed"}.

Both are overridable:

ratelimit.Middleware(limiter, key,
    ratelimit.WithLimitedHandler(func(w http.ResponseWriter, r *http.Request, d ratelimit.Decision) {
        // build your own response from d
    }),
    ratelimit.WithErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
        // build your own response from err
    }),
    ratelimit.WithCost(1), // tokens consumed per request; default 1
)

The defaults stay stable and documented — a host overrides them for consistency with its own error envelope, not because the defaults are expected to silently change.

Deliberate limits

  • Single-process, in-memory only — no distributed quota across multiple server processes; running more than one instance covers the workaround.
  • No tenant-level policy engine or adaptive throttling.
  • admin/accounts' existing failed-login-attempt limiter is untouched and unaffected by this package.

See limitations for the full list.

View this page's source on GitHub