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/sqliteis 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 errorsassert.NoError(t, err)-- record failure but continueassert.Equal(t, expected, actual, "message")-- prefer over raw comparisonsassert.True/assert.False-- for boolean checksassert.Empty/assert.Len-- for slice length checksassert.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:
CountTokenssuccess path -- the Google API URL is hardcoded; unit tests cannot intercept the HTTP call without URL injection support.yaml.Marshalerror path inPersist()--yaml.Marshaldoes not fail on valid Go structs.os.UserHomeDir()error path inNewWithConfig()-- triggered only when$HOMEis 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, becauseprune()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,gosimpleineffassign,typecheck,gocritic,gofmt
Disabled linters: exhaustive, wrapcheck.
Run golangci-lint run ./... to check before committing.