From 8c94b4f10180808fc12d3c6dfa6f9c2d7c5fb069 Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:02:58 +1100 Subject: [PATCH 1/6] Adding a basic token based algo --- .github/dependabot.yml | 14 +++++ .github/workflows/ci.yml | 50 ++++++++++++++++ .golangci.yml | 125 +++++++++++++++++++++++++++++++++++++++ go.mod | 3 + token/rate.go | 58 ++++++++++++++++++ token/rate_test.go | 53 +++++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml create mode 100644 go.mod create mode 100644 token/rate.go create mode 100644 token/rate_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7e2452f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "Australia/Sydney" + - package-ecosystem: gomod + directory: / + schedule: + interval: daily + time: "06:00" + timezone: "Australia/Sydney" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d0d067 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "stable" + + - name: Tidy Go modules + run: go mod tidy + + - name: Check for uncommitted go.mod/go.sum changes + run: | + if ! git diff --quiet go.mod; then + echo "::error::go.mod is not tidy. Run 'go mod tidy' and commit changes." + git diff go.mod + exit 1 + fi + if [ -f go.sum ] && ! git diff --quiet go.sum; then + echo "::error::go.sum is not tidy. Run 'go mod tidy' and commit changes." + git diff go.sum + exit 1 + fi + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + + - name: Run tests + run: go test -v -race ./... + + - name: Install go-test-coverage + run: go install github.com/vladopajic/go-test-coverage/v2@latest + + - name: Run Tests with Coverage + run: go test ./... -coverprofile=coverage.out -covermode=atomic + + - name: Check Coverage Threshold (95%) + run: go-test-coverage --config=.testcoverage.yml \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0801574 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,125 @@ +version: "2" +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - canonicalheader + - containedctx + - contextcheck + - copyloopvar + - cyclop + - decorder + - dogsled + - dupl + - dupword + - durationcheck + - errchkjson + - errname + - errorlint + - exhaustive + - exptostd + - fatcontext + - forbidigo + - funlen + - ginkgolinter + - gocheckcompilerdirectives + - gochecknoinits + - gochecksumtype + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosec + - gosmopolitan + - grouper + - iface + - importas + - interfacebloat + - intrange + - lll + - loggercheck + - maintidx + - makezero + - mirror + - misspell + - musttag + - nakedret + - nestif + - nilerr + - nilnesserr + - nilnil + - nlreturn + - noctx + - nolintlint + - nosprintfhostport + - perfsprint + - prealloc + - predeclared + - promlinter + - reassign + - recvcheck + - revive + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - staticcheck + - tagalign + - tagliatelle + - testableexamples + - testifylint + - testpackage + - thelper + - tparallel + - unconvert + - unparam + - usestdlibvars + - usetesting + - wastedassign + - whitespace + - wsl_v5 + - zerologlint + settings: + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ + rules: + - path: _test\.go$ + linters: + - funlen + - dupl + - path: internal/app/.*\.go$ + linters: + - funlen + - dupl +formatters: + enable: + - gci + - gofmt + - gofumpt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..14dcbdc --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module rate + +go 1.25 diff --git a/token/rate.go b/token/rate.go new file mode 100644 index 0000000..c2f975d --- /dev/null +++ b/token/rate.go @@ -0,0 +1,58 @@ +package token + +import "time" + +type Clock interface { + Now() time.Time +} + +type realClock struct{} + +func (c realClock) Now() time.Time { + return time.Now() +} + +type Limiter struct { + capacity, tokens, rate float64 + lastRefillAt time.Time + clock Clock +} + +func NewLimiter(capacity, rate float64) *Limiter { + clock := realClock{} + return &Limiter{ + capacity: capacity, + tokens: capacity, + rate: rate, + clock: clock, + lastRefillAt: clock.Now(), + } +} + +func NewLimiterWithClock(capacity, rate float64, clock Clock) *Limiter { + return &Limiter{ + capacity: capacity, + tokens: capacity, + rate: rate, + clock: clock, + lastRefillAt: clock.Now(), + } +} + +func (lim *Limiter) Allow() bool { + lim.refill() + if lim.tokens >= 1 { + lim.tokens-- + return true + } + return false +} + +func (lim *Limiter) refill() { + t := lim.clock.Now() + if t.Before(lim.lastRefillAt) { + return + } + lim.tokens = min(lim.capacity, lim.tokens+t.Sub(lim.lastRefillAt).Seconds()*lim.rate) + lim.lastRefillAt = t +} diff --git a/token/rate_test.go b/token/rate_test.go new file mode 100644 index 0000000..7a5789f --- /dev/null +++ b/token/rate_test.go @@ -0,0 +1,53 @@ +package token_test + +import ( + "testing" + "time" + + "rate/token" +) + +type testClock struct { + now time.Time +} + +func (c *testClock) Now() time.Time { + return c.now +} + +func (c *testClock) advance(by time.Duration) { + c.now = c.now.Add(by) +} + +func TestLimiter_Allow(t *testing.T) { + type fields struct { + capacity float64 + rate float64 + } + clock := &testClock{now: time.Now()} + tests := []struct { + name string + fields fields + previousAttempts int + advanceBy time.Duration + want bool + }{ + {name: "Test basic", fields: fields{capacity: 0, rate: 1}, want: false}, + {name: "Test basic", fields: fields{capacity: 1, rate: 1}, want: true}, + {name: "Test After 1 attempt", fields: fields{capacity: 1, rate: 1}, previousAttempts: 1, want: false}, + {name: "Test after many attempts", fields: fields{capacity: 5, rate: 2}, previousAttempts: 4, want: true}, + {name: "Test after many attempts and 2 sec", fields: fields{capacity: 5, rate: 2}, previousAttempts: 7, advanceBy: 2 * time.Second, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lim := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) + for i := 0; i < tt.previousAttempts; i++ { + lim.Allow() + } + clock.advance(tt.advanceBy) + if got := lim.Allow(); got != tt.want { + t.Errorf("Allow() = %v, want %v", got, tt.want) + } + }) + } +} From 5ddf26e2e3b30aa1c4487b6e85787c42e776badb Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:05:22 +1100 Subject: [PATCH 2/6] Linting --- go.mod | 2 +- token/rate.go | 5 +++++ token/rate_test.go | 30 +++++++++++++++++++++++++----- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 14dcbdc..d9e684f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module rate +module github.com/serroba/rate go 1.25 diff --git a/token/rate.go b/token/rate.go index c2f975d..7cb413e 100644 --- a/token/rate.go +++ b/token/rate.go @@ -20,6 +20,7 @@ type Limiter struct { func NewLimiter(capacity, rate float64) *Limiter { clock := realClock{} + return &Limiter{ capacity: capacity, tokens: capacity, @@ -41,10 +42,13 @@ func NewLimiterWithClock(capacity, rate float64, clock Clock) *Limiter { func (lim *Limiter) Allow() bool { lim.refill() + if lim.tokens >= 1 { lim.tokens-- + return true } + return false } @@ -53,6 +57,7 @@ func (lim *Limiter) refill() { if t.Before(lim.lastRefillAt) { return } + lim.tokens = min(lim.capacity, lim.tokens+t.Sub(lim.lastRefillAt).Seconds()*lim.rate) lim.lastRefillAt = t } diff --git a/token/rate_test.go b/token/rate_test.go index 7a5789f..4fcc96f 100644 --- a/token/rate_test.go +++ b/token/rate_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "rate/token" + "github.com/serroba/rate/token" ) type testClock struct { @@ -24,7 +24,9 @@ func TestLimiter_Allow(t *testing.T) { capacity float64 rate float64 } + clock := &testClock{now: time.Now()} + tests := []struct { name string fields fields @@ -34,17 +36,35 @@ func TestLimiter_Allow(t *testing.T) { }{ {name: "Test basic", fields: fields{capacity: 0, rate: 1}, want: false}, {name: "Test basic", fields: fields{capacity: 1, rate: 1}, want: true}, - {name: "Test After 1 attempt", fields: fields{capacity: 1, rate: 1}, previousAttempts: 1, want: false}, - {name: "Test after many attempts", fields: fields{capacity: 5, rate: 2}, previousAttempts: 4, want: true}, - {name: "Test after many attempts and 2 sec", fields: fields{capacity: 5, rate: 2}, previousAttempts: 7, advanceBy: 2 * time.Second, want: true}, + { + name: "Test After 1 attempt", + fields: fields{capacity: 1, rate: 1}, + previousAttempts: 1, + want: false, + }, + { + name: "Test after many attempts", + fields: fields{capacity: 5, rate: 2}, + previousAttempts: 4, + want: true, + }, + { + name: "Test after many attempts and 2 sec", + fields: fields{capacity: 5, rate: 2}, + previousAttempts: 7, + advanceBy: 2 * time.Second, + want: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { lim := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) - for i := 0; i < tt.previousAttempts; i++ { + for range tt.previousAttempts { lim.Allow() } + clock.advance(tt.advanceBy) + if got := lim.Allow(); got != tt.want { t.Errorf("Allow() = %v, want %v", got, tt.want) } From 570515e805ea646afc0230f1995fcff7988e21bc Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:07:03 +1100 Subject: [PATCH 3/6] Add test cove --- .testcoverage.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .testcoverage.yml diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..e8a190b --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,24 @@ +# Configuration for go-test-coverage +# See: https://github.com/vladopajic/go-test-coverage + +profile: coverage.out + +threshold: + # Total coverage threshold + total: 95 + + # Per-file thresholds (optional) + file: 0 + +# Exclude patterns - these packages won't count toward coverage +exclude: + # Main entry points and DI wiring + paths: + - ^cmd/ + - ^internal/container/ + - ^internal/handlers/routes\.go$ + # Infrastructure stores tested via integration tests + - ^internal/store/redis\.go$ + - ^internal/store/postgres\.go$ + - ^internal/store/redis_cache\.go$ + - ^internal/analytics/store/postgres\.go$ From 106ac9ec3f24ab473ff8147848be72f2188df575 Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:15:05 +1100 Subject: [PATCH 4/6] Adding better initialisation --- go.mod | 8 ++++++++ go.sum | 10 ++++++++++ token/rate.go | 27 ++++++++++++++------------- token/rate_test.go | 8 +++++--- 4 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 go.sum diff --git a/go.mod b/go.mod index d9e684f..95a24bb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/serroba/rate go 1.25 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/token/rate.go b/token/rate.go index 7cb413e..ccc988d 100644 --- a/token/rate.go +++ b/token/rate.go @@ -1,6 +1,9 @@ package token -import "time" +import ( + "fmt" + "time" +) type Clock interface { Now() time.Time @@ -18,26 +21,24 @@ type Limiter struct { clock Clock } -func NewLimiter(capacity, rate float64) *Limiter { - clock := realClock{} - - return &Limiter{ - capacity: capacity, - tokens: capacity, - rate: rate, - clock: clock, - lastRefillAt: clock.Now(), - } +func NewLimiter(capacity, rate float64) (*Limiter, error) { + return NewLimiterWithClock(capacity, rate, realClock{}) } -func NewLimiterWithClock(capacity, rate float64, clock Clock) *Limiter { +func NewLimiterWithClock(capacity, rate float64, clock Clock) (*Limiter, error) { + if capacity < 0 { + return nil, fmt.Errorf("capacity must be greater than zero") + } + if rate < 0 { + return nil, fmt.Errorf("rate must be greater than zero") + } return &Limiter{ capacity: capacity, tokens: capacity, rate: rate, clock: clock, lastRefillAt: clock.Now(), - } + }, nil } func (lim *Limiter) Allow() bool { diff --git a/token/rate_test.go b/token/rate_test.go index 4fcc96f..df3643f 100644 --- a/token/rate_test.go +++ b/token/rate_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/serroba/rate/token" + "github.com/stretchr/testify/require" ) type testClock struct { @@ -34,8 +35,8 @@ func TestLimiter_Allow(t *testing.T) { advanceBy time.Duration want bool }{ - {name: "Test basic", fields: fields{capacity: 0, rate: 1}, want: false}, - {name: "Test basic", fields: fields{capacity: 1, rate: 1}, want: true}, + {name: "Test with zero capacity", fields: fields{capacity: 0, rate: 1}, want: false}, + {name: "Test with capacity of one", fields: fields{capacity: 1, rate: 1}, want: true}, { name: "Test After 1 attempt", fields: fields{capacity: 1, rate: 1}, @@ -58,7 +59,8 @@ func TestLimiter_Allow(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lim := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) + lim, err := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) + require.NoError(t, err) for range tt.previousAttempts { lim.Allow() } From 37fa4a22d6e720db18d5f0f9d274319df94ca87e Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:18:15 +1100 Subject: [PATCH 5/6] Add comments and lint the code --- token/rate.go | 24 ++++++++++++++++++------ token/rate_test.go | 1 + 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/token/rate.go b/token/rate.go index ccc988d..d9fc558 100644 --- a/token/rate.go +++ b/token/rate.go @@ -1,11 +1,11 @@ package token import ( - "fmt" + "errors" "time" ) -type Clock interface { +type clock interface { Now() time.Time } @@ -15,23 +15,32 @@ func (c realClock) Now() time.Time { return time.Now() } +// Limiter implements a token bucket rate limiter. It allows a burst of +// requests up to capacity, then refills tokens at the specified rate per second. type Limiter struct { capacity, tokens, rate float64 lastRefillAt time.Time - clock Clock + clock clock } +// NewLimiter creates a new rate limiter with the given capacity and refill rate. +// Capacity is the maximum burst size. Rate is tokens added per second. +// Returns an error if capacity or rate is negative. func NewLimiter(capacity, rate float64) (*Limiter, error) { return NewLimiterWithClock(capacity, rate, realClock{}) } -func NewLimiterWithClock(capacity, rate float64, clock Clock) (*Limiter, error) { +// NewLimiterWithClock creates a new rate limiter with a custom clock. +// Use this constructor for testing with a mock clock. +func NewLimiterWithClock(capacity, rate float64, clock clock) (*Limiter, error) { if capacity < 0 { - return nil, fmt.Errorf("capacity must be greater than zero") + return nil, errors.New("capacity must be greater than zero") } + if rate < 0 { - return nil, fmt.Errorf("rate must be greater than zero") + return nil, errors.New("rate must be greater than zero") } + return &Limiter{ capacity: capacity, tokens: capacity, @@ -41,6 +50,9 @@ func NewLimiterWithClock(capacity, rate float64, clock Clock) (*Limiter, error) }, nil } +// Allow reports whether a request is allowed. It consumes one token if +// available and returns true. If no tokens are available, it returns false +// without blocking. func (lim *Limiter) Allow() bool { lim.refill() diff --git a/token/rate_test.go b/token/rate_test.go index df3643f..fd41dbd 100644 --- a/token/rate_test.go +++ b/token/rate_test.go @@ -61,6 +61,7 @@ func TestLimiter_Allow(t *testing.T) { t.Run(tt.name, func(t *testing.T) { lim, err := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) require.NoError(t, err) + for range tt.previousAttempts { lim.Allow() } From f46216db9965d5a57be97a4183e85db0d7a41e2e Mon Sep 17 00:00:00 2001 From: Sebastian Machuca Date: Wed, 31 Dec 2025 14:28:21 +1100 Subject: [PATCH 6/6] adding coverage --- token/rate_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/token/rate_test.go b/token/rate_test.go index fd41dbd..b2bac05 100644 --- a/token/rate_test.go +++ b/token/rate_test.go @@ -20,6 +20,38 @@ func (c *testClock) advance(by time.Duration) { c.now = c.now.Add(by) } +func TestNewLimiter(t *testing.T) { + lim, err := token.NewLimiter(5, 2) + require.NoError(t, err) + require.True(t, lim.Allow()) +} + +func TestNewLimiterWithClock_NegativeCapacity(t *testing.T) { + clock := &testClock{now: time.Now()} + _, err := token.NewLimiterWithClock(-1, 2, clock) + require.Error(t, err) +} + +func TestNewLimiterWithClock_NegativeRate(t *testing.T) { + clock := &testClock{now: time.Now()} + _, err := token.NewLimiterWithClock(5, -1, clock) + require.Error(t, err) +} + +func TestLimiter_Allow_ClockGoesBackwards(t *testing.T) { + clock := &testClock{now: time.Now()} + lim, err := token.NewLimiterWithClock(1, 1, clock) + require.NoError(t, err) + + // Drain the token + require.True(t, lim.Allow()) + + // Move clock backwards - should not refill + clock.now = clock.now.Add(-1 * time.Second) + + require.False(t, lim.Allow()) +} + func TestLimiter_Allow(t *testing.T) { type fields struct { capacity float64