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/.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$ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95a24bb --- /dev/null +++ b/go.mod @@ -0,0 +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 new file mode 100644 index 0000000..d9fc558 --- /dev/null +++ b/token/rate.go @@ -0,0 +1,76 @@ +package token + +import ( + "errors" + "time" +) + +type clock interface { + Now() time.Time +} + +type realClock struct{} + +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 +} + +// 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{}) +} + +// 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, errors.New("capacity must be greater than zero") + } + + if rate < 0 { + return nil, errors.New("rate must be greater than zero") + } + + return &Limiter{ + capacity: capacity, + tokens: capacity, + rate: rate, + clock: clock, + lastRefillAt: clock.Now(), + }, 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() + + 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..b2bac05 --- /dev/null +++ b/token/rate_test.go @@ -0,0 +1,108 @@ +package token_test + +import ( + "testing" + "time" + + "github.com/serroba/rate/token" + "github.com/stretchr/testify/require" +) + +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 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 + rate float64 + } + + clock := &testClock{now: time.Now()} + + tests := []struct { + name string + fields fields + previousAttempts int + advanceBy time.Duration + want bool + }{ + {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}, + 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, err := token.NewLimiterWithClock(tt.fields.capacity, tt.fields.rate, clock) + require.NoError(t, err) + + 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) + } + }) + } +}