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:

repository
internal/ ratelimit/     doc.go     decision.go     limiter.go     clock.go     window.go     window_test.go

Go'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:

internal/ratelimit/doc.go
// 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 ratelimit

The 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:

example.go
Allow(key string) bool

It 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:

internal/ratelimit/decision.go
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.

FieldAllowed requestDenied request
Allowedtruefalse
LimitPolicy ceilingSame policy ceiling
RemainingZero or greaterUsually zero; never negative
RetryAfterZeroPositive when a retry time is known
ResetAtNext useful change, or zeroNext useful change, or zero
ReasonEmptyStable 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:

denial-vs-error
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:

internal/ratelimit/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.

PiecePlain-language meaning
ctxStop this work if the caller leaves or times out
keyThe caller and action that own the budget
costHow many budget units this request spends
DecisionThe allow-or-deny answer and useful details
errorThe 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:

snippet.text
metadata read   cost 1report export   cost 20video render    cost 50

Zero 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

SituationDecisionErrorMeaning
Quota availableAllowed: truenilEvaluation succeeded
Quota exhaustedAllowed: falsenilEvaluation succeeded
Invalid costZero valueNon-nilEvaluation did not run
Context cancelledZero valueNon-nilCaller no longer wants the answer
State backend unavailableZero valueNon-nilNo 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.

Flow· Admission and failure are different paths
Allow(ctx, key, cost)
          │
          ├── valid evaluation ───► Decision{Allowed: true}, nil
          │
          ├── valid evaluation ───► Decision{Allowed: false}, nil
          │
          └── cannot evaluate ────► Decision{}, error

Do not invent a universal Policy

This struct looks reusable but is mostly a bag of optional fields:

do-not-use.go
type Policy struct {	Limit           int64	Window          time.Duration	Capacity        int64	RefillPerSecond float64	MaxConcurrent   int64}

The algorithms have different natural configuration:

AlgorithmNatural policy
Fixed windowLimit and window
Sliding windowLimit and moving window
Token bucketCapacity and refill rate
Leaky bucketQueue capacity and drain rate
Concurrency limiterMaximum 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:

internal/ratelimit/clock.go
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:

preview — added to Lesson 3's test file
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:

snippet.text
2^53 = 9,007,199,254,740,992

Modern 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:

internal/ratelimit/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:

example.go
window := 1500 * time.Microsecond

But 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:

internal/ratelimit/window_test.go
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:

terminal
gofmt -w internal/ratelimit/*.gogo test ./...go test -race ./...go build ./...go vet ./...go doc ./internal/ratelimitgo doc ./internal/ratelimit Decisiongo doc ./internal/ratelimit Limiter

Then ask:

  1. Can a caller distinguish denial from failure?
  2. Is cost > 0 explicit?
  3. Is the package free from HTTP types?
  4. Can time-dependent code be tested without sleeping?
  5. 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 Decision and a nil error;
  • why Limiter accepts 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µs is 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.

© 2026 Tonmoy Talukder

Open to Academic / Research Opportunities · 2026
light theme, blue accent