Lesson 2 of 3 · 40 min
Design the Small Contracts We Need
Define a few small Go types so later algorithms can return clear answers, report failures correctly, and be tested without waiting for real time.
Learning objectives
- Create a Decision type that explains allow and deny results.
- Create one small Limiter interface that does not depend on HTTP.
- Replace the real clock in tests so time can move instantly.
- Validate time windows before an algorithm uses them.
Why contracts come before algorithms
Lesson 1 built a server, but it did not decide what a limiter should return.
At first, true for “allow” and false for “deny” may seem enough. Real callers need more help:
- How much budget is left?
- How long should the caller wait?
- Did the limiter deny the request, or did the limiter itself break?
This lesson creates a few small Go types that answer those questions. In software design, those shared types and method signatures are called contracts.
In simple words
A contract is a promise between two pieces of code. It says, “Give me these inputs, and I will return this kind of answer.”
Our contract should be:
- small;
- useful outside HTTP;
- clear about denial versus failure;
- easy to test.
Lesson boundary
This lesson defines vocabulary only. Do not add counters, stores, mutexes, middleware, Redis, or 429 responses yet.
Keep the API experimental
Create an internal package:
internal/└── ratelimit/ ├── doc.go ├── decision.go ├── limiter.go ├── clock.go ├── window.go └── window_test.goGo's internal directory acts like a “not public yet” sign. Code in this project can use the package, but other projects cannot import it. We can still improve the design without breaking external users.
In doc.go, state the package's responsibility:
// Package ratelimit defines the common contracts shared by rate-limiting// algorithms. It separates an admission decision from operational failure and// provides an injectable source of time for deterministic implementations.//// Algorithm-specific policies, state, and behavior belong to their// implementation packages.package ratelimitThe last sentence keeps this package focused. It stores shared vocabulary, not every algorithm's settings and data.
A Boolean is not a decision
Consider the smallest possible API:
Allow(key string) boolIt answers one question, but it hides everything else:
- how much quota remains;
- when a retry may succeed;
- when state resets;
- why a composed limiter denied the request;
- whether the request was denied or the limiter failed.
Model the result explicitly:
package ratelimitimport "time"// Decision is returned for both allowed and denied requests.// Only a non-nil error means the limiter failed to evaluate the request.type Decision struct { Allowed bool // Limit is the policy ceiling in the algorithm's unit. Limit int64 // Remaining is the budget after this decision. It is never negative. Remaining int64 // RetryAfter is zero when Allowed is true. RetryAfter time.Duration // ResetAt is the next useful state-change instant. // The zero value means unknown or not applicable. ResetAt time.Time // Reason is empty when Allowed is true. Reason string}Make the invariants memorable
An invariant is a rule that must always remain true. For example, remaining quota must never be negative.
| Field | Allowed request | Denied request |
|---|---|---|
Allowed | true | false |
Limit | Policy ceiling | Same policy ceiling |
Remaining | Zero or greater | Usually zero; never negative |
RetryAfter | Zero | Positive when a retry time is known |
ResetAt | Next useful change, or zero | Next useful change, or zero |
Reason | Empty | Stable machine-readable tag |
Do not add fields such as Tokens, QueueDepth, or Window. They do not have honest meanings for every algorithm.
Notice that RetryAfter is a time.Duration, not a whole number of seconds. The limiter computes precise time; only the HTTP layer renders HTTP. If the algorithm rounded to whole seconds here, a future gRPC or queue consumer would inherit HTTP's coarseness for no reason, and Lesson 1's round-up rule could no longer be applied at the edge where it belongs.
Important semantic split
Denial means the limiter worked and enforced policy. An error means the limiter could not produce a trustworthy decision.
Think about an ATM:
“Insufficient balance” → the ATM worked and denied the withdrawal.“Cannot reach bank” → the ATM failed to make a trustworthy decision.Rate limiting uses the same distinction.
Design the Limiter interface
Create limiter.go:
package ratelimitimport "context"// Limiter decides whether a key may consume cost units.//// Implementations must be safe for concurrent use. Cost must be greater than// zero; invalid cost returns an error without creating or changing state.type Limiter interface { Allow(ctx context.Context, key string, cost int64) (Decision, error)}Read the signature from left to right.
| Piece | Plain-language meaning |
|---|---|
ctx | Stop this work if the caller leaves or times out |
key | The caller and action that own the budget |
cost | How many budget units this request spends |
Decision | The allow-or-deny answer and useful details |
error | The limiter could not make a trustworthy decision |
Context controls the lifetime
context.Context lets the caller say, “Stop working; I no longer need the answer.” This matters when a future limiter waits on Redis or another network service.
Without it, the limiter could continue waiting on a slow state backend after the original HTTP request has already disappeared.
Key is already resolved
The limiter accepts a string, not *http.Request. Authentication, route normalization, proxy trust, and header rendering belong to the transport edge.
That separation lets the same interface work in:
- HTTP middleware;
- a gRPC interceptor;
- a background worker;
- a queue consumer.
Cost enables weighted work
Most examples use cost = 1, but not all operations are equal:
metadata read → cost 1report export → cost 20video render → cost 50Zero and negative costs are invalid. A negative cost must never mint quota.
For your first pass through the course, it is fine to imagine that every request costs 1.
Decision and error are separate channels
| Situation | Decision | Error | Meaning |
|---|---|---|---|
| Quota available | Allowed: true | nil | Evaluation succeeded |
| Quota exhausted | Allowed: false | nil | Evaluation succeeded |
| Invalid cost | Zero value | Non-nil | Evaluation did not run |
| Context cancelled | Zero value | Non-nil | Caller no longer wants the answer |
| State backend unavailable | Zero value | Non-nil | No trustworthy decision exists |
This distinction is what later enables an explicit fail-open or fail-closed policy. If failures masquerade as denials, the transport layer cannot choose safely.
Allow(ctx, key, cost)
│
├── valid evaluation ───► Decision{Allowed: true}, nil
│
├── valid evaluation ───► Decision{Allowed: false}, nil
│
└── cannot evaluate ────► Decision{}, errorDo not invent a universal Policy
This struct looks reusable but is mostly a bag of optional fields:
type Policy struct { Limit int64 Window time.Duration Capacity int64 RefillPerSecond float64 MaxConcurrent int64}The algorithms have different natural configuration:
| Algorithm | Natural policy |
|---|---|
| Fixed window | Limit and window |
| Sliding window | Limit and moving window |
| Token bucket | Capacity and refill rate |
| Leaky bucket | Queue capacity and drain rate |
| Concurrency limiter | Maximum active operations |
Each algorithm should own and validate its policy. The common contract is admission, not configuration.
Beginner rule
Share types only when they truly mean the same thing. Similar names do not guarantee similar behavior.
Make time a dependency
Time is an input to every rate-limiting calculation. If the code secretly calls time.Now(), a test must wait for real time to pass. That is slow and unreliable.
Create a minimal clock:
package ratelimitimport "time"// Clock supplies the current time.type Clock interface { Now() time.Time}// SystemClock reads the process wall clock.type SystemClock struct{}func (SystemClock) Now() time.Time { return time.Now()}var _ Clock = SystemClock{}The last line is a compile-time assertion that SystemClock implements Clock.
Tests can provide a controllable fake. Read this now, but do not create a file for it yet—it belongs next to the tests that use it, and the first such tests appear in Lesson 3's algorithm package:
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)}Now a test can move forward one hour instantly. It does not sleep, and it gets the exact same result on every machine. Keeping the fake out of production code is deliberate: a test helper earns a shared package only after a second test package needs it.
Do not memorize the mutex yet
The mutex only makes the fake clock safe when several goroutines use it. The main idea is simpler: production uses real time; tests use controllable time.
Keep Clock to one method. Add timers or sleeping only when a real traffic-shaping algorithm needs them.
Establish one time-precision contract
The rule we need is simple:
Every window must be a whole number of milliseconds.
That means 1ms, 500ms, and 1s are valid. 1.5ms is rejected.
The deeper reason matters later: local Go code and a future Redis implementation should calculate the same boundaries.
Redis Lua number arithmetic uses IEEE-754 doubles. Integers are exact only through:
2^53 = 9,007,199,254,740,992Modern Unix timestamps in nanoseconds exceed that range. Unix milliseconds remain exact for far longer than this system needs and are appropriate for a network-backed limiter.
First-pass takeaway
You do not need to memorize 2^53. Remember that different systems represent numbers differently, so we choose one safe shared unit: Unix milliseconds.
The complete rule is: a window is a whole number of milliseconds, at least one millisecond, and at most one year.
One common misreading: the one-millisecond minimum is a precision floor, not a rate floor. It restricts how finely a window can be expressed, not how many requests must be allowed. Limit: 1 per Window: time.Hour is a perfectly valid, very strict policy.
Create window.go:
package ratelimitimport ( "fmt" "time")const ( MinWindow time.Duration = time.Millisecond MaxWindow time.Duration = 24 * 365 * time.Hour)func ValidateWindow(window time.Duration) error { switch { case window < MinWindow: return fmt.Errorf( "ratelimit: window %s is below %s", window, MinWindow, ) case window%time.Millisecond != 0: return fmt.Errorf( "ratelimit: window %s is not a whole millisecond", window, ) case window > MaxWindow: return fmt.Errorf( "ratelimit: window %s exceeds %s", window, MaxWindow, ) default: return nil }}Reject instead of silently truncating
This duration is legal Go:
window := 1500 * time.MicrosecondBut window.Milliseconds() returns 1, not 1.5. Quietly changing 1.5 ms into 1 ms would enforce the wrong policy, so validation returns an error instead.
Test boundaries, not implementation trivia
A table-driven test makes the precision contract readable:
package ratelimitimport ( "testing" "time")func TestValidateWindow(t *testing.T) { t.Parallel() tests := []struct { name string window time.Duration wantErr bool }{ {"zero", 0, true}, {"negative", -time.Second, true}, {"below minimum", 500 * time.Microsecond, true}, {"fractional millisecond", 1500 * time.Microsecond, true}, {"minimum", MinWindow, false}, {"one second", time.Second, false}, {"maximum", MaxWindow, false}, {"above maximum", MaxWindow + time.Millisecond, true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() err := ValidateWindow(test.window) if test.wantErr && err == nil { t.Fatalf("ValidateWindow(%s) error = nil", test.window) } if !test.wantErr && err != nil { t.Fatalf("ValidateWindow(%s) error = %v", test.window, err) } }) }}Test whether an error exists, not its full prose. Error taxonomy is not stable yet.
Do not write a test that assigns Decision.Remaining and immediately reads it back. That tests the Go language. Save tests for behavior, invariants, transformations, and boundaries.
Read the package as a consumer
Run the full gate:
gofmt -w internal/ratelimit/*.gogo test ./...go test -race ./...go build ./...go vet ./...go doc ./internal/ratelimitgo doc ./internal/ratelimit Decisiongo doc ./internal/ratelimit LimiterThen ask:
- Can a caller distinguish denial from failure?
- Is
cost > 0explicit? - Is the package free from HTTP types?
- Can time-dependent code be tested without sleeping?
- Did an algorithm-specific concept leak into the shared package?
If those questions are hard to answer from the names and comments, improve the documentation before adding more code.
Return Decision by value
Why: The result is small, immutable in spirit, and has a useful zero value when an error occurs. A pointer would add a second nil channel.
Alternative: Return *Decision, which complicates ownership and may add an allocation without providing useful semantics.
Keep Reason as a string
Why: Several algorithms must reveal the stable reason set before the course can freeze constants.
Alternative: Define an enum now and risk breaking it when real implementations produce new reasons.
Keep the package internal
Why: Multiple algorithms should prove the API before external users depend on it.
Alternative: Publish immediately and turn every early correction into a compatibility problem.
Lesson 2 checkpoint
You are ready to implement an algorithm when you can explain:
- why a denied request returns a valid
Decisionand a nil error; - why
Limiteraccepts context, resolved key, and positive cost; - why HTTP requests and headers stay outside the algorithm;
- why algorithm policies are not forced into one universal struct;
- why deterministic tests inject a clock;
- why whole Unix milliseconds are the shared precision;
- why
1500µsis rejected instead of truncated.
Lesson 3 will put these contracts under real pressure: mutable state, concurrent goroutines, overflow-safe arithmetic, and a storage interface that is thread-safe but not transaction-safe.