Nobody has started this yet — be first.
Business impact
This algorithm guards login-brute-force-guard -- i.e. it sits directly on the login page, the single most trafficked, most first-impression-critical path in the product. A bug that turns a temporary slow-down into a permanent lockout means any legitimate user who ever tried logging in 5 times in a row (a mistyped password, a forgotten password, a shared office IP with several people logging in) is locked out forever, with no recovery except an operator restarting the whole service -- which resets everyone's state, not just theirs. This reads to customers as "the login page is broken," generates a support ticket per affected user, and can plausibly look like a full outage even though every other system is healthy. It is a self-inflicted denial-of-service against your own users, on the path they hit first.
Problem
The leaky bucket is supposed to "leak" -- its fill level should decrease over time at leakRate units/sec, so a key that stops sending requests eventually becomes free to send again. Right now the level only ever goes up over time, never down. Once any key's bucket fills to capacity, it is rate-limited forever, even after it stops sending requests entirely.
Current behavior
Elapsed time since the last request is being added to the bucket's level instead of subtracted from it. A bucket that receives even a single burst of traffic and then goes completely idle will still show its level rising indefinitely, permanently denying that key.
Expected behavior
Elapsed idle time should reduce the level by elapsed * leakRate, floored at 0. A key that stops sending traffic must eventually recover (be allowed again) -- the whole point of a leaky bucket over a hard blocklist.
Steps to reproduce
lb := ratelimit.NewLeakyBucketMemory(1, 50 /* drains 50/sec */, time.Minute) lb.Allow(ctx, "user-1") // allowed, level now 1 (at capacity) lb.Allow(ctx, "user-1") // denied, as expected (over capacity) time.Sleep(30 * time.Millisecond) // at 50 units/sec, 1.5 units of drain -- more than enough lb.Allow(ctx, "user-1") // got: still denied, and RetryAfter keeps growing no matter how long you wait // want: Allowed: true -- the bucket had time to drain
Why this matters
The seeded login-brute-force-guard rule (leaky bucket, capacity 5, drains 1/10sec) is meant to slow down repeated login attempts, not permanently ban every IP that ever tried to log in 5 times. With this bug live, the first burst of 5 login attempts from any legitimate user permanently locks that IP/key out of /demo/login for the lifetime of the process (or until the janitor's idle-TTL eviction happens to clear it, a much longer, unrelated timescale). This is a self-inflicted denial-of-service against your own users.
Suggested approach
One arithmetic operator, where elapsed time is combined with lb.leakRate to update e.level. Compare against the in-memory token bucket's refill line just above it in the sibling file (pkg/ratelimit/token_bucket.go) for the mirror-image case (tokens go up over time there; level should go down here) -- and against the Redis Lua script pkg/ratelimit/lua/leaky_bucket.lua, which already does this correctly and is not part of this bug.
Acceptance criteria
Verification
go test ./test/ -run TestTask02 -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/leaky-bucket-drains-backwardsFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/leaky-bucket-drains-backwardsSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inhttp://localhost:8080localhost:6380http://localhost:8081curl localhost:8080/healthz
curl localhost:8080/api/v1/rules
curl -s -X POST localhost:8080/api/v1/check -d '{"rule":"free-tier","key":"user-123"}' | jq
No Docker? STORE_BACKEND=memory is the default, so go run ./cmd/server alone (no Redis needed) exercises everything except the Redis-backed code paths.
Work the tickets in tasks/ (ordered easy → hard); each names one Go test in test/:
make test-race # go test -race -count=1 ./...
go test ./test/ -run TestTask01 -v # a single ticket
./test/run_all.sh # all 15, pass/fail summary
Level 1
Fix a bug
Read existing behaviour, correct it.