Skip to content

feat: leaky bucket, and a Strategy to choose between the two - #16

Merged
christiangda merged 1 commit into
mainfrom
feat/leaky-bucket
Aug 25, 2026
Merged

feat: leaky bucket, and a Strategy to choose between the two#16
christiangda merged 1 commit into
mainfrom
feat/leaky-bucket

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

Adds LeakyBucket (virtual scheduling / GCRA) and a Strategy value so the
algorithm can be chosen from configuration — a database column, a YAML field
— rather than at compile time.

strategy, err := ratelimiter.ParseStrategy(row.Strategy) // "token_bucket" | "leaky_bucket"
newLimiter, err := ratelimiter.NewLimiterFunc(strategy, limit)
bl := ratelimiter.NewBucketLimiter(newLimiter, time.Minute, storage)

One Limit describes both; the strategy decides how it's enforced.
ParseStrategy rejects an unrecognised value rather than defaulting — a typo
that silently became token_bucket would admit bursts the operator specifically
asked it not to, and nothing about the running service would say so.

The documentation I first wrote for this was wrong

This is the most useful thing in the PR. My first draft claimed:

token bucket, 60/min burst 60 → 60 at once, then nothing
leaky bucket, 60/min burst 1  → one per second, for ever

That's the comparison everybody makes, and it's rigged — the two sides have
different capacities. Measured side by side with equal parameters:

token bucket, 10/s burst 1  → 1 admitted now, 1 more after 250ms
leaky bucket, 100ms cap 1   → 1 admitted now, 1 more after 250ms

They are duals. Identical. TestLeakyBucketAndTokenBucketAgreeOnTheSameParameters
pins it, so the claim can't quietly drift back into the docs.

So why ship it?

Two honest reasons, neither of them "different behaviour":

  • It's parameterised by spacing, not rate-and-burst — which makes strict
    pacing the obvious configuration instead of a non-obvious burst: 1 that's
    easy to leave at a default. The parameterisation is the feature.
  • The arithmetic is exact: one time.Time and integer durations, so no
    floating-point tokens accumulating rounding error over a long uptime, no
    allocation, O(1).

What it does add

  • Wait shapes rather than drops, and rolls its reservation back if the
    context ends first — so a caller who gives up doesn't make the next caller wait
    for a request that never happened.
  • Cancel genuinely returns the slot. The TAT is a single instant, so
    cancelling is rewinding it. A window-counter backend can't do that.

Tests — each verified to fail by mutating what it guards

Two were rewritten because the first versions couldn't fail:

  • The idempotency test used capacity 1, where the clamp inside rewind masks
    a non-idempotent Cancel completely. Only at capacity 3, with the TAT several
    intervals ahead, can repeated cancels refund slots that were never taken.
  • The strategy-selection test had to assert the algorithm actually differs, not
    merely that a factory came back.

Coverage 99.8%, and the two uncovered spots are stated rather than papered
over: backendReservation.Cancel has an empty body, which Go's coverage tool
cannot count even though two tests call it; and Wait's contended-retry branch is
exercised non-deterministically. Adding a no-op statement to move the number
would be gaming it.

Docs

File
docs/LEAKY_BUCKET.md new — leads with the equivalence rather than burying it
README.md both strategies get equal billing, with the measured comparison
docs/TOKEN_BUCKET.md points at its counterpart; the comparison row now says "bundled"
docs/CUSTOM_STORAGE.md stops implying you'd have to implement a leaky bucket yourself
doc.go a "Two strategies" section

Runnable: go run ./examples/leakybucket and go run ./examples/strategy.

One of those examples was also wrong on first write — it cancelled a refused
reservation and claimed the slot came back. It demonstrates both cases now.

Gates

go vet · gofmt · go fix -diff clean · race tests · all six examples run.

🤖 Generated with Claude Code

Adds LeakyBucket (virtual scheduling / GCRA) and a Strategy value so the
algorithm can be selected from configuration -- a database column, a YAML field
-- rather than chosen at compile time.

    strategy, err := ratelimiter.ParseStrategy(row.Strategy)
    newLimiter, err := ratelimiter.NewLimiterFunc(strategy, limit)
    bl := ratelimiter.NewBucketLimiter(newLimiter, time.Minute, storage)

One Limit describes both strategies; the strategy decides how it is enforced.
ParseStrategy REJECTS an unrecognised value rather than defaulting, because a
typo that silently became token_bucket would admit bursts the operator
specifically asked it not to, and nothing about the running service would say so.

THE DOCUMENTATION I FIRST WROTE FOR THIS WAS WRONG, and the correction is the
most useful thing in the commit. It claimed:

    token bucket, 60/min burst 60 -> 60 at once, then nothing
    leaky bucket, 60/min burst 1  -> one per second, for ever

which is the comparison everybody makes, and it is rigged: the two sides have
different capacities. Measured side by side with equal parameters:

    token bucket, 10/s burst 1  -> 1 admitted now, 1 more after 250ms
    leaky bucket, 100ms cap 1   -> 1 admitted now, 1 more after 250ms

They are duals. Identical. TestLeakyBucketAndTokenBucketAgreeOnTheSameParameters
pins that, so the claim cannot quietly drift back into the docs.

So why ship it? Two honest reasons, neither of them "different behaviour":

  - It is parameterised by SPACING rather than rate-and-burst, which makes
    strict pacing the obvious configuration instead of a non-obvious burst of 1
    that is easy to leave at a default. The parameterisation is the feature.
  - The arithmetic is exact: one time.Time and integer durations, so there are
    no floating-point tokens accumulating rounding error over a long uptime, no
    allocation, and every operation is O(1).

Wait shapes rather than drops, and rolls its reservation back if the context
ends first, so a caller who gives up does not make the next caller wait for a
request that never happened. Cancel genuinely returns the slot -- the TAT is a
single instant, so cancelling is rewinding it -- which a window-counter backend
cannot do.

Tests, each verified to fail by mutating what it guards. Two were rewritten
because the first versions could not fail:

  - the idempotency test used capacity 1, where the clamp inside rewind masks a
    non-idempotent Cancel completely. Only at capacity 3, with the TAT several
    intervals ahead, can repeated cancels refund slots that were never taken.
  - the strategy-selection test had to assert the algorithm actually differs,
    not merely that a factory was returned.

Coverage 99.8%. The two uncovered spots are stated rather than papered over:
backendReservation.Cancel has an EMPTY body, which Go's coverage tool cannot
count even though two tests call it; and Wait's contended-retry branch is
exercised non-deterministically. Adding a no-op statement to move the number
would be gaming it.

Docs: new docs/LEAKY_BUCKET.md, which leads with the equivalence rather than
burying it; README gives both strategies equal billing and repeats the measured
comparison; TOKEN_BUCKET.md, CUSTOM_STORAGE.md and doc.go cross-link.
Runnable: examples/leakybucket and examples/strategy.
@christiangda christiangda self-assigned this Aug 25, 2026
@christiangda
christiangda merged commit 56800b9 into main Aug 25, 2026
5 checks passed
@christiangda
christiangda deleted the feat/leaky-bucket branch August 25, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant