feat: leaky bucket, and a Strategy to choose between the two - #16
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
LeakyBucket(virtual scheduling / GCRA) and aStrategyvalue so thealgorithm can be chosen from configuration — a database column, a YAML field
— rather than at compile time.
One
Limitdescribes both; the strategy decides how it's enforced.ParseStrategyrejects an unrecognised value rather than defaulting — a typothat silently became
token_bucketwould admit bursts the operator specificallyasked 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:
That's the comparison everybody makes, and it's rigged — the two sides have
different capacities. Measured side by side with equal parameters:
They are duals. Identical.
TestLeakyBucketAndTokenBucketAgreeOnTheSameParameterspins it, so the claim can't quietly drift back into the docs.
So why ship it?
Two honest reasons, neither of them "different behaviour":
pacing the obvious configuration instead of a non-obvious
burst: 1that'seasy to leave at a default. The parameterisation is the feature.
time.Timeand integer durations, so nofloating-point tokens accumulating rounding error over a long uptime, no
allocation, O(1).
What it does add
Waitshapes rather than drops, and rolls its reservation back if thecontext ends first — so a caller who gives up doesn't make the next caller wait
for a request that never happened.
Cancelgenuinely returns the slot. The TAT is a single instant, socancelling 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:
rewindmasksa non-idempotent
Cancelcompletely. Only at capacity 3, with the TAT severalintervals ahead, can repeated cancels refund slots that were never taken.
merely that a factory came back.
Coverage 99.8%, and the two uncovered spots are stated rather than papered
over:
backendReservation.Cancelhas an empty body, which Go's coverage toolcannot count even though two tests call it; and
Wait's contended-retry branch isexercised non-deterministically. Adding a no-op statement to move the number
would be gaming it.
Docs
docs/LEAKY_BUCKET.mdREADME.mddocs/TOKEN_BUCKET.mddocs/CUSTOM_STORAGE.mddoc.goRunnable:
go run ./examples/leakybucketandgo run ./examples/strategy.Gates
go vet·gofmt·go fix -diffclean · race tests · all six examples run.🤖 Generated with Claude Code