Nobody has started this yet — be first.
Business impact
A capacity guarantee that only holds under light load is not a capacity guarantee -- it fails precisely when traffic is heavy enough to matter, which is the entire reason a rate limiter exists in the first place. If a token bucket is sized to protect a downstream database, a paid third-party API with its own (billed) rate limit, or an expensive compute path, this bug means that protection quietly disappears under exactly the load spike it was provisioned for. That risk ranges from cascading internal outages to unexpected overage charges from a third party you're supposed to be shielding -- both are the kind of incident that happens once, expensively, under real production traffic, not in a quiet afternoon of manual testing.
Problem
The README is explicit about the concurrency contract here: token bucket uses a sync.Mutex per key, because refilling and consuming tokens is a compound read-modify-write over two related float64 fields and there's no lock-free way to update two dependent floats together. The mutex is still present in the code, but it no longer covers the whole compound operation -- part of the critical section (the check-and-decrement of e.tokens) now runs after the lock has already been released.
Current behavior
Under concurrent access to the same key, the "check if enough tokens remain, then subtract" sequence is not atomic with respect to other goroutines doing the same thing. Two goroutines can both observe e.tokens >= n and both proceed to subtract, jointly consuming more tokens than were actually available.
Expected behavior
Under any amount of concurrency, the total number of admitted requests for a shared key must never exceed the bucket's capacity (with no refill).
Steps to reproduce
tb := ratelimit.NewTokenBucketMemory(50, 0 /* no refill */, time.Minute) var wg sync.WaitGroup; var allowed int64 for i := 0; i < 500; i++ { wg.Add(1) go func() { defer wg.Done() if res, _ := tb.Allow(ctx, "shared-key"); res.Allowed { atomic.AddInt64(&allowed, 1) } }() } wg.Wait() // want: allowed <= 50, always // got: allowed occasionally > 50; go test -race reliably reports a data race on tokens
Why this matters
A rate limiter that can be pushed past its configured capacity under load is worse than useless -- the failure mode (silently admitting more traffic than intended) shows up exactly when the system is under the load pressure the limiter exists to protect against, i.e. the worst possible time for it to fail.
Suggested approach
Find where e.mu.Unlock() happens relative to the read of e.tokens in the if e.tokens >= float64(n) check and the subsequent e.tokens -= float64(n) write. The entire compound operation -- refill, check, and consume-if-sufficient -- needs to happen while holding the lock, not just the refill part. Using defer e.mu.Unlock() right after e.mu.Lock() (the pattern every other mutex-protected section in this package still uses) is the simplest way to guarantee this regardless of how many early-return branches get added later.
Acceptance criteria
Verification
go test -race ./test/ -run TestTask11 -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/token-bucket-raceFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/token-bucket-raceSubmit 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.