Lesson 3 of 3 · 65 min
Build a Correct Fixed-Window Limiter
Build the first real limiter step by step, make concurrent requests safe, connect it to HTTP, and understand what must change when the service has multiple replicas.
Learning objectives
- Explain fixed windows using a timeline.
- Make one caller's counter safe when requests arrive at the same time.
- Return correct allow, deny, remaining, and retry values.
- Explain why local memory cannot enforce one shared limit across replicas.
The simplest useful algorithm
A fixed-window limiter divides time into equal boxes. Each caller gets a fresh counter in every box.
For example, imagine one box for every minute:
12:00 minute → count requests in box A12:01 minute → forget box A and count in box B12:02 minute → forget box B and count in box CThis is called fixed window because the boxes start and end at fixed clock boundaries.
For this policy:
Limit: 10 requestsWindow: 60 secondsthe timeline looks like:
12:00:00.000 ───────────────────────── 12:00:59.999 window N12:01:00.000 ───────────────────────── 12:01:59.999 window N + 1The window ID is integer division:
windowID := now.UnixMilli() / policy.Window.Milliseconds()Every timestamp in the same interval produces the same ID. Windows are aligned to the Unix epoch, so a one-minute policy resets at each UTC minute boundary—not one minute after a client's first request. Every caller resets at the same moment.
Production note
Epoch alignment has a surprising consequence for long windows: a one-day window resets at 00:00 UTC, not at the user's local midnight. If a product promises "daily" limits in the user's calendar day, that is a different design and must be built explicitly.
Do not worry about the formula yet
The division simply turns every timestamp in the same minute into the same window number. The tests will prove the boundaries for us.
Reset state lazily
We do not need a background job that clears every counter at midnight or every minute. Store two numbers:
- which time box this state belongs to;
- how many budget units have been used.
package fixedwindow// State is the constant-size algorithm payload for one key.type State struct { WindowID int64 Count int64}At evaluation time:
stored WindowID == current WindowID → continue countingstored WindowID != current WindowID → start from zeroThe payload is 16 bytes, but the complete cache entry is larger because keys, maps, interfaces, LRU links, and allocator metadata also consume memory.
Understand the boundary burst
A client can consume ten requests at 12:00:59.xxx and another ten at 12:01:00.xxx.
old window new window───────────────┬────────────────────────────10 allowed │ 10 allowedat 59.9s │ at 60.0s ▲ reset boundaryThat is roughly 20 requests in a fraction of a second under a “10 per minute” policy.
The code did not make a mistake. The caller used one legal budget immediately before the boundary and another legal budget immediately after it.
This is the algorithm's main tradeoff:
- constant-size state per active key;
- expected O(1) work per request;
- coarse enforcement at interval boundaries.
Algorithm honesty
Do not “fix” the boundary burst inside this implementation. That would silently create a different algorithm. Preserve it in a passing test and choose sliding-window or token-bucket semantics when the product cannot tolerate it.
Validate policy before serving traffic
Configuration errors should stop the application at startup, not surprise us after traffic arrives.
The policy checks two simple rules first:
Limit must be greater than zero.Window must be a valid whole number of milliseconds.The package then checks one larger-number safety rule for future Redis compatibility:
package fixedwindowimport ( "fmt" "time" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit")const luaMaxExactInteger int64 = 1 << 53type Policy struct { Limit int64 Window time.Duration}func (p Policy) Validate() error { if p.Limit <= 0 { return fmt.Errorf("fixedwindow: limit must be > 0") } if err := ratelimit.ValidateWindow(p.Window); err != nil { return fmt.Errorf("fixedwindow: %w", err) } windowMilliseconds := p.Window.Milliseconds() if p.Limit > luaMaxExactInteger/windowMilliseconds { return fmt.Errorf( "fixedwindow: limit and window exceed exact integer budget", ) } return nil}The final comparison uses division, not:
p.Limit * p.Window.Milliseconds() > 1<<53The multiplication could overflow before it is compared. Validation order also matters: ValidateWindow must reject a sub-millisecond value before .Milliseconds() can become zero and be used as a divisor.
First-pass takeaway
Validate configuration once when constructing the limiter. Request handling should never discover that the configured window is zero or unusable.
The storage trap: thread-safe is not atomic
Start with a deliberately simple local store:
package storeimport ( "context" "time")// Store operations are individually safe, but this interface cannot express// one atomic read-modify-write transaction.type Store[T any] interface { Get(ctx context.Context, key string) (T, bool, error) Set(ctx context.Context, key string, value T, ttl time.Duration) error Delete(ctx context.Context, key string) error}An in-memory cache may make Get safe and make Set safe. The complete sequence can still be wrong.
Imagine two cashiers reading the same gift-card balance:
Starting balance: $10Cashier A reads $10 and approves a $7 purchase.Cashier B reads $10 and approves a $7 purchase.Both write the new balance as $3.$14 was spent, but the stored balance only changed by $7.Our counter has the same problem:
goroutine A goroutine B
│ │
├── Get(key) → Count 9 │
│ ├── Get(key) → Count 9
├── decide → Count 10 ├── decide → Count 10
├── Set(key, 10) │
│ ├── Set(key, 10)
▼ ▼
Two requests were admitted, but stored Count advanced only once.The cache has no unsynchronized memory access, so Go's race detector may stay silent. This is a logical race across several synchronized operations.
Core distributed-systems lesson
Thread safety protects an operation. Atomicity protects an invariant across a state transition. Rate limiting needs the second guarantee.
In simpler language: protecting each step is not enough. The entire read, check, and update must behave like one indivisible action.
Adapt the local cache deliberately
The learning implementation uses process-cache to store counters in this application's memory:
go get github.com/tonmoytalukder/process-cache@v0.1.1go mod edit -go=1.24go mod tidyThe module minimum changes because process-cache v0.1.1 requires Go 1.24. A project cannot honestly advertise Go 1.22 compatibility while depending on a module that requires a newer language version.
Adapt the cache's untyped values to the generic store contract:
What an adapter does
An adapter is a small translator. process-cache stores values as any; our adapter checks that the value is really the State type the limiter expects.
package storeimport ( "context" "fmt" "time" processcache "github.com/tonmoytalukder/process-cache")type ProcessCache[T any] struct { cache processcache.Cache}func NewProcessCache[T any]( cache processcache.Cache,) (*ProcessCache[T], error) { if cache == nil { return nil, fmt.Errorf("store: process cache must not be nil") } return &ProcessCache[T]{cache: cache}, nil}func (s *ProcessCache[T]) Get( ctx context.Context, key string,) (T, bool, error) { var zero T if err := ctx.Err(); err != nil { return zero, false, err } value, found := s.cache.Get(key) if !found { return zero, false, nil } typed, ok := value.(T) if !ok { return zero, false, fmt.Errorf( "store: key %q has unexpected type %T", key, value, ) } return typed, true, nil}func (s *ProcessCache[T]) Set( ctx context.Context, key string, value T, ttl time.Duration,) error { if err := ctx.Err(); err != nil { return err } if !s.cache.Set(key, value, ttl) { return fmt.Errorf("store: process cache rejected key %q", key) } return nil}func (s *ProcessCache[T]) Delete( ctx context.Context, key string,) error { if err := ctx.Err(); err != nil { return err } s.cache.Delete(key) return nil}var _ Store[int] = (*ProcessCache[int])(nil)Get has three meaningful result shapes:
| Result | Meaning |
|---|---|
value, true, nil | Typed state exists |
zero, false, nil | Normal cache miss |
zero, false, non-nil | State cannot be trusted |
Test round-trip, expiry, delete, wrong dynamic type, cancelled context, rejected writes, and nil construction. Use the same fake clock for the cache and limiter so TTL tests remain deterministic.
Eviction changes correctness
process-cache is an LRU cache. Evicting a key deletes its counter and can give that identity a fresh budget. It is useful for this local learning lab, but an evictable cache is not automatically suitable for security-sensitive quotas.
Serialize one key's transition
For this local implementation, a lock makes the complete Get → decide → Set sequence behave like one action.
One global lock would make every caller wait in the same line. One lock per caller could use unlimited memory. A fixed set of lock shards gives us a practical middle ground.
package keyedlockimport ( "fmt" "sync")type Locker struct { shards []sync.Mutex}func New(shardCount int) (*Locker, error) { if shardCount <= 0 { return nil, fmt.Errorf("keyedlock: shard count must be > 0") } return &Locker{shards: make([]sync.Mutex, shardCount)}, nil}func (l *Locker) Lock(key string) func() { shard := &l.shards[shardIndex(key, len(l.shards))] shard.Lock() return shard.Unlock}func shardIndex(key string, shardCount int) int { const ( offset64 = uint64(14695981039346656037) prime64 = uint64(1099511628211) ) hash := offset64 for i := 0; i < len(key); i++ { hash ^= uint64(key[i]) hash *= prime64 } return int(hash % uint64(shardCount))}FNV-1a only selects a shard; it is not a security hash. A collision makes two unrelated keys wait on the same mutex, but their stored state remains separate.
| Lock design | Memory | Concurrency | Important limitation |
|---|---|---|---|
| One global mutex | Constant | All keys serialize | Hot key blocks everyone |
| Mutex per key | Grows with keys | Highest local independence | Attacker-controlled memory |
| Fixed shards | Bounded | Unrelated shards proceed | Collisions serialize |
These mutexes exist in one process. A second replica owns a different locker.
Give the locker its own small test file: prove that 100 goroutines incrementing a plain integer under the same key's lock always reach exactly 100, that two keys on different shards do not block each other, and that a non-positive shard count is rejected.
Simple picture
Each key chooses one of 256 checkout lines. Requests in the same line wait for each other. Requests in different lines may continue at the same time.
Assemble the limiter
The limiter now has four building blocks:
| Building block | Job |
|---|---|
| Policy | Holds the limit and window |
| Clock | Supplies the current time |
| Store | Saves the counter |
| Keyed lock | Prevents two requests from corrupting one update |
The limiter coordinates those pieces. A separate pure function performs the arithmetic.
package fixedwindowimport ( "context" "fmt" "time" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/keyedlock" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")const lockShards = 256type Limiter struct { policy Policy clock ratelimit.Clock store store.Store[State] locks *keyedlock.Locker}func New( policy Policy, clock ratelimit.Clock, stateStore store.Store[State],) (*Limiter, error) { if err := policy.Validate(); err != nil { return nil, err } if clock == nil { return nil, fmt.Errorf("fixedwindow: clock must not be nil") } if stateStore == nil { return nil, fmt.Errorf("fixedwindow: store must not be nil") } locks, err := keyedlock.New(lockShards) if err != nil { return nil, fmt.Errorf("fixedwindow: create keyed lock: %w", err) } return &Limiter{ policy: policy, clock: clock, store: stateStore, locks: locks, }, nil}func (l *Limiter) Allow( ctx context.Context, key string, cost int64,) (ratelimit.Decision, error) { if err := ctx.Err(); err != nil { return ratelimit.Decision{}, err } if cost <= 0 { return ratelimit.Decision{}, fmt.Errorf( "fixedwindow: cost must be > 0", ) } unlock := l.locks.Lock(key) defer unlock() state, found, err := l.store.Get(ctx, key) if err != nil { return ratelimit.Decision{}, fmt.Errorf( "fixedwindow: load state: %w", err, ) } now := l.clock.Now() nextState, decision := decide(l.policy, state, now, cost) if !found || nextState != state { if err := l.store.Set( ctx, key, nextState, 2*l.policy.Window, ); err != nil { return ratelimit.Decision{}, fmt.Errorf( "fixedwindow: save state: %w", err, ) } } return decision, nil}var _ ratelimit.Limiter = (*Limiter)(nil)Read Allow as a transaction:
validate context and cost ↓lock the key's shard ↓load state ↓read the clock once ↓calculate the next state and decision ↓persist changed state ↓unlock and returnInvalid input is rejected before locking or touching state. The clock is read exactly once so a single decision cannot combine a window ID from one interval with retry metadata from the next.
If persistence fails, return an error—never an allowed decision. Otherwise the operation would proceed without consuming quota.
Keep decision logic pure
A pure function receives everything it needs as input. It does not read the real clock, call the cache, or change global data. The same inputs always produce the same outputs.
That makes the hardest arithmetic easy to test:
func decide( policy Policy, state State, now time.Time, cost int64,) (State, ratelimit.Decision) { windowMilliseconds := policy.Window.Milliseconds() windowID := now.UnixMilli() / windowMilliseconds windowEnd := time.UnixMilli( (windowID + 1) * windowMilliseconds, ) if state.WindowID != windowID { state = State{WindowID: windowID} } if cost > policy.Limit || state.Count > policy.Limit-cost { return state, ratelimit.Decision{ Allowed: false, Limit: policy.Limit, Remaining: remaining(policy.Limit, state.Count), RetryAfter: windowEnd.Sub(now), ResetAt: windowEnd, Reason: "limit_exceeded", } } state.Count += cost return state, ratelimit.Decision{ Allowed: true, Limit: policy.Limit, Remaining: policy.Limit - state.Count, ResetAt: windowEnd, }}func remaining(limit, count int64) int64 { if count >= limit { return 0 } return limit - count}Make arithmetic overflow-safe
This common check is unsafe:
if state.Count + cost > policy.Limit {The addition can wrap around math.MaxInt64. Instead:
if cost > policy.Limit || state.Count > policy.Limit-cost {Short-circuit evaluation guarantees policy.Limit-cost is computed only when cost is no larger than the limit.
First-pass takeaway
Integers have a maximum size. Adding before comparing can wrap a very large number into a wrong value. Rearranging the comparison avoids that addition.
An oversized positive cost is a valid request that cannot fit, so it receives a denied decision. Zero or negative cost is invalid input and receives an error.
TTL is cleanup, not reset
State is stored for 2 × Window to provide cleanup margin around a boundary. Correct reset behavior comes from comparing WindowID, not from assuming the cache expires at the perfect instant.
Do not refresh TTL for a denied request whose state did not change. Rewriting identical state adds work and can keep stale keys alive under constant rejected traffic.
Test the invariants
Deterministic time makes every test below fast and exact. You do not need to write every test at once. Use this order:
- make one allowed request;
- fill the budget;
- deny the next request;
- move time into the next window;
- add different keys;
- add concurrent requests;
- add invalid and extreme inputs.
Start the test file with the fake clock from Lesson 2 and one helper that owns the cache lifecycle. The fake clock lives here—not in the contracts package—because Go does not share test helpers across packages, and this is the first package that needs it:
package fixedwindowimport ( "sync" "testing" "time" processcache "github.com/tonmoytalukder/process-cache" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")type fakeClock struct { mu sync.Mutex now time.Time}func (c *fakeClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now}func (c *fakeClock) Advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.now = c.now.Add(d)}func newTestLimiter( t testing.TB, policy Policy, start time.Time,) (*Limiter, *fakeClock) { t.Helper() clock := &fakeClock{now: start} cache, err := processcache.NewMemoryCache( processcache.WithCleanupDisabled(), processcache.WithClock(clock), ) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = cache.Close() }) stateStore, err := store.NewProcessCache[State](cache) if err != nil { t.Fatal(err) } limiter, err := New(policy, clock, stateStore) if err != nil { t.Fatal(err) } return limiter, clock}Two details in the helper are easy to miss. The same fake clock drives both the limiter and the cache, so TTL expiry and window arithmetic advance together. And every test starts at a realistic post-1970 timestamp: Go's integer division truncates negative numbers toward zero, so pre-1970 timestamps would produce surprising window IDs.
| Test | Invariant |
|---|---|
| Requests 1–10 | Admitted; remaining decreases to zero |
| Request 11 | Denied with nil error |
| Key isolation | Exhausting A does not consume B |
| Window reset | First request in a new window is admitted |
| Retry metadata | Points to the aligned boundary |
| Boundary burst | Twenty requests pass across adjacent windows |
| Invalid cost | Returns error and creates no state |
| Oversized cost | Denies without consuming state |
| Corrupted large count | Cannot overflow into an allowed result |
| Cancelled context | Returns error and creates no state |
The retry-metadata test deserves concrete numbers. Start the clock at 12:00:30, exhaust a one-minute window, and deny one call. The correct assertions are:
RetryAfter = 30s (time left until the boundary)ResetAt = 12:01:00 (the aligned boundary itself)A common bug is computing the reset as now.Add(window)—that would claim 12:01:30, a full minute after rejection instead of thirty seconds. Retry metadata describes the algorithm's real boundary, not "one window from now."
Capture the known boundary behavior
The first behavioral test calls Allow with a context, so add "context" to the test file's import block:
func TestAllow_BoundaryBurst(t *testing.T) { t.Parallel() limiter, clock := newTestLimiter( t, Policy{Limit: 10, Window: time.Minute}, time.Date(2026, 7, 31, 12, 0, 59, 0, time.UTC), ) allowed := 0 for i := 0; i < 10; i++ { decision, err := limiter.Allow(context.Background(), "user-1", 1) if err != nil { t.Fatal(err) } if decision.Allowed { allowed++ } } clock.Advance(time.Second) for i := 0; i < 10; i++ { decision, err := limiter.Allow(context.Background(), "user-1", 1) if err != nil { t.Fatal(err) } if decision.Allowed { allowed++ } } if allowed != 20 { t.Fatalf("allowed = %d, want 20", allowed) }}Prove behavior under concurrency
This test uses an atomic counter, so also add "sync/atomic" to the import block. It collects every goroutine error separately: the count is meaningful only when all 100 limiter calls succeed.
func TestAllow_Concurrent(t *testing.T) { t.Parallel() limiter, _ := newTestLimiter( t, Policy{Limit: 10, Window: time.Minute}, time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC), ) var allowed atomic.Int64 errCh := make(chan error, 100) var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() decision, err := limiter.Allow( context.Background(), "user-1", 1, ) if err != nil { errCh <- err return } if decision.Allowed { allowed.Add(1) } }() } wg.Wait() close(errCh) for err := range errCh { t.Errorf("Allow() error = %v", err) } if allowed.Load() != 10 { t.Fatalf("allowed = %d, want exactly 10", allowed.Load()) }}Run it repeatedly as well as under the memory race detector:
go test ./...go test -count=20 ./internal/ratelimit/fixedwindowgo test -race ./...Optional experiment: break it on purpose
The fastest way to believe the storage-trap section is to see the failure yourself. Temporarily delete these two lines from Allow:
unlock := l.locks.Lock(key)defer unlock()Then hammer the concurrency test:
go test -count=100 -run TestAllow_Concurrent \ ./internal/ratelimit/fixedwindowYou should see failures where far more than ten requests pass—one validated run ranged from 12 to 83 admitted requests. Now run the same broken code under -race: it may stay completely silent, because no goroutine touches unsynchronized memory. The behavioral test caught a logical bug the race detector cannot see. Restore the two lines and rerun the suite before continuing.
Why two concurrency checks?
-race looks for unsafe access to Go memory. The test assertion checks the business rule: exactly ten requests may pass. We need both.
Map decisions to HTTP
Keep /api/demo unrestricted. Add a separate /api/fixed-window route that uses the shared interface.
First, wire the real dependencies together at startup, inside main before the routes are registered. This is the moment Lesson 2's design pays off: the server sees only the ratelimit.Limiter interface, and misconfiguration fails at boot instead of during traffic:
cache, err := processcache.NewMemoryCache( processcache.WithMaxSize(16*processcache.MB), processcache.WithCleanupInterval(time.Minute),)if err != nil { log.Fatal(err)}defer func() { if err := cache.Close(); err != nil { log.Printf("close cache: %v", err) }}()stateStore, err := store.NewProcessCache[fixedwindow.State](cache)if err != nil { log.Fatal(err)}limiter, err := fixedwindow.New( fixedwindow.Policy{Limit: 10, Window: time.Minute}, ratelimit.SystemClock{}, stateStore,)if err != nil { log.Fatal(err)}mux.HandleFunc("GET /api/fixed-window", fixedWindowHandler(limiter))The import block of cmd/server/main.go grows accordingly:
import ( "fmt" "log" "math" "net" "net/http" "strconv" "time" processcache "github.com/tonmoytalukder/process-cache" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/fixedwindow" "github.com/TonmoyTalukder/go-rate-limiter/internal/ratelimit/store")Note this is the production path: SystemClock instead of the fake, and a bounded cache with periodic cleanup instead of the test cache.
The HTTP handler itself has only three paths:
limiter error → 500 Internal Server Errorvalid denial → 429 Too Many Requestsvalid approval → 200 OKfunc fixedWindowHandler(limiter ratelimit.Limiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { decision, err := limiter.Allow( r.Context(), clientKey(r), 1, ) if err != nil { http.Error( w, "limiter unavailable", http.StatusInternalServerError, ) return } w.Header().Set( "X-RateLimit-Limit", strconv.FormatInt(decision.Limit, 10), ) w.Header().Set( "X-RateLimit-Remaining", strconv.FormatInt(decision.Remaining, 10), ) if !decision.Allowed { retrySeconds := max( int64(1), int64(math.Ceil(decision.RetryAfter.Seconds())), ) w.Header().Set( "Retry-After", strconv.FormatInt(retrySeconds, 10), ) http.Error( w, "rate limit exceeded", http.StatusTooManyRequests, ) return } w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint( w, `{"status":"ok","endpoint":"fixed-window"}`, ) }}X-RateLimit-* is a common legacy convention, not a claim of current standards compliance. The algorithm returns precise durations; the HTTP adapter rounds Retry-After upward.
For a demo identity, RemoteAddr is acceptable only with an explicit warning:
func clientKey(r *http.Request) string { // Demo only. Production identity must use authenticated principals or an // explicitly trusted proxy configuration. host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host}Manually verify both paths:
curl -i http://localhost:8080/api/demofor i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code}\n" \ http://localhost:8080/api/fixed-windowdoneWithin one window, the limited endpoint should return ten 200 responses followed by five 429 responses. The control endpoint should continue returning 200.
If your loop happens to straddle a real minute boundary, the split may differ—perhaps eight 200s, then a reset, then more. That is the algorithm behaving exactly as specified against the real clock, and it is precisely why the deterministic fake-clock tests, not manual curl runs, are the correctness evidence.
Measure the complete path
Benchmark one hot key and many keys:
BenchmarkAllow_SingleKey → lock contention + store pathBenchmarkAllow_ManyKeys → shard distribution + store path + key selectionReport ns/op, B/op, allocs/op, Go version, CPU, operating system, and the exact command:
go test -run '^$' -bench=. -benchmem -count=5 \ ./internal/ratelimit/fixedwindowDo not assume many keys must be faster. A cache that maintains exact LRU order may synchronize internally and dominate both benchmarks.
Avoid optimizing a number without a profile:
go test -run '^$' \ -bench=BenchmarkAllow_SingleKey \ -benchmem \ -memprofile=mem.out \ ./internal/ratelimit/fixedwindowgo tool pprof -alloc_objects mem.outFrom local correctness to distributed enforcement
So far, one application process owns the counter and the locks. A production service may run many copies of the application.
The keyed lock proves correctness inside one copy. It cannot reach into another machine or process:
single process
Get → keyed mutex → decide → Set
│
▼
multiple replicas
atomic shared operation at the state owner
│
▼
Redis / database / quota service
load + reset + compare + increment + expiry
must execute as one transactionSimply moving Get and Set to Redis is not enough. Two replicas could repeat the same gift-card mistake. The shared state owner must perform load, check, update, and expiry as one atomic action.
Common choices include a Redis Lua script, a database transaction with the correct isolation, or a dedicated quota service.
Production design must also answer:
| Concern | Decision to make |
|---|---|
| Backend timeout | How long may admission wait? |
| Backend failure | Fail open, fail closed, or use a degraded local fallback? |
| Clock source | Application time or state-owner time? |
| Key cardinality | How are unbounded identities expired and monitored? |
| Hot keys | Can one tenant overload a single shard or Redis node? |
| Replica topology | Is the quota global, regional, or per service instance? |
| Observability | Which metrics distinguish denial, failure, and latency? |
Observe decisions, not identities
A useful starting metric set is:
rate_limit_decisions_total{algorithm, policy, outcome}rate_limit_errors_total{backend, reason}rate_limit_decision_duration_seconds{algorithm}rate_limit_state_operations_total{operation, outcome}Do not place raw user IDs, API keys, or IP addresses in metric labels. High-cardinality or sensitive labels can damage both privacy and the monitoring system.
Failure policy is product policy
Fail-open preserves availability but can expose an expensive or sensitive dependency. Fail-closed preserves the quota but can turn a limiter outage into a service outage. The right choice may differ for public reads, login attempts, payments, and cost-bearing AI operations.
Choose fixed window deliberately
| Use fixed window when… | Choose another design when… |
|---|---|
| Quotas are coarse and boundary bursts are acceptable | A brief 2× burst is unsafe |
| Constant state per key matters | Exact moving-window fairness matters more |
| A simple product quota needs explainable behavior | Smooth traffic shaping is required |
| Local enforcement is an intentional first layer | One global budget must span replicas |
Pure decision function
Why: Time and arithmetic boundaries can be tested without cache setup or goroutines.
Alternative: Mix clock reads, storage, and arithmetic in one method, making failures harder to isolate.
Fixed lock shards
Why: Lock memory remains bounded while unrelated shards can progress independently.
Alternative: One mutex per attacker-controlled key creates an unbounded second state store.
Deny oversized positive cost
Why: The input is well-formed but cannot fit inside the policy budget; no state should be consumed.
Alternative: Return an operational error and blur invalid evaluation with valid policy denial.
Final verification
Run one complete evidence gate:
find internal/ratelimit -name '*.go' -print0 | xargs -0 gofmt -wgofmt -w cmd/server/main.gogo mod tidygo test ./...go test -count=20 ./internal/ratelimit/fixedwindowgo test -race ./...go test -run '^$' -bench=. -benchmem \ ./internal/ratelimit/fixedwindowgo build ./...go vet ./...go mod verifyCourse checkpoint
You have completed the course when you can defend these statements:
- fixed windows are globally aligned and reset lazily;
- boundary bursts are algorithm behavior, not a concurrency bug;
- individually safe
GetandSetcalls do not make their sequence atomic; - behavioral concurrency tests catch lost updates that
-racemay not report; - the clock is read once per decision;
- overflow-safe comparison uses subtraction after checking
cost <= limit; - denial is a valid decision, while storage failure is an error;
Retry-Afterpoints to the aligned boundary and rounds up at the HTTP edge;- LRU eviction can reset quota and therefore affects correctness;
- keyed locks coordinate only one process;
- N independent replicas can admit approximately N times a local limit;
- a distributed limiter needs one atomic state transition at the shared state owner.
The implementation is now honest and testable: production-minded because its boundaries are explicit, and intentionally incomplete because distributed coordination, identity trust, and failure policy deserve their own designs.
If you can explain the ticket-budget model, the fixed time boxes, the gift-card race, and why replicas need one atomic shared update, you understand the central ideas of this course.