go-ratelimit/docs/development.md
Snider 9572425e89
All checks were successful
Security Scan / security (push) Successful in 6s
Test / test (push) Successful in 45s
docs: add human-friendly documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:40 +00:00

7.2 KiB

title description
Development Guide How to build, test, and contribute to go-ratelimit -- prerequisites, test patterns, coding standards, and commit conventions.

Development Guide

Prerequisites

  • Go 1.26 or later (the module declares go 1.26.0)
  • No CGO required -- modernc.org/sqlite is a pure Go port

No C toolchain, no system SQLite library, no external build tools. A plain go build ./... is sufficient.


Build and Test

# Run all tests
go test ./...

# Run all tests with the race detector (required before every commit)
go test -race ./...

# Run a single test by name
go test -v -run TestCanSend ./...

# Run a single subtest
go test -v -run "TestCanSend/RPM_at_exact_limit_is_rejected" ./...

# Run benchmarks
go test -bench=. -benchmem ./...

# Run a specific benchmark
go test -bench=BenchmarkCanSend -benchmem ./...

# Check for vet issues
go vet ./...

# Lint (requires golangci-lint)
golangci-lint run ./...

# Tidy dependencies
go mod tidy

All three commands (go test -race ./..., go vet ./..., and go mod tidy) must produce no errors or warnings before a commit is pushed.


Test Organisation

File Layout

File Scope
ratelimit_test.go Core sliding window logic, provider profiles, concurrency, benchmarks
sqlite_test.go SQLite backend, migration, concurrent persistence
error_test.go SQLite and YAML error paths
iter_test.go Models() and Iter() iterators, CountTokens edge cases

All test files are in package ratelimit (white-box tests), giving access to unexported fields and methods such as prune(), filePath, and sqlite.

Naming Convention

SQLite tests follow the _Good, _Bad, _Ugly suffix pattern:

  • _Good -- happy path
  • _Bad -- expected error conditions (invalid paths, corrupt input)
  • _Ugly -- panic-adjacent edge cases (corrupt database files, truncated files)

Core logic tests use plain descriptive names without suffixes, grouped by method with table-driven subtests.

Test Helper

newTestLimiter(t) creates a RateLimiter with Gemini defaults and redirects the YAML file path into t.TempDir():

func newTestLimiter(t *testing.T) *RateLimiter {
    t.Helper()
    rl, err := New()
    require.NoError(t, err)
    rl.filePath = filepath.Join(t.TempDir(), "ratelimits.yaml")
    return rl
}

Use t.TempDir() for all file paths in tests. Go cleans these up automatically after each test completes.

Testify Usage

Tests use github.com/stretchr/testify exclusively:

  • require.NoError(t, err) -- fail immediately on setup errors
  • assert.NoError(t, err) -- record failure but continue
  • assert.Equal(t, expected, actual, "message") -- prefer over raw comparisons
  • assert.True / assert.False -- for boolean checks
  • assert.Empty / assert.Len -- for slice length checks
  • assert.ErrorIs(t, err, target) -- for sentinel errors

Do not use t.Error, t.Fatal, or t.Log directly.

Concurrency Tests

Race tests spin up goroutines and use sync.WaitGroup. Some assert specific outcomes (e.g., correct RPD count after concurrent recordings), while others rely solely on the race detector to catch data races:

var wg sync.WaitGroup
for range 20 {
    wg.Go(func() {
        for range 50 {
            rl.CanSend(model, 10)
            rl.RecordUsage(model, 5, 5)
            rl.Stats(model)
        }
    })
}
wg.Wait()

Always run concurrency tests with -race.

Benchmarks

The following benchmarks are included:

Benchmark What it measures
BenchmarkCanSend CanSend with a 1,000-entry sliding window
BenchmarkRecordUsage Recording usage on a single model
BenchmarkCanSendConcurrent Parallel CanSend across goroutines
BenchmarkCanSendWithPrune CanSend with 500 old + 500 new entries
BenchmarkStats Stats retrieval with a 1,000-entry window
BenchmarkAllStats AllStats across 5 models x 200 entries each
BenchmarkPersist YAML persistence I/O
BenchmarkSQLitePersist SQLite persistence I/O
BenchmarkSQLiteLoad SQLite state loading

Coverage

Current coverage: 95.1%. The remaining paths cannot be covered in unit tests without modifying production code:

  1. CountTokens success path -- the Google API URL is hardcoded; unit tests cannot intercept the HTTP call without URL injection support.
  2. yaml.Marshal error path in Persist() -- yaml.Marshal does not fail on valid Go structs.
  3. os.UserHomeDir() error path in NewWithConfig() -- triggered only when $HOME is unset, which test infrastructure prevents.

Do not lower coverage below 95% without a documented reason.


Coding Standards

Language

UK English throughout: colour, organisation, serialise, initialise, behaviour. Do not use American spellings in identifiers, comments, or documentation.

Go Style

  • All exported types, functions, and fields must have doc comments.
  • Error strings must be lowercase and not end with punctuation (Go convention).
  • Contextual errors use fmt.Errorf("ratelimit.Function: what: %w", err) so errors identify their origin clearly.
  • No init() functions.
  • No global mutable state. DefaultProfiles() returns a fresh map on each call.

Mutex Discipline

The RateLimiter.mu mutex is the only synchronisation primitive. Rules:

  • Methods that call prune() always acquire the write lock (mu.Lock()), even if they appear read-only, because prune() mutates state slices.
  • Persist() acquires the write lock briefly to clone state, then releases it before performing I/O.
  • Lock acquisition always happens at the top of the public method, never inside a helper. Helpers document "Caller must hold the lock".
  • Never call a public method from inside another public method while holding the lock (deadlock risk).

Dependencies

Direct dependencies are intentionally minimal:

Dependency Purpose
gopkg.in/yaml.v3 YAML serialisation for legacy backend
modernc.org/sqlite Pure Go SQLite for persistent backend
github.com/stretchr/testify Test assertions (test-only)

Do not add database/sql drivers beyond modernc.org/sqlite. Do not add HTTP client libraries; the existing CountTokens function uses the standard library.


Licence

EUPL-1.2. Confirm with the project lead before adding files under a different licence.


Commit Convention

Format: type(scope): description

Common types: feat, fix, test, refactor, docs, perf, chore

Common scopes: ratelimit, sqlite, persist, config

Every commit must include:

Co-Authored-By: Virgil <virgil@lethean.io>

Example:

feat(sqlite): add WAL-mode SQLite backend with migration helper

Co-Authored-By: Virgil <virgil@lethean.io>

Commits must not be pushed unless go test -race ./... and go vet ./... both pass. go mod tidy must produce no changes.


Linting

The project uses golangci-lint with the following enabled linters (see .golangci.yml):

  • govet, errcheck, staticcheck, unused, gosimple
  • ineffassign, typecheck, gocritic, gofmt

Disabled linters: exhaustive, wrapcheck.

Run golangci-lint run ./... to check before committing.