Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
125 changes: 125 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -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$
24 changes: 24 additions & 0 deletions .testcoverage.yml
Original file line number Diff line number Diff line change
@@ -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$
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
76 changes: 76 additions & 0 deletions token/rate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package token

import (
"errors"
"time"
)

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public Clock interface lacks documentation. According to Go conventions, exported types should have a comment describing their purpose. Consider adding a comment like "// Clock provides the current time, allowing for time injection in tests."

Suggested change
// Clock provides the current time, allowing for time injection in tests.

Copilot uses AI. Check for mistakes.
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
}
Comment on lines +20 to +24
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public Limiter struct lacks documentation. According to Go conventions, exported types should have a comment describing their purpose. Consider adding a comment explaining that this is a token bucket rate limiter and describing its parameters.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public NewLimiter function lacks documentation. According to Go conventions, exported functions should have a comment describing their purpose, parameters, and return values. Consider adding a comment explaining what capacity and rate represent (e.g., maximum tokens and tokens per second).

Suggested change
// NewLimiter creates a token-bucket limiter with the given capacity and rate.
// capacity is the maximum number of tokens that can be accumulated in the bucket,
// and rate is the number of tokens added to the bucket per second.
// The returned Limiter is initialized with a real-time clock and starts full.

Copilot uses AI. Check for mistakes.
// 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")
}

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public NewLimiterWithClock function lacks documentation. According to Go conventions, exported functions should have a comment describing their purpose. Consider adding a comment explaining this is primarily for testing purposes and what each parameter represents.

Suggested change
// NewLimiterWithClock creates a Limiter with the given capacity and refill rate,
// using the provided Clock implementation. This is primarily intended for tests
// that need to control the passage of time.

Copilot uses AI. Check for mistakes.
return &Limiter{
capacity: capacity,
tokens: capacity,
rate: rate,
clock: clock,
lastRefillAt: clock.Now(),
}, nil
}

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The public Allow method lacks documentation. According to Go conventions, exported methods should have a comment describing their purpose and behavior. Consider adding a comment explaining that it attempts to consume one token and returns whether the operation was successful.

Suggested change
// Allow attempts to consume one token from the limiter and reports whether it succeeded.

Copilot uses AI. Check for mistakes.
// 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
Comment on lines +52 to +62
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Limiter is not thread-safe. The Allow method reads and modifies shared state (tokens, lastRefillAt) without synchronization, which can lead to race conditions when accessed from multiple goroutines. Consider adding a mutex (sync.Mutex) to protect access to these fields, especially since rate limiters are commonly used in concurrent scenarios.

Copilot uses AI. Check for mistakes.
}

return false
}

func (lim *Limiter) refill() {
t := lim.clock.Now()
if t.Before(lim.lastRefillAt) {
return
Comment on lines +64 to +71
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refill method modifies shared state without synchronization, which will cause race conditions in concurrent usage. This method should be protected by the same mutex that guards the Allow method.

Copilot uses AI. Check for mistakes.
}

lim.tokens = min(lim.capacity, lim.tokens+t.Sub(lim.lastRefillAt).Seconds()*lim.rate)
lim.lastRefillAt = t
}
Loading