diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2e5720d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# These are supported funding model platforms +github: [christiangda] +liberapay: christiangda +patreon: christiangda +custom: ["https://paypal.me/slashdevops"] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5e562fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..39d2e33 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +--- +# https://docs.github.com/es/repositories/releasing-projects-on-github/automatically-generated-release-notes +changelog: + categories: + - title: Breaking Changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: New Features 🎉 + labels: + - Semver-Minor + - enhancement + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..73cce2b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "10 12 * * 3" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..720ed32 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,73 @@ +version: "2" +linters: + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + # Disable specific linter + # https://golangci-lint.run/usage/linters/#disabled-by-default + disable: + # Enable presets. + # https://golangci-lint.run/usage/linters + # Default: [] + - govet + - godot + - wsl + - testpackage + - whitespace + - tagalign + - nosprintfhostport + - nlreturn + - nestif + - mnd + - misspell + - lll + - godox + - funlen + - gochecknoinits + - depguard + - goconst + - dupword + - cyclop + - gocognit + - maintidx + - gocyclo + - dupl + + settings: + errcheck: + # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. + # Such cases aren't reported by default. + # Default: false + check-type-assertions: false + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. + # Such cases aren't reported by default. + # Default: false + check-blank: false + # To disable the errcheck built-in exclude list. + # See `-excludeonly` option in https://github.com/kisielk/errcheck#excluding-functions for details. + # Default: false + disable-default-exclusions: true + # List of functions to exclude from checking, where each entry is a single function to exclude. + # See https://github.com/kisielk/errcheck#excluding-functions for details. + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - io/ioutil.ReadFile + - io.Copy(*bytes.Buffer) + - io.Copy(os.Stdout) + - (io.Writer).Write + - (*bufio.Writer).Write + - (*encoding/json.Encoder).Encode + - os.Setenv + - os.Unsetenv + - fmt.Printf + - fmt.Print + - fmt.Println + - fmt.Fprint + - fmt.Fprintf + - (*strings.Builder).WriteString diff --git a/README.md b/README.md index 2c6adb8..3dd2c8f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # ratelimiter [![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/ratelimiter.svg)](https://pkg.go.dev/github.com/slashdevops/ratelimiter) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/ratelimiter?style=plastic) +[![license](https://img.shields.io/github/license/slashdevops/ratelimiter.svg)](https://github.com/slashdevops/ratelimiter/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/ratelimiter/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/ratelimiter/actions/workflows/release.yml) +[![CodeQL Advanced](https://github.com/slashdevops/ratelimiter/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/ratelimiter/actions/workflows/codeql.yml) A flexible, goroutine-safe, **per-key** rate limiter for Go, built as a thin manager around the token-bucket implementation in diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a108806 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security Policy + +This project uses [GitHub CodeQL](https://codeql.github.com/) to scan the code for +vulnerabilities. You can see the report in the pipeline +[![CodeQL Advanced](https://github.com/slashdevops/ratelimiter/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/ratelimiter/actions/workflows/codeql.yml) + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +Please use the [Project Issues --> Report a vulnerability](https://github.com/slashdevops/ratelimiter/issues/new/choose) +to report it. diff --git a/bucket_limiter_test.go b/bucket_limiter_test.go index a60239f..04c63b2 100644 --- a/bucket_limiter_test.go +++ b/bucket_limiter_test.go @@ -8,8 +8,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "golang.org/x/time/rate" ) @@ -26,9 +24,15 @@ func TestNewBucketLimiter(t *testing.T) { bl := NewBucketLimiter(NewRateLimiterFunc(rate.Limit(10), 5), deleteAfter, storage) t.Cleanup(func() { _ = bl.Close() }) - require.NotNil(t, bl) - assert.Equal(t, deleteAfter, bl.deleteAfter) - assert.Same(t, storage, bl.storage) + if bl == nil { + t.Fatal("NewBucketLimiter returned nil") + } + if bl.deleteAfter != deleteAfter { + t.Errorf("deleteAfter = %v, want %v", bl.deleteAfter, deleteAfter) + } + if bl.storage != storage { + t.Error("storage field should be the exact instance passed in") + } } // TestBucketLimiter_PerKeyIsolation is the key regression test: distinct keys @@ -41,9 +45,13 @@ func TestBucketLimiter_PerKeyIsolation(t *testing.T) { a := bl.GetOrAdd("user-A") for i := range 3 { - require.True(t, a.Allow(), "user-A request %d should be allowed", i+1) + if !a.Allow() { + t.Fatalf("user-A request %d should be allowed", i+1) + } + } + if a.Allow() { + t.Error("user-A should be exhausted after its burst") } - assert.False(t, a.Allow(), "user-A should be exhausted after its burst") // A completely separate key must still have a full, independent bucket. b := bl.GetOrAdd("user-B") @@ -53,7 +61,9 @@ func TestBucketLimiter_PerKeyIsolation(t *testing.T) { allowed++ } } - assert.Equal(t, 3, allowed, "user-B must have its own bucket independent of user-A") + if allowed != 3 { + t.Errorf("user-B must have its own bucket independent of user-A: allowed %d, want 3", allowed) + } } // TestBucketLimiter_GetOrAdd_New verifies a new key is created and stored. @@ -63,11 +73,17 @@ func TestBucketLimiter_GetOrAdd_New(t *testing.T) { t.Cleanup(func() { _ = bl.Close() }) limiter := bl.GetOrAdd("k") - require.NotNil(t, limiter) + if limiter == nil { + t.Fatal("GetOrAdd returned nil") + } stored, ok := storage.Load("k") - assert.True(t, ok) - assert.Same(t, limiter, stored) + if !ok { + t.Fatal("new key should be stored") + } + if stored != limiter { + t.Error("stored limiter should be the same instance returned by GetOrAdd") + } } // TestBucketLimiter_GetOrAdd_Existing verifies the same instance is returned. @@ -78,7 +94,9 @@ func TestBucketLimiter_GetOrAdd_Existing(t *testing.T) { first := bl.GetOrAdd("k") second := bl.GetOrAdd("k") - assert.Same(t, first, second, "existing key must return the same instance") + if first != second { + t.Error("existing key must return the same instance") + } } // TestBucketLimiter_EvictsIdle verifies idle keys are evicted, using a fake @@ -101,16 +119,19 @@ func TestBucketLimiter_EvictsIdle(t *testing.T) { t.Cleanup(func() { _ = bl.Close() }) bl.GetOrAdd("k") - _, ok := storage.Load("k") - require.True(t, ok, "key should be present right after creation") + if _, ok := storage.Load("k"); !ok { + t.Fatal("key should be present right after creation") + } // Advance the clock well past the idle window; the sweeper must evict it. nowNanos.Add(int64(2 * deleteAfter)) - assert.Eventually(t, func() bool { + if !eventually(t, time.Second, 5*time.Millisecond, func() bool { _, ok := storage.Load("k") return !ok - }, time.Second, 5*time.Millisecond, "idle key should be evicted") + }) { + t.Error("idle key should be evicted") + } } // TestBucketLimiter_AccessKeepsAlive verifies that continued use prevents @@ -139,8 +160,9 @@ func TestBucketLimiter_AccessKeepsAlive(t *testing.T) { nowNanos.Add(int64(deleteAfter / 2)) bl.GetOrAdd("k") time.Sleep(10 * time.Millisecond) // let the sweeper run at least once - _, ok := storage.Load("k") - require.True(t, ok, "actively-used key must not be evicted (iteration %d)", i) + if _, ok := storage.Load("k"); !ok { + t.Fatalf("actively-used key must not be evicted (iteration %d)", i) + } } } @@ -151,12 +173,14 @@ func TestBucketLimiter_Remove(t *testing.T) { t.Cleanup(func() { _ = bl.Close() }) bl.GetOrAdd("k") - _, ok := storage.Load("k") - require.True(t, ok) + if _, ok := storage.Load("k"); !ok { + t.Fatal("key should be present after GetOrAdd") + } bl.Remove("k") - _, ok = storage.Load("k") - assert.False(t, ok, "key should be gone after Remove") + if _, ok := storage.Load("k"); ok { + t.Error("key should be gone after Remove") + } } // TestBucketLimiter_Close verifies Close stops the sweeper and is idempotent. @@ -164,11 +188,17 @@ func TestBucketLimiter_Close(t *testing.T) { storage := newStorage() bl := NewBucketLimiter(NewRateLimiterFunc(rate.Limit(10), 5), time.Second, storage) - require.NoError(t, bl.Close()) - require.NoError(t, bl.Close(), "Close must be idempotent") + if err := bl.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if err := bl.Close(); err != nil { + t.Fatalf("Close must be idempotent, second call returned: %v", err) + } // The manager still serves requests after Close. - assert.NotNil(t, bl.GetOrAdd("k")) + if bl.GetOrAdd("k") == nil { + t.Error("manager should still serve requests after Close") + } } // TestBucketLimiter_NoEvictionWhenDisabled verifies deleteAfter <= 0 keeps keys. @@ -179,8 +209,9 @@ func TestBucketLimiter_NoEvictionWhenDisabled(t *testing.T) { bl.GetOrAdd("k") time.Sleep(50 * time.Millisecond) - _, ok := storage.Load("k") - assert.True(t, ok, "eviction disabled: key must remain") + if _, ok := storage.Load("k"); !ok { + t.Error("eviction disabled: key must remain") + } } // TestBucketLimiter_GetOrAdd_Concurrent verifies concurrent creation of the @@ -203,9 +234,13 @@ func TestBucketLimiter_GetOrAdd_Concurrent(t *testing.T) { wg.Wait() stored, ok := storage.Load("concurrent-key") - require.True(t, ok) + if !ok { + t.Fatal("concurrent key should be stored") + } for i, got := range results { - assert.Same(t, stored, got, "goroutine %d got a different instance", i) + if got != stored { + t.Errorf("goroutine %d got a different instance", i) + } } } @@ -225,7 +260,9 @@ func TestBucketLimiter_DistinctKeysConcurrent(t *testing.T) { // Each key's first burst-worth of Allow() calls should all pass on // its own fresh bucket. lim := bl.GetOrAdd(key) - assert.True(t, lim.Allow(), "first request for %s should pass", key) + if !lim.Allow() { + t.Errorf("first request for %s should pass", key) + } }() } wg.Wait() @@ -244,14 +281,34 @@ func TestBucketLimiter_Wait(t *testing.T) { const numRequests = 4 start := time.Now() for range numRequests { - require.NoError(t, limiter.Wait(ctx)) + if err := limiter.Wait(ctx); err != nil { + t.Fatalf("Wait returned error: %v", err) + } } elapsed := time.Since(start) // Req 0 is immediate (burst), the next 3 wait ~0.5s each -> ~1.5s total. // Use a lower bound only to avoid flakiness under race/CI scheduling. - assert.GreaterOrEqual(t, elapsed, 1200*time.Millisecond, - "Wait must shape traffic to roughly the configured rate") + if elapsed < 1200*time.Millisecond { + t.Errorf("Wait must shape traffic to roughly the configured rate: elapsed %v, want >= 1.2s", elapsed) + } +} + +// eventually polls condition until it returns true or the timeout elapses. It +// mirrors the small slice of testify's assert.Eventually the tests rely on, +// without the dependency. +func eventually(t *testing.T, timeout, interval time.Duration, condition func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for { + if condition() { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(interval) + } } // ExampleBucketLimiter demonstrates basic per-key usage. diff --git a/go.mod b/go.mod index c4a29b4..da801d4 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,4 @@ module github.com/slashdevops/ratelimiter go 1.25.0 -require ( - github.com/stretchr/testify v1.11.1 - golang.org/x/time v0.15.0 -) - -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 -) +require golang.org/x/time v0.15.0 diff --git a/go.sum b/go.sum index 22a4c55..8ecb2d8 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,2 @@ -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= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -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/memory_store_test.go b/memory_store_test.go index f9813b0..effc4d8 100644 --- a/memory_store_test.go +++ b/memory_store_test.go @@ -1,39 +1,51 @@ package ratelimiter import ( + "maps" "sync" "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestInMemoryStorage_StoreLoadDelete(t *testing.T) { s := NewInMemoryStorage[string, int]() - _, ok := s.Load("missing") - assert.False(t, ok, "load of absent key returns zero, false") + if _, ok := s.Load("missing"); ok { + t.Error("load of absent key should return zero, false") + } s.Store("a", 1) v, ok := s.Load("a") - require.True(t, ok) - assert.Equal(t, 1, v) + if !ok { + t.Fatal("stored key should be found") + } + if v != 1 { + t.Errorf("Load(\"a\") = %d, want 1", v) + } s.Delete("a") - _, ok = s.Load("a") - assert.False(t, ok) + if _, ok := s.Load("a"); ok { + t.Error("key should be gone after Delete") + } } func TestInMemoryStorage_LoadOrStore(t *testing.T) { s := NewInMemoryStorage[string, int]() actual, loaded := s.LoadOrStore("k", 10) - assert.False(t, loaded) - assert.Equal(t, 10, actual) + if loaded { + t.Error("first LoadOrStore should report loaded=false") + } + if actual != 10 { + t.Errorf("first LoadOrStore = %d, want 10", actual) + } actual, loaded = s.LoadOrStore("k", 20) - assert.True(t, loaded, "second call should load the existing value") - assert.Equal(t, 10, actual, "existing value wins") + if !loaded { + t.Error("second call should load the existing value") + } + if actual != 10 { + t.Errorf("existing value should win: got %d, want 10", actual) + } } // TestInMemoryStorage_LoadOrStore_Atomic verifies concurrent racers converge on @@ -56,7 +68,9 @@ func TestInMemoryStorage_LoadOrStore_Atomic(t *testing.T) { winner := results[0] for i, got := range results { - assert.Equal(t, winner, got, "goroutine %d disagreed on the stored value", i) + if got != winner { + t.Errorf("goroutine %d disagreed on the stored value: got %d, want %d", i, got, winner) + } } } @@ -71,7 +85,9 @@ func TestInMemoryStorage_Range(t *testing.T) { seen[k] = v return true }) - assert.Equal(t, map[string]int{"a": 1, "b": 2, "c": 3}, seen) + if want := map[string]int{"a": 1, "b": 2, "c": 3}; !maps.Equal(seen, want) { + t.Errorf("Range visited %v, want %v", seen, want) + } // Early stop after the first element. count := 0 @@ -79,5 +95,7 @@ func TestInMemoryStorage_Range(t *testing.T) { count++ return false }) - assert.Equal(t, 1, count, "returning false stops iteration") + if count != 1 { + t.Errorf("returning false should stop iteration after 1 element, got %d", count) + } }