Skip to main content
Version: 1.1

Composite Limits

Real limits often come in tiers: "10 per second, 300 per minute, 5,000 per hour". Multi combines several limiters with AND logic — a request is allowed only if every one of them allows it.

multi := ratelimit.NewMulti(
ratelimit.NewTokenBucket(10, 10), // 10/s
ratelimit.NewSlidingWindowCounter(300, time.Minute), // 300/min
ratelimit.NewSlidingWindowCounter(5000, time.Hour), // 5,000/h
)

if multi.Allow() {
// allowed by all three
}

Multi is itself a Limiter, so it supports AllowN, Wait, WaitN, and Reset, and can be nested or passed anywhere a Limiter is accepted.

Check before consuming​

The naive approach — call Allow on each limiter in turn — has a flaw: if the hourly limit denies, the per-second and per-minute limits have already spent a unit on a request that was never served.

When every limiter implements ResultLimiter (all built-in limiters do), Multi avoids that: it first runs CheckN against all of them, and only consumes when every check passes. A denied request costs nothing.

If any limiter does not implement ResultLimiter, Multi falls back to consuming from each limiter in turn, and cannot undo earlier consumption when a later one denies. Multi itself is not a ResultLimiter, so nesting one Multi inside another triggers this fallback — pass all limiters to a single NewMulti instead.

Waiting​

When every limiter implements ResultLimiter, WaitN on a Multi waits for the longest RetryAfter reported by the limiters that denied, then checks all of them again. It returns ErrExceedsLimit straight away if n is larger than any limiter's capacity, as reported in Result.Limit. Built-in limiters always set it; a custom ResultLimiter that leaves it zero makes WaitN retry until the context ends.

Otherwise WaitN calls each limiter's WaitN in turn, so earlier limiters are consumed while later ones are still being waited on, and nothing is undone if a later wait fails.

Concurrency​

A mutex serializes each check-then-consume step, so two goroutines calling the same Multi cannot both pass the checks and then both consume past a limit. That guarantee only covers calls that go through the Multi: if you also call the wrapped limiters directly, those calls can race with it.

Keyed composite limits​

Multi combines single limiters. For tiered limits per key, call each keyed limiter in your own code, checking all of them before taking from any:

func allow(key string) bool {
if !perSecond.Check(key).Allowed || !perHour.Check(key).Allowed {
return false
}
return perSecond.Take(key).Allowed && perHour.Take(key).Allowed
}

This is not atomic across the two limiters; under concurrent requests for the same key it can occasionally consume from one and be denied by the other.