diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7393466..39c0771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,24 +9,26 @@ jobs: build: name: Build, lint and test runs-on: ubuntu-latest + strategy: + matrix: + go: ["1.21", "1.22", "1.23"] steps: - name: Checkout Code uses: actions/checkout@v4 - - name: Setup Go Environment - uses: actions/setup-go@v4.1.0 + - name: Setup Go Environment (go${{ matrix.go }}) + uses: actions/setup-go@v5 with: - go-version: 1.21 + go-version: ${{ matrix.go }} cache: false # managed by golangci-lint - + - name: Download Dependencies run: go mod download -x - name: Lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 with: version: latest - name: Test run: go test -v -race ./... - diff --git a/Dockerfile b/Dockerfile index 127bc87..9fcba16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -ARG GO_VERSION=latest +ARG GO_VERSION=1.23 ARG GOLANGCI_LINT_VERSION=latest-alpine FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS deps diff --git a/Makefile b/Makefile index eb5bffb..6d43c3f 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,15 @@ all: help tidy lint test test/cover .PHONY: help help: ## Display this help screen - awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) .PHONY: tidy tidy: ## Tidy go mod tidy -v go mod verify go fmt ./... - go vet ./.. + go vet ./... + staticcheck ./... .PHONY: lint lint: ## Lint @@ -18,9 +19,9 @@ lint: ## Lint .PHONY: test test: ## Test - go test -v -race -buildvcs ./... + go test -v -race -buildvcs -count=1 ./...m .PHONY: test/cover test/cover: ## Test and cover - go test -v -race -buildvcs -coverprofile=/tmp/coverage.out ./... + go test -v -race -buildvcs -count=1 -coverprofile=/tmp/coverage.out ./... go tool cover -html=/tmp/coverage.out diff --git a/allow.lua b/allow.lua new file mode 100644 index 0000000..8c3364e --- /dev/null +++ b/allow.lua @@ -0,0 +1,29 @@ +-- key is a key to associate with this rate limiter +-- limit_events is the maximum number of events to be allowed in a limiting interval. It must be > 0 +-- limit_interval is the duration of the limiting interval in milliseconds +-- now is the current Unix time in milliseconds +-- key_ttl is an optional timeout in milliseconds for the key. It must be >= than limit_interval +-- returns limited, remaining, delay +local key = KEYS[1] +local limit_events = tonumber(ARGV[1]) +local limit_interval = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local key_ttl = tonumber(ARGV[4]) + +local trim_time = now - limit_interval +redis.call('ZREMRANGEBYSCORE', key, "-inf", trim_time) + +local limited = 1 +local count = redis.call('ZCARD', key) +if count < limit_events then + redis.call('ZADD', key, now, now) + redis.call('PEXPIRE', key, key_ttl) + + limited = 0 +end + +local min = tonumber(redis.call("ZRANGE", key, -limit_events, -limit_events)[1]) +if not min then + return { limited, limit_events - count - 1, 0 } +end +return { limited, 0, min - trim_time } diff --git a/go.mod b/go.mod index 2e83b82..252fece 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,61 @@ -module github.com/denpeshkov/go-template +module github.com/denpeshkov/throttle -go 1.22 +go 1.21 + +require ( + github.com/redis/go-redis/v9 v9.6.1 + github.com/testcontainers/testcontainers-go/modules/redis v0.33.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.2.0+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/testcontainers/testcontainers-go v0.33.0 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/sdk v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/time v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..944e50b --- /dev/null +++ b/go.sum @@ -0,0 +1,187 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7 h1:5RK988zAqB3/AN3opGfRpoQgAVqr6/A5+qRTi67VUZY= +github.com/lufia/plan9stats v0.0.0-20240819163618-b1d8f4d146e7/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/redis v0.33.0 h1:S/QvMOwpr00MM2aWH+krzP73Erlp/Ug0dr2rkgZYI5s= +github.com/testcontainers/testcontainers-go/modules/redis v0.33.0/go.mod h1:gudb3+6uZ9SsAysOVoLs7nazbjGlkHegBW8nqPXvDMI= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= +github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0 h1:JAv0Jwtl01UFiyWZEMiJZBiTlv5A50zNs8lsthXqIio= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.29.0/go.mod h1:QNKLmUEAq2QUbPQUfvw4fmv0bgbK7UlOSFCnXyfvSNc= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= +go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo= +google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/rate.go b/rate.go new file mode 100644 index 0000000..b74571d --- /dev/null +++ b/rate.go @@ -0,0 +1,142 @@ +package throttle + +import ( + "context" + _ "embed" + "errors" + "fmt" + "math" + "strings" + "time" +) + +// Inf is the infinite duration. +const Inf = time.Duration(math.MaxInt64) + +var errInvalidInterval = errors.New("limit interval is not positive") + +// Rediser defines an interface to abstract a Redis client. +type Rediser interface { + // ScriptLoad preloads a Lua script into Redis and returns its SHA-1 hash. + ScriptLoad(ctx context.Context, script string) (string, error) + // EvalSHA executes a preloaded Lua script using its SHA-1 hash. + EvalSHA(ctx context.Context, sha1 string, keys []string, args ...any) (any, error) + // Del removes the specified keys. A key is ignored if it does not exist. + Del(ctx context.Context, keys ...string) (int64, error) +} + +// Limit defines the maximum number of events allowed within a specified time interval. +// Setting Events to zero disallows all events. Interval must be a positive duration. +type Limit struct { + Events int + Interval time.Duration +} + +func (l Limit) String() string { + return fmt.Sprintf("%d req in %s", l.Events, l.Interval.String()) +} + +// Status represents the current status of the limit. +type Status struct { + // Limited indicates whether the current event was limited. + Limited bool + // Remaining specifies the number of events left in the current limit window. + Remaining int + // Delay is the duration until the next event is permitted. + // A zero duration means the event can occur immediately. + // An [Inf] duration indicates that no events are allowed. + Delay time.Duration +} + +func (s Status) String() string { + ra := s.Delay.String() + if s.Delay == Inf { + ra = "Inf" + } + l := "unlimited" + if s.Limited { + l = "limited" + } + return fmt.Sprintf("(%s, %d req, %s)", l, s.Remaining, ra) +} + +//go:embed allow.lua +var luaScript string + +// A Limiter controls how frequently events are allowed to happen. +// It implements a "sliding window" algorithm backed by [Redis]. +// +// [Redis]: https://redis.io +type Limiter struct { + rds Rediser + scriptSHA1 string + key string + lim Limit +} + +// NewLimiter returns a new Limiter for the given key that allows events up to the specified limit. +// Creating multiple Limiter instances for the same key with different limits may violate limits. +func NewLimiter(rds Rediser, key string, limit Limit) *Limiter { + return &Limiter{rds: rds, scriptSHA1: "", key: key, lim: limit} +} + +// Allow returns the status of the current request. +func (l *Limiter) Allow(ctx context.Context) (*Status, error) { + return l.allowAt(ctx, time.Now(), 2*l.lim.Interval) +} + +func (l *Limiter) allowAt(ctx context.Context, now time.Time, keyTTL time.Duration) (*Status, error) { + if l.lim.Interval <= 0 { + return nil, errInvalidInterval + } + if l.lim.Events == 0 { + return &Status{Limited: true, Remaining: 0, Delay: Inf}, nil + } + + keys := []string{l.key} + args := []any{l.lim.Events, l.lim.Interval.Milliseconds(), now.UnixMilli(), keyTTL.Milliseconds()} + + v, err := l.execScript(context.Background(), keys, args) + if err != nil { + return nil, err + } + values := v.([]interface{}) + return &Status{ + Limited: values[0].(int64) != 0, + Remaining: int(values[1].(int64)), + Delay: time.Duration(values[2].(int64)) * time.Millisecond, + }, nil +} + +func (l *Limiter) execScript(ctx context.Context, keys []string, args ...any) (any, error) { + v, err := l.rds.EvalSHA(ctx, l.scriptSHA1, keys, args...) + if err != nil && strings.HasPrefix(err.Error(), "NOSCRIPT") { + var sha1 string + if sha1, err = l.rds.ScriptLoad(ctx, luaScript); err == nil { + l.scriptSHA1 = sha1 + v, err = l.rds.EvalSHA(ctx, l.scriptSHA1, keys, args...) + } + } + if err != nil { + return nil, err + } + return v, nil +} + +// Limit returns the current limit. +func (l *Limiter) Limit() Limit { + return l.lim +} + +// SetLimit sets a new Limit for the limiter. +// If the new limit's Interval exceeds the current one, the new limit may be +// temporarily violated by up to the difference between the new and current limit's Interval durations. +func (l *Limiter) SetLimit(newLimit Limit) { + l.lim = newLimit +} + +// Reset clears all limitations and previous usages of the limiter. +func (l *Limiter) Reset(ctx context.Context) error { + _, err := l.rds.Del(ctx, l.key) + return err +} diff --git a/rate_test.go b/rate_test.go new file mode 100644 index 0000000..68f5a2e --- /dev/null +++ b/rate_test.go @@ -0,0 +1,301 @@ +package throttle + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "testing" + "time" + + rds "github.com/redis/go-redis/v9" + "github.com/testcontainers/testcontainers-go/modules/redis" +) + +const infTTL = 1 * time.Hour // inf TTL for our test purposes. + +var ( + redisAddr string + now = time.Now() +) + +type rediser struct { + rds *rds.Client +} + +func (r rediser) ScriptLoad(ctx context.Context, script string) (string, error) { + return r.rds.ScriptLoad(ctx, script).Result() +} +func (r rediser) EvalSHA(ctx context.Context, sha1 string, keys []string, args ...any) (any, error) { + return r.rds.EvalSha(ctx, sha1, keys, args...).Result() +} +func (r rediser) Del(ctx context.Context, keys ...string) (int64, error) { + return r.rds.Del(ctx, keys...).Result() +} + +type allow struct { + now time.Time + status Status +} + +func setupRedis() (teardown func(ctx context.Context) error, err error) { + ctx := context.Background() + reds, err := redis.Run(ctx, "redis:latest") + if err != nil { + return nil, err + } + addr, err := reds.Endpoint(ctx, "") + if err != nil { + return nil, err + } + redisAddr = addr + log.Println(redisAddr) + return reds.Terminate, nil +} + +func initLimiter(t *testing.T, rds rediser, key string, limit Limit) *Limiter { + t.Helper() + l := NewLimiter(rds, key, limit) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + l.Reset(ctx) + return l +} + +func testAllow(t *testing.T, l *Limiter, allows ...allow) { + t.Helper() + + for i, allow := range allows { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + status, err := l.allowAt(ctx, allow.now, 2*l.lim.Interval) + cancel() + + if err != nil { + t.Fatalf("Step %d: Allow() unexpected error: %v", i, err) + } + if *status != allow.status { + t.Errorf("Step %d: Allow() = %s, want %s", i, status, allow.status) + } + } +} + +func TestMain(m *testing.M) { + os.Exit(testMain(m)) +} +func testMain(m *testing.M) (code int) { + teardown, err := setupRedis() + if err != nil { + log.Fatalf("Failed to setup Redis: %v", err) + return 1 + } + defer func() { + if err := teardown(context.Background()); err != nil { + log.Fatalf("Failed to setup Redis: %v", err) + code = 1 + } + }() + return m.Run() +} + +func TestAllow(t *testing.T) { + rds := rediser{rds.NewClient(&rds.Options{Addr: redisAddr})} + key := "ratelimit:test_allow" + + t.Run("disallow all", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{0, 1 * time.Second}) + allow := allow{now, Status{true, 0, Inf}} + + testAllow(t, l, allow) + + l.SetLimit(Limit{0, 3 * time.Second}) + testAllow(t, l, allow) + + l.SetLimit(Limit{0, 10 * time.Second}) + testAllow(t, l, allow) + }) + + t.Run("basic", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{3, 5 * time.Second}) + + testAllow(t, l, + allow{now, Status{false, 2, 0 * time.Second}}, + allow{now.Add(1 * time.Second), Status{false, 1, 0 * time.Second}}, + allow{now.Add(2 * time.Second), Status{false, 0, 3 * time.Second}}, + allow{now.Add(3 * time.Second), Status{true, 0, 2 * time.Second}}, + allow{now.Add(4 * time.Second), Status{true, 0, 1 * time.Second}}, + allow{now.Add(5 * time.Second), Status{false, 0, 1 * time.Second}}, + allow{now.Add(6 * time.Second), Status{false, 0, 1 * time.Second}}, + allow{now.Add(7 * time.Second), Status{false, 0, 3 * time.Second}}, + allow{now.Add(8 * time.Second), Status{true, 0, 2 * time.Second}}, + ) + }) + + t.Run("once per window", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{5, 3 * time.Second}) + + testAllow(t, l, + allow{now, Status{false, 4, 0 * time.Second}}, + allow{now.Add(10 * time.Second), Status{false, 4, 0 * time.Second}}, + allow{now.Add(20 * time.Second), Status{false, 4, 0 * time.Second}}, + ) + }) + + t.Run("big window", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{1, 100 * time.Second}) + + testAllow(t, l, + allow{now, Status{false, 0, 100 * time.Second}}, + allow{now.Add(5 * time.Second), Status{true, 0, 95 * time.Second}}, + allow{now.Add(50 * time.Second), Status{true, 0, 50 * time.Second}}, + allow{now.Add(99 * time.Second), Status{true, 0, 1 * time.Second}}, + allow{now.Add(100 * time.Second), Status{false, 0, 100 * time.Second}}, + allow{now.Add(199 * time.Second), Status{true, 0, 1 * time.Second}}, + allow{now.Add(201 * time.Second), Status{false, 0, 100 * time.Second}}, + ) + }) + + t.Run("sub-second", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{1, 2 * time.Millisecond}) + + testAllow(t, l, + allow{now, Status{false, 0, 2 * time.Millisecond}}, + allow{now.Add(1 * time.Millisecond), Status{true, 0, 1 * time.Millisecond}}, + allow{now.Add(2 * time.Millisecond), Status{false, 0, 2 * time.Millisecond}}, + ) + }) + + t.Run("sub-millisecond", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{1, 500 * time.Nanosecond}) + + testAllow(t, l, + allow{now, Status{false, 0, 0 * time.Second}}, + allow{now.Add(1 * time.Millisecond), Status{false, 0, 0 * time.Second}}, + ) + + l.SetLimit(Limit{3, 500 * time.Nanosecond}) + testAllow(t, l, + allow{now, Status{false, 2, 0 * time.Second}}, + allow{now.Add(1 * time.Millisecond), Status{false, 2, 0 * time.Second}}, + ) + + l.SetLimit(Limit{3, 500 * time.Nanosecond}) + testAllow(t, l, + allow{now.Add(2 * time.Second), Status{false, 2, 0 * time.Second}}, + allow{now.Add(3 * time.Second), Status{false, 2, 0 * time.Second}}, + ) + }) +} + +func TestSetLimit(t *testing.T) { + rds := rediser{rds.NewClient(&rds.Options{Addr: redisAddr})} + key := "ratelimit:test_set_limit" + + t.Run("incr events", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{}) + + lim := Limit{1, 10 * time.Second} + l.SetLimit(lim) + testAllow(t, l, + allow{now, Status{false, 0, 10 * time.Second}}, + allow{now.Add(1 * time.Second), Status{true, 0, 9 * time.Second}}, + ) + + lim.Events = 5 + l.SetLimit(lim) + testAllow(t, l, + allow{now.Add(2 * time.Second), Status{false, 3, 0 * time.Second}}, + allow{now.Add(3 * time.Second), Status{false, 2, 0 * time.Second}}, + ) + }) + + t.Run("incr interval", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{}) + + lim := Limit{1, 10 * time.Second} + + l.SetLimit(lim) + testAllow(t, l, allow{now, Status{false, 0, 10 * time.Second}}) + testAllow(t, l, allow{now.Add(1 * time.Second), Status{true, 0, 9 * time.Second}}) + + lim.Interval = 20 * time.Second + l.SetLimit(lim) + testAllow(t, l, allow{now.Add(2 * time.Second), Status{true, 0, 18 * time.Second}}) + testAllow(t, l, allow{now.Add(3 * time.Second), Status{true, 0, 17 * time.Second}}) + }) + + t.Run("decr events", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{}) + + lim := Limit{10, 10 * time.Second} + l.SetLimit(lim) + testAllow(t, l, allow{now, Status{false, 9, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(1 * time.Second), Status{false, 8, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(2 * time.Second), Status{false, 7, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(3 * time.Second), Status{false, 6, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(4 * time.Second), Status{false, 5, 0 * time.Second}}) + + lim.Events = 2 + l.SetLimit(lim) + testAllow(t, l, allow{now.Add(5 * time.Second), Status{true, 0, 8 * time.Second}}) + testAllow(t, l, allow{now.Add(6 * time.Second), Status{true, 0, 7 * time.Second}}) + testAllow(t, l, allow{now.Add(7 * time.Second), Status{true, 0, 6 * time.Second}}) + }) + + t.Run("decr interval", func(t *testing.T) { + t.Parallel() + + l := initLimiter(t, rds, fmt.Sprintf("%s:%s", key, t.Name()), Limit{}) + + lim := Limit{4, 20 * time.Second} + l.SetLimit(lim) + testAllow(t, l, allow{now, Status{false, 3, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(1 * time.Second), Status{false, 2, 0 * time.Second}}) + + lim.Interval = 10 * time.Second + l.SetLimit(lim) + testAllow(t, l, allow{now.Add(2 * time.Second), Status{false, 1, 0 * time.Second}}) + testAllow(t, l, allow{now.Add(3 * time.Second), Status{false, 0, 7 * time.Second}}) + testAllow(t, l, allow{now.Add(4 * time.Second), Status{true, 0, 6 * time.Second}}) + testAllow(t, l, allow{now.Add(5 * time.Second), Status{true, 0, 5 * time.Second}}) + }) +} + +func TestInvalidInterval(t *testing.T) { + rds := rediser{rds.NewClient(&rds.Options{Addr: redisAddr})} + l := initLimiter(t, rds, "ratelimit:test_invalid_interval", Limit{1, 0}) + + testAllowErr := func() { + t.Helper() + if _, err := l.allowAt(context.Background(), now, infTTL); !errors.Is(err, errInvalidInterval) { + t.Errorf("Allow() returned error: %v, want %v", err, errInvalidInterval) + } + } + + testAllowErr() + + l.SetLimit(Limit{Interval: 0}) + testAllowErr() + + l.SetLimit(Limit{Interval: -1}) + testAllowErr() +}