go-ratelimit/docs/development.md

253 lines
7.2 KiB
Markdown
Raw Normal View History

---
title: Development Guide
description: 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
```bash
# 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()`:
```go
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:
```go
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.