diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 357928b84..fc8bf19f9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,23 +26,23 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version-file: go.mod # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@012739e5082ff0c22ca6d6ab32e07c36df03c4a4 # v3.22.12 + uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pullrequest.yaml b/.github/workflows/pullrequest.yaml index 1289fad6f..df7b27e5d 100644 --- a/.github/workflows/pullrequest.yaml +++ b/.github/workflows/pullrequest.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: check format run: make check_format @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: build and test run: make docker_tests @@ -29,15 +29,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.9" - - uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: "1.21.5" + go-version: "1.26.4" - name: run pre-commits run: | diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dc6ff10a8..8193b01d9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,20 +12,20 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: check format run: make check_format build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up QEMU - uses: docker/setup-qemu-action@27d0a4f181a40b142cce983c5393082c365d1480 # v1.2.0 + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 - name: Set up Docker buildx id: buildx - uses: docker/setup-buildx-action@f211e3e9ded2d9377c8cadc4489a4e38014bc4c9 # v1.7.0 + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3.6.1 - name: build and push docker image run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 20321ba2e..f053a9c89 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -29,12 +29,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif @@ -56,7 +56,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: SARIF file path: results.sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2.2.4 + uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 37c261b44..558d1760e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ cover.out bin/ +env/ .idea/ .vscode/ vendor diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f23dc0426..2a32bea79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,9 +5,9 @@ repos: rev: v1.0.0-beta.4 hooks: - id: go-imports - args: ["-w", "-local", "github.com/envoyproxy/ratelimit"] + args: ["-w", "-local", "github.com/goatapp/ratelimit"] - id: go-fumpt - args: ["-w"] + args: ["-l", "-w", "."] - repo: https://github.com/pre-commit/mirrors-prettier rev: "v2.4.1" diff --git a/Dockerfile b/Dockerfile index 5528de296..309f437db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21.5@sha256:672a2286da3ee7a854c3e0a56e0838918d0dbb1c18652992930293312de898a6 AS build +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS build WORKDIR /ratelimit ENV GOPROXY=https://proxy.golang.org @@ -10,6 +10,6 @@ COPY script script RUN CGO_ENABLED=0 GOOS=linux go build -o /go/bin/ratelimit -ldflags="-w -s" -v github.com/goatapp/ratelimit/src/service_cmd -FROM alpine:3.18.5@sha256:34871e7290500828b39e22294660bee86d966bc0017544e848dd9a255cdf59e0 AS final -RUN apk --no-cache add ca-certificates && apk --no-cache update +FROM gcr.io/distroless/static-debian12:nonroot@sha256:e8a4044e0b4ae4257efa45fc026c0bc30ad320d43bd4c1a7d5271bd241e386d0 COPY --from=build /go/bin/ratelimit /bin/ratelimit +ENTRYPOINT ["/bin/ratelimit"] diff --git a/Dockerfile.integration b/Dockerfile.integration index 25cae665c..af75cd9ce 100644 --- a/Dockerfile.integration +++ b/Dockerfile.integration @@ -1,5 +1,5 @@ # Running this docker image runs the integration tests. -FROM golang@sha256:672a2286da3ee7a854c3e0a56e0838918d0dbb1c18652992930293312de898a6 +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 RUN apt-get update -y && apt-get install sudo stunnel4 redis memcached -y && rm -rf /var/lib/apt/lists/* diff --git a/Makefile b/Makefile index d6e86f5d3..c9e1f7310 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,14 @@ GIT_REF = $(shell git describe --tags --exact-match 2>/dev/null || git rev-parse VERSION ?= $(GIT_REF) SHELL := /bin/bash BUILDX_PLATFORMS := linux/amd64,linux/arm64/v8 -export GOSTATS_LOGGING_SINK_DISABLED=true # Root dir returns absolute path of current directory. It has a trailing "/". PROJECT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) export PROJECT_DIR +ifneq ($(shell docker compose version 2>/dev/null),) + DOCKER_COMPOSE=docker compose +else + DOCKER_COMPOSE=docker-compose +endif .PHONY: bootstrap bootstrap: ; @@ -143,7 +147,7 @@ docker_multiarch_push: docker_multiarch_image .PHONY: integration_tests integration_tests: - docker-compose --project-directory $(PWD) -f integration-test/docker-compose-integration-test.yml up --build --exit-code-from tester + $(DOCKER_COMPOSE) --project-directory $(PWD) -f integration-test/docker-compose-integration-test.yml up --build --exit-code-from tester .PHONY: precommit_install precommit_install: diff --git a/README.md b/README.md index b55b06bd7..f2e8d6df3 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ - [Overview](#overview) - [Docker Image](#docker-image) + - [Distroless Base Image](#distroless-base-image) + - [Benefits of Distroless:](#benefits-of-distroless) + - [Debugging with Distroless:](#debugging-with-distroless) - [Supported Envoy APIs](#supported-envoy-apis) - [API Deprecation History](#api-deprecation-history) - [Building and Testing](#building-and-testing) @@ -18,6 +21,8 @@ - [Replaces](#replaces) - [ShadowMode](#shadowmode) - [Including detailed metrics for unspecified values](#including-detailed-metrics-for-unspecified-values) + - [Including descriptor values in metrics](#including-descriptor-values-in-metrics) + - [Sharing thresholds for wildcard matches](#sharing-thresholds-for-wildcard-matches) - [Examples](#examples) - [Example 1](#example-1) - [Example 2](#example-2) @@ -28,6 +33,8 @@ - [Example 7](#example-7) - [Example 8](#example-8) - [Example 9](#example-9) + - [Example 10](#example-10) + - [Example 11](#example-11) - [Loading Configuration](#loading-configuration) - [File Based Configuration Loading](#file-based-configuration-loading) - [xDS Management Server Based Configuration Loading](#xds-management-server-based-configuration-loading) @@ -35,6 +42,7 @@ - [GRPC Keepalive](#grpc-keepalive) - [Health-check](#health-check) - [Health-check configurations](#health-check-configurations) + - [GRPC server](#grpc-server) - [Request Fields](#request-fields) - [GRPC Client](#grpc-client) - [Commandline flags](#commandline-flags) @@ -43,19 +51,28 @@ - [Statistics](#statistics) - [Statistics](#statistics-1) - [Statistics options](#statistics-options) + - [DogStatsD](#dogstatsd) + - [Example](#example) + - [Continued example:](#continued-example) + - [Prometheus](#prometheus) - [HTTP Port](#http-port) - [/json endpoint](#json-endpoint) - [Debug Port](#debug-port) - [Local Cache](#local-cache) - [Redis](#redis) - [Redis type](#redis-type) - - [Pipelining](#pipelining) + - [Connection Pool Settings](#connection-pool-settings) + - [Pool Size](#pool-size) + - [Connection Timeout](#connection-timeout) + - [Pool On-Empty Behavior](#pool-on-empty-behavior) + - [Pipelining](#pipelining) - [One Redis Instance](#one-redis-instance) - [Two Redis Instances](#two-redis-instances) - [Health Checking for Redis Active Connection](#health-checking-for-redis-active-connection) - [Memcache](#memcache) - [Custom headers](#custom-headers) - [Tracing](#tracing) +- [TLS](#tls) - [mTLS](#mtls) - [Contact](#contact) @@ -74,6 +91,32 @@ decision is then returned to the caller. For every main commit, an image is pushed to [Dockerhub](https://hub.docker.com/r/envoyproxy/ratelimit/tags?page=1&ordering=last_updated). There is currently no versioning (post v1.4.0) and tags are based on commit sha. +## Distroless Base Image + +The Docker image uses Google's [distroless](https://github.com/GoogleContainerTools/distroless) base image (`gcr.io/distroless/static-debian12:nonroot`) for enhanced security and minimal attack surface. Distroless images contain only the application and its runtime dependencies, omitting unnecessary OS components like package managers, shells, and other utilities. + +The image is pinned to a specific SHA digest for deterministic builds and uses the `nonroot` variant to run as a non-privileged user, following security best practices. + +### Benefits of Distroless: + +- **Enhanced Security**: Minimal attack surface with no unnecessary components +- **Smaller Image Size**: Significantly smaller than traditional base images +- **Reduced Vulnerabilities**: Fewer components means fewer potential security issues +- **Better Compliance**: Meets security requirements for minimal base images +- **Non-root Execution**: Runs as a non-privileged user (UID 65532) for enhanced security +- **Deterministic Builds**: Pinned to specific SHA digest ensures reproducible builds + +### Debugging with Distroless: + +For debugging purposes, you can use the debug variant of the distroless image: + +```dockerfile +FROM gcr.io/distroless/static-debian12:debug +COPY --from=build /go/bin/ratelimit /bin/ratelimit +``` + +This provides shell access and debugging tools while maintaining the security benefits of distroless. + # Supported Envoy APIs [v3 rls.proto](https://github.com/envoyproxy/data-plane-api/blob/master/envoy/service/ratelimit/v3/rls.proto) is currently supported. @@ -126,14 +169,13 @@ Support for [v2 rls proto](https://github.com/envoyproxy/data-plane-api/blob/mas ## Docker-compose setup -The docker-compose setup has three containers: redis, ratelimit-build, and ratelimit. In order to run the docker-compose setup from the root of the repo, run +The docker-compose setup uses a distroless-based container for the ratelimit service. In order to run the docker-compose setup from the root of the repo, run ```bash docker-compose up ``` -The ratelimit-build container will build the ratelimit binary. Then via a shared volume the binary will be shared with the ratelimit container. This dual container setup is used in order to use a -a minimal container to run the application, rather than the heftier container used to build it. +The ratelimit service is built using the main Dockerfile which uses Google's distroless base image for enhanced security and minimal attack surface. The distroless image contains only the application and its runtime dependencies, omitting unnecessary OS components like package managers and shells. If you want to run with [two redis instances](#two-redis-instances), you will need to modify the docker-compose.yml file to run a second redis container, and change the environment variables @@ -247,6 +289,8 @@ descriptors: requests_per_unit: shadow_mode: (optional) detailed_metric: (optional) + value_to_metric: (optional) + share_threshold: (optional) descriptors: (optional block) - ... (nested repetition of above) ``` @@ -301,6 +345,28 @@ Setting the `detailed_metric: true` for a descriptor will extend the metrics tha NB! This should only be enabled in situations where the potentially large cardinality of metrics that this can lead to is acceptable. +### Including descriptor values in metrics + +Setting `value_to_metric: true` (default: `false`) for a descriptor will include the descriptor's runtime value in the metric key, even when the descriptor value is not explicitly defined in the configuration. This allows you to track metrics per descriptor value when the value comes from the runtime request, providing visibility into different rate limit scenarios without needing to pre-define every possible value. + +**Note:** If a value is explicitly specified in a descriptor (e.g., `value: "GET"`), that value is always included in the metric key regardless of the `value_to_metric` setting. The `value_to_metric` flag only affects descriptors where the value is not explicitly defined in the configuration. + +When combined with wildcard matching, the full runtime value is included in the metric key, not just the wildcard prefix. This feature works independently of `detailed_metric` - when `detailed_metric` is set, it takes precedence and `value_to_metric` is ignored. + +### Sharing thresholds for wildcard matches + +Setting `share_threshold: true` (default: `false`) for a descriptor with a wildcard value (ending with `*`) allows all values matching that wildcard to share the same rate limit threshold, instead of using isolated thresholds for each matching value. + +This is useful when you want to apply a single rate limit across multiple resources that match a wildcard pattern. For example, if you have a rule for `files/*`, both `files/a.pdf` and `files/b.csv` will share the same threshold when `share_threshold: true` is set. + +**Important notes:** + +- `share_threshold` can only be used with wildcard values (values ending with `*`) +- When `share_threshold: true` is enabled, all matching values share the same cache key and rate limit counter +- When `share_threshold: false` (or not set), each matching value has its own isolated threshold +- When combined with `value_to_metric: true`, the metric key includes the wildcard prefix (the part before `*`) instead of the full runtime value, to reflect that values are sharing a threshold +- When combined with `detailed_metric: true`, the metric key also includes the wildcard prefix for entries with `share_threshold` enabled + ### Examples #### Example 1 @@ -577,12 +643,9 @@ rather than the normal #### Example 9 -Value supports wildcard matching to apply rate-limit for nested endpoints: +Value supports wildcard matching using `*`, which can appear at any position — trailing, middle, or multiple times. Each `*` matches zero or more characters. -``` -(key_1, value_1): 20 / sec -(key_1, value_2): 20 / sec -``` +Trailing wildcard — matches any value starting with the given prefix: ```yaml domain: example9 @@ -594,6 +657,140 @@ descriptors: requests_per_unit: 20 ``` +Matches `value1`, `value2`, `valueXYZ`, etc. + +Middle wildcard — matches values with a fixed prefix **and** suffix: + +```yaml +domain: example9 +descriptors: + - key: path + value: /api/*/action + rate_limit: + unit: minute + requests_per_unit: 20 +``` + +Matches `/api/123/action`, `/api/user-id/action`. Does not match `/api/123/other`. + +Multiple wildcards — each `*` matches an independent segment, in order: + +```yaml +domain: example9 +descriptors: + - key: route + value: /api/*/resource/*/action + rate_limit: + unit: minute + requests_per_unit: 20 +``` + +Matches `/api/v1/resource/123/action`, `/api/v2/resource/456/action`. + +#### Example 10 + +Using `value_to_metric: true` to include descriptor values in metrics when values are not explicitly defined in the configuration: + +```yaml +domain: example10 +descriptors: + - key: route + value_to_metric: true + descriptors: + - key: http_method + value_to_metric: true + descriptors: + - key: subject_id + rate_limit: + unit: minute + requests_per_unit: 60 +``` + +With this configuration, requests with different runtime values for `route` and `http_method` will generate separate metrics: + +- Request: `route=api`, `http_method=GET`, `subject_id=123` +- Metric key: `example10.route_api.http_method_GET.subject_id` + +- Request: `route=web`, `http_method=POST`, `subject_id=456` +- Metric key: `example10.route_web.http_method_POST.subject_id` + +Without `value_to_metric: true`, both requests would use the same metric key: `example10.route.http_method.subject_id`. + +When combined with wildcard matching, the full runtime value is included: + +```yaml +domain: example10_wildcard +descriptors: + - key: user + value_to_metric: true + descriptors: + - key: action + value: read* + value_to_metric: true + descriptors: + - key: resource + rate_limit: + unit: minute + requests_per_unit: 100 +``` + +- Request: `user=alice`, `action=readfile`, `resource=documents` +- Metric key: `example10_wildcard.user_alice.action_readfile.resource` + +Note: When `detailed_metric: true` is set on a descriptor, it takes precedence and `value_to_metric` is ignored for that descriptor. + +#### Example 11 + +Using `share_threshold: true` to share rate limits across wildcard matches: + +```yaml +domain: example11 +descriptors: + # With share_threshold: true, all files/* matches share the same threshold + - key: files + value: files/* + share_threshold: true + rate_limit: + unit: hour + requests_per_unit: 10 + + # Without share_threshold, each files_no_share/* match has its own isolated threshold + - key: files_no_share + value: files_no_share/* + share_threshold: false + rate_limit: + unit: hour + requests_per_unit: 10 +``` + +With this configuration: + +- Requests for `files/a.pdf`, `files/b.csv`, and `files/c.txt` all share the same threshold of 10 requests per hour +- If 5 requests are made for `files/a.pdf` and 5 requests for `files/b.csv`, a request for `files/c.txt` will be rate limited (OVER_LIMIT) because the shared threshold of 10 has been reached +- Requests for `files_no_share/a.pdf` and `files_no_share/b.csv` each have their own isolated threshold of 10 requests per hour +- If 10 requests are made for `files_no_share/a.pdf` (exhausting its quota), requests for `files_no_share/b.csv` will still be allowed (up to 10 requests) + +Combining `share_threshold` with `value_to_metric`: + +```yaml +domain: example11_metrics +descriptors: + - key: route + value: api/* + share_threshold: true + value_to_metric: true + descriptors: + - key: method + rate_limit: + unit: minute + requests_per_unit: 60 +``` + +- Request: `route=api/v1`, `method=GET` +- Metric key: `example11_metrics.route_api.method_GET` (includes the wildcard prefix `api` instead of the full value `api/v1`) + +This reflects that all `api/*` routes share the same threshold, while still providing visibility into which API routes are being accessed. + ## Loading Configuration Rate limit service supports following configuration loading methods. You can define which methods to use by configuring environment variable `CONFIG_TYPE`. @@ -647,7 +844,19 @@ To enable this behavior set `MERGE_DOMAIN_CONFIG` to `true`. xDS Management Server is a gRPC server which implements the [Aggregated Discovery Service (ADS)](https://github.com/envoyproxy/data-plane-api/blob/97b6dae39046f7da1331a4dc57830d20e842fc26/envoy/service/discovery/v3/ads.proto). The xDS Management server serves [Discovery Response](https://github.com/envoyproxy/data-plane-api/blob/97b6dae39046f7da1331a4dc57830d20e842fc26/envoy/service/discovery/v3/discovery.proto#L69) with [Ratelimit Configuration Resources](api/ratelimit/config/ratelimit/v3/rls_conf.proto) and with Type URL `"type.googleapis.com/ratelimit.config.ratelimit.v3.RateLimitConfig"`. + The xDS client in the Rate limit service configure Rate limit service with the provided configuration. +In case of connection failures, the xDS Client retries the connection to the xDS server with exponential backoff and the backoff parameters are configurable. + +1. `XDS_CLIENT_BACKOFF_JITTER`: set to `"true"` to add jitter to the exponential backoff. +2. `XDS_CLIENT_BACKOFF_INITIAL_INTERVAL`: The base amount of time the xDS client waits before retrying the connection after failure. Default: "10s" +3. `XDS_CLIENT_BACKOFF_MAX_INTERVAL`: The max backoff interval is the upper limit on the amount of time the xDS client will wait between retries. After reaching the max backoff interval, the next retries will continue using the max interval. Default: "60s" +4. `XDS_CLIENT_BACKOFF_RANDOM_FACTOR`: This is a factor by which the initial interval is multiplied to calculate the next backoff interval. Default: "0.5" + +The followings are the gRPC connection options. + +1. `XDS_CLIENT_MAX_MSG_SIZE_IN_BYTES`: The maximum message size in bytes that the xDS client can receive. + For more information on xDS protocol please refer to the [envoy proxy documentation](https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol). You can refer to [the sample xDS configuration management server](examples/xds-sotw-config-server/README.md). @@ -682,6 +891,8 @@ time="2020-09-10T17:22:35Z" level=debug msg="loading domain: messaging" time="2020-09-10T17:22:35Z" level=debug msg="loading descriptor: key=messaging.message_type_marketing" time="2020-09-10T17:22:35Z" level=debug msg="loading descriptor: key=messaging.message_type_marketing.to_number ratelimit={requests_per_unit=5, unit=DAY}" time="2020-09-10T17:22:35Z" level=debug msg="loading descriptor: key=messaging.to_number ratelimit={requests_per_unit=100, unit=DAY}" +time="2020-09-10T17:21:55Z" level=warning msg="Listening for debug on ':6070'" +time="2020-09-10T17:21:55Z" level=warning msg="Listening for HTTP on ':8080'" time="2020-09-10T17:21:55Z" level=debug msg="waiting for runtime update" time="2020-09-10T17:21:55Z" level=warning msg="Listening for gRPC on ':8081'" ``` @@ -699,6 +910,8 @@ Output example: {"@message":"loading descriptor: key=messaging.message_type_marketing","@timestamp":"2020-09-10T17:22:44.926019315Z","level":"debug"} {"@message":"loading descriptor: key=messaging.message_type_marketing.to_number ratelimit={requests_per_unit=5, unit=DAY}","@timestamp":"2020-09-10T17:22:44.926037174Z","level":"debug"} {"@message":"loading descriptor: key=messaging.to_number ratelimit={requests_per_unit=100, unit=DAY}","@timestamp":"2020-09-10T17:22:44.926048993Z","level":"debug"} +{"@message":"Listening for debug on ':6070'","@timestamp":"2020-09-10T17:22:44.926113905Z","level":"warning"} +{"@message":"Listening for gRPC on ':8081'","@timestamp":"2020-09-10T17:22:44.926182006Z","level":"warning"} {"@message":"Listening for HTTP on ':8080'","@timestamp":"2020-09-10T17:22:44.926227031Z","level":"warning"} {"@message":"waiting for runtime update","@timestamp":"2020-09-10T17:22:44.926267808Z","level":"debug"} ``` @@ -736,6 +949,13 @@ HEALTHY_WITH_AT_LEAST_ONE_CONFIG_LOADED default:"false"` If `HEALTHY_WITH_AT_LEAST_ONE_CONFIG_LOADED` is enabled then health check will start as unhealthy and becomes healthy if it detects at least one domain is loaded with the config. If it detects no config again then it will change to unhealthy. +## GRPC server + +By default the ratelimit gRPC server binds to `0.0.0.0:8081`. To change this set +`GRPC_HOST` and/or `GRPC_PORT`. If you want to run the server on a unix domain +socket then set `GRPC_UDS`, e.g. `GRPC_UDS=//ratelimit.sock` and leave +`GRPC_HOST` and `GRPC_PORT` unmodified. + # Request Fields For information on the fields of a Ratelimit gRPC request please read the information @@ -776,6 +996,14 @@ The rate limit service generates various statistics for each configured rate lim users both for visibility and for setting alarms. Ratelimit uses [gostats](https://github.com/lyft/gostats) as its statistics library. Please refer to [gostats' documentation](https://godoc.org/github.com/lyft/gostats) for more information on the library. +Statistics default to using [StatsD](https://github.com/statsd/statsd) and configured via the env vars from [gostats](https://github.com/lyft/gostats). + +To output statistics to stdout instead, set env var `USE_STATSD` to `false` + +Configure statistics output frequency with `STATS_FLUSH_INTERVAL`, where the type is `time.Duration`, e.g. `10s` is the default value. + +To disable statistics entirely, set env var `DISABLE_STATS` to `true` + Rate Limit Statistic Path: ``` @@ -818,6 +1046,162 @@ ratelimit.service.rate_limit.messaging.auth-service.over_limit.shadow_mode: 1 1. `EXTRA_TAGS`: set to `","` to tag all emitted stats with the provided tags. You might want to tag build commit or release version, for example. +## DogStatsD + +To enable dogstatsd integration set: + +1. `USE_DOG_STATSD`: `true` to use [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?code-lang=go) + +dogstatsd also enables so called `mogrifiers` which can +convert from traditional stats tags into a combination of stat name and tags. + +To enable mogrifiers, set a comma-separated list of them in `DOG_STATSD_MOGRIFIERS`. + +e.g. `USE_DOG_STATSD_MOGRIFIERS`: `FOO,BAR` + +For each mogrifier, define variables that declare the mogrification + +1. `DOG_STATSD_MOGRIFIERS_%s_PATTERN`: The regex pattern to match on +2. `DOG_STATSD_MOGRIFIERS_%s_NAME`: The name of the metric to emit. Can contain variables. +3. `DOG_STATSD_MOGRIFIERS_%s_TAGS`: Comma-separated list of tags to emit. Can contain variables. + +Variables within mogrifiers are strings such as `$1`, `$2`, `$3` which can be used to reference +a match group from the regex pattern. + +### Example + +In the example below we will set mogrifier DOMAIN to adjust +`some.original.metric.TAG` to `some.original.metric` with tag `domain:TAG` + +First enable a single mogrifier: + +1. `USE_DOG_STATSD_MOGRIFIERS`: `DOMAIN` + +Then, declare the rules for the `DOMAIN` modifier: + +1. `DOG_STATSD_MOGRIFIER_DOMAIN_PATTERN`: `^some\.original\.metric\.(.*)$` +2. `DOG_STATSD_MOGRIFIER_DOMAIN_NAME`: `some.original.metric` +3. `DOG_STATSD_MOGRIFIER_DOMAIN_TAGS`: `domain:$1` + +### Continued example: + +Let's also set another mogrifier which outputs the hits metrics with a domain and descriptor tag + +First, enable an extra mogrifier: + +1. `USE_DOG_STATSD_MOGRIFIERS`: `DOMAIN,HITS` + +Then, declare additional rules for the `DESCRIPTOR` mogrifier + +1. `DOG_STATSD_MOGRIFIER_HITS_PATTERN`: `^ratelimit\.service\.rate_limit\.([^.]+)\.(.*)\.([^.]+)$` +2. `DOG_STATSD_MOGRIFIER_HITS_NAME`: `ratelimit.service.rate_limit.$3` +3. `DOG_STATSD_MOGRIFIER_HITS_TAGS`: `domain:$1,descriptor:$2` + +## Prometheus + +To enable Prometheus integration set: + +1. `USE_PROMETHEUS`: `true` to use [Prometheus](https://prometheus.io/) +2. `PROMETHEUS_ADDR`: The port to listen on for Prometheus metrics. Defaults to `:9090` +3. `PROMETHEUS_PATH`: The path to listen on for Prometheus metrics. Defaults to `/metrics` +4. `PROMETHEUS_MAPPER_YAML`: The path to the YAML file that defines the mapping from statsd to prometheus metrics. +5. `PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS`: `true` to keep the legacy millisecond behavior for `ratelimit_server.*.response_time` in the built-in mapper. Ignored when `PROMETHEUS_MAPPER_YAML` is set. + +Define the mapping from statsd to prometheus metrics in a YAML file. +Find more information about the mapping in the [Metric Mapping and Configuration](https://github.com/prometheus/statsd_exporter?tab=readme-ov-file#metric-mapping-and-configuration). +The default setting is: + +```yaml +mappings: # Requires statsd exporter >= v0.6.0 since it uses the "drop" action. + - match: "ratelimit.service.rate_limit.*.*.near_limit" + name: "ratelimit_service_rate_limit_near_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.over_limit" + name: "ratelimit_service_rate_limit_over_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.total_hits" + name: "ratelimit_service_rate_limit_total_hits" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.within_limit" + name: "ratelimit_service_rate_limit_within_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + + - match: "ratelimit.service.rate_limit.*.*.*.near_limit" + name: "ratelimit_service_rate_limit_near_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit.service.rate_limit.*.*.*.over_limit" + name: "ratelimit_service_rate_limit_over_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit.service.rate_limit.*.*.*.total_hits" + name: "ratelimit_service_rate_limit_total_hits" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit.service.rate_limit.*.*.*.within_limit" + name: "ratelimit_service_rate_limit_within_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + + - match: "ratelimit.service.call.should_rate_limit.*" + name: "ratelimit_service_should_rate_limit_error" + match_metric_type: counter + labels: + err_type: "$1" + + - match: "ratelimit_server.*.total_requests" + name: "ratelimit_service_total_requests" + match_metric_type: counter + labels: + grpc_method: "$1" + + - match: "ratelimit_server.*.response_time" + name: "ratelimit_service_response_time_seconds" + timer_type: histogram + scale: 0.001 + labels: + grpc_method: "$1" + + - match: "ratelimit.service.config_load_success" + name: "ratelimit_service_config_load_success" + match_metric_type: counter + - match: "ratelimit.service.config_load_error" + name: "ratelimit_service_config_load_error" + match_metric_type: counter + + - match: "ratelimit.service.rate_limit.*.*.*.shadow_mode" + name: "ratelimit_service_rate_limit_shadow_mode" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" +``` + # HTTP Port The ratelimit service listens to HTTP 1.1 (by default on port 8080) with two endpoints: @@ -898,8 +1282,9 @@ As well Ratelimit supports TLS connections and authentication. These can be conf 1. `REDIS_TLS` & `REDIS_PERSECOND_TLS`: set to `"true"` to enable a TLS connection for the specific connection type. 1. `REDIS_TLS_CLIENT_CERT`, `REDIS_TLS_CLIENT_KEY`, and `REDIS_TLS_CACERT` to provides files to specify a TLS connection configuration to Redis server that requires client certificate verification. (This is effective when `REDIS_TLS` or `REDIS_PERSECOND_TLS` is set to to `"true"`). 1. `REDIS_TLS_SKIP_HOSTNAME_VERIFICATION` set to `"true"` will skip hostname verification in environments where the certificate has an invalid hostname, such as GCP Memorystore. -1. `REDIS_AUTH` & `REDIS_PERSECOND_AUTH`: set to `"password"` to enable password-only authentication to the redis host. -1. `REDIS_AUTH` & `REDIS_PERSECOND_AUTH`: set to `"username:password"` to enable username-password authentication to the redis host. +1. `REDIS_AUTH` & `REDIS_PERSECOND_AUTH`: set to `"password"` to enable password-only authentication to the Redis master/replica nodes. +1. `REDIS_AUTH` & `REDIS_PERSECOND_AUTH`: set to `"username:password"` to enable username-password authentication to the Redis master/replica nodes. +1. `REDIS_SENTINEL_AUTH` & `REDIS_PERSECOND_SENTINEL_AUTH`: set to `"password"` or `"username:password"` to enable authentication to Redis Sentinel nodes. This is separate from `REDIS_AUTH`/`REDIS_PERSECOND_AUTH` which authenticate to the Redis master/replica nodes. Only used when `REDIS_TYPE` or `REDIS_PERSECOND_TYPE` is set to `"sentinel"`. If not set, no authentication will be attempted when connecting to Sentinel nodes. 1. `CACHE_KEY_PREFIX`: a string to prepend to all cache keys For controlling the behavior of cache key incrementation when any of them is already over the limit, you can use the following configuration: @@ -922,18 +1307,43 @@ The deployment type can be specified with the `REDIS_TYPE` / `REDIS_PERSECOND_TY 1. "sentinel": A comma separated list with the first string as the master name of the sentinel cluster followed by hostname:port pairs. The list size should be >= 2. The first item is the name of the master and the rest are the sentinels. 1. "cluster": A comma separated list of hostname:port pairs with all the nodes in the cluster. -## Pipelining +## Connection Pool Settings + +### Pool Size + +1. `REDIS_POOL_SIZE`: the number of connections to keep in the pool. Default: `10` +1. `REDIS_PERSECOND_POOL_SIZE`: pool size for per-second Redis. Default: `10` + +### Connection Timeout + +Controls the maximum duration for Redis connection establishment, read operations, and write operations. + +1. `REDIS_TIMEOUT`: sets the timeout for Redis connection and I/O operations. Default: `10s` +1. `REDIS_PERSECOND_TIMEOUT`: sets the timeout for per-second Redis connection and I/O operations. Default: `10s` + +### Pool On-Empty Behavior + +Controls what happens when all connections in the pool are in use and a new request arrives. + +1. `REDIS_POOL_ON_EMPTY_BEHAVIOR`: controls what happens when the pool is empty. Default: `CREATE` + - `CREATE`: create a new overflow connection after waiting for `REDIS_POOL_ON_EMPTY_WAIT_DURATION`. This is the [default radix behavior](https://github.com/mediocregopher/radix/blob/v3.8.1/pool.go#L291-L312). + - `ERROR`: return an error after waiting for `REDIS_POOL_ON_EMPTY_WAIT_DURATION`. This enforces a strict pool size limit. + - `WAIT`: block until a connection becomes available. This enforces a strict pool size limit but may cause goroutine buildup. +1. `REDIS_POOL_ON_EMPTY_WAIT_DURATION`: the duration to wait before taking the configured action (`CREATE` or `ERROR`). Default: `1s` +1. `REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR`: same as above for per-second Redis pool. Default: `CREATE` +1. `REDIS_PERSECOND_POOL_ON_EMPTY_WAIT_DURATION`: same as above for per-second Redis pool. Default: `1s` + +### Pipelining By default, for each request, ratelimit will pick up a connection from pool, write multiple redis commands in a single write then reads their responses in a single read. This reduces network delay. -For high throughput scenarios, ratelimit also support [implicit pipelining](https://github.com/mediocregopher/radix/blob/v3.5.1/pool.go#L238) . It can be configured using the following environment variables: +For high throughput scenarios, ratelimit supports write buffering via [radix v4's WriteFlushInterval](https://pkg.go.dev/github.com/mediocregopher/radix/v4#Dialer). It can be configured using the following environment variables: -1. `REDIS_PIPELINE_WINDOW` & `REDIS_PERSECOND_PIPELINE_WINDOW`: sets the duration after which internal pipelines will be flushed. - If window is zero then implicit pipelining will be disabled. -1. `REDIS_PIPELINE_LIMIT` & `REDIS_PERSECOND_PIPELINE_LIMIT`: sets maximum number of commands that can be pipelined before flushing. - If limit is zero then no limit will be used and pipelines will only be limited by the specified time window. +1. `REDIS_PIPELINE_WINDOW` & `REDIS_PERSECOND_PIPELINE_WINDOW`: controls how often buffered writes are flushed to the network connection. When set to a non-zero value (e.g., 150us-500us), radix v4 will buffer multiple concurrent write operations and flush them together, reducing system calls and improving throughput. If zero, each write is flushed immediately. **Required for Redis Cluster mode.** +1. `REDIS_PIPELINE_LIMIT` & `REDIS_PERSECOND_PIPELINE_LIMIT`: **DEPRECATED** - These settings have no effect in radix v4. Write buffering is controlled solely by the window settings above. +1. `REDIS_CLUSTER_PIPELINE_PARALLELISM` & `REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM`: controls per-key pipeline group concurrency in Redis Cluster mode. Default: `1`, which preserves the legacy serial behavior. Set to `0` for auto parallelism bounded by the corresponding Redis pool size, or a value greater than `1` to bound concurrent Redis group calls per pipeline execution. Configured values greater than the corresponding Redis pool size are capped to the pool size. -`implicit pipelining` is disabled by default. To enable it, you can use default values [used by radix](https://github.com/mediocregopher/radix/blob/v3.5.1/pool.go#L278) and tune for the optimal value. +Write buffering is disabled by default (window = 0). For optimal performance, set `REDIS_PIPELINE_WINDOW` to 150us-500us depending on your latency requirements and load patterns. ## One Redis Instance @@ -980,6 +1390,9 @@ To configure a Memcache instance use the following environment variables instead 1. `BACKEND_TYPE=memcache` 1. `CACHE_KEY_PREFIX`: a string to prepend to all cache keys 1. `MEMCACHE_MAX_IDLE_CONNS=2`: the maximum number of idle TCP connections per memcache node, `2` is the default of the underlying library +1. `MEMCACHE_TLS`: set to `"true"` to connect to the server with TLS. +1. `MEMCACHE_TLS_CLIENT_CERT`, `MEMCACHE_TLS_CLIENT_KEY`, and `MEMCACHE_TLS_CACERT` to provide files that parameterize the memcache client TLS connection configuration. +1. `MEMCACHE_TLS_SKIP_HOSTNAME_VERIFICATION` set to `"true"` will skip hostname verification in environments where the certificate has an invalid hostname. With memcache mode increments will happen asynchronously, so it's technically possible for a client to exceed quota briefly if multiple requests happen at exactly the same time. @@ -1024,17 +1437,26 @@ otelcol-contrib --config examples/otlp-collector/config.yaml docker run -d --name jaeger -p 16686:16686 -p 14250:14250 jaegertracing/all-in-one:1.33 ``` +# TLS + +Ratelimit supports TLS for it's gRPC endpoint. + +The following environment variables control the TLS feature: + +1. `GRPC_SERVER_USE_TLS` - Enables gRPC connections to server over TLS +1. `GRPC_SERVER_TLS_CERT` - Path to the file containing the server cert chain +1. `GRPC_SERVER_TLS_KEY` - Path to the file containing the server private key + +Ratelimit uses [goruntime](https://github.com/lyft/goruntime) to watch the TLS certificate and key and will hot reload them on changes. + # mTLS Ratelimit supports mTLS when Envoy sends requests to the service. -The following environment variables control the mTLS feature: +TLS must be enabled on the gRPC endpoint in order for mTLS to work see [TLS](#TLS). The following variables can be set to enable mTLS on the Ratelimit service. -1. `GRPC_SERVER_USE_TLS` - Enables gprc connections to server over TLS -1. `GRPC_SERVER_TLS_CERT` - Path to the file containing the server cert chain -1. `GRPC_SERVER_TLS_KEY` - Path to the file containing the server private key 1. `GRPC_CLIENT_TLS_CACERT` - Path to the file containing the client CA certificate. 1. `GRPC_CLIENT_TLS_SAN` - (Optional) DNS Name to validate from the client cert during mTLS auth diff --git a/api/ratelimit/config/ratelimit/v3/rls_conf.proto b/api/ratelimit/config/ratelimit/v3/rls_conf.proto index cdb1836fd..4b62e797b 100644 --- a/api/ratelimit/config/ratelimit/v3/rls_conf.proto +++ b/api/ratelimit/config/ratelimit/v3/rls_conf.proto @@ -42,6 +42,13 @@ message RateLimitDescriptor { // Mark the descriptor as shadow. When the values is true, rate limit service allow requests to the backend. bool shadow_mode = 5; + + // Setting the `detailed_metric: true` for a descriptor will extend the metrics that are produced. + bool detailed_metric = 6; + + // Mark the descriptor as quota mode. When quota_mode is true, the rate limit service will allow + // requests until all quota descriptors are over the limit. + bool quota_mode = 7; } // Rate-limit policy. @@ -88,17 +95,13 @@ enum RateLimitUnit { // The time unit representing a day. DAY = 4; -} -// [#protodoc-title: Rate Limit Config Discovery Service (RLS Conf DS)] + // The time unit representing a week. + WEEK = 7; -// Return list of all rate limit configs that rate limit service should be configured with. -service RateLimitConfigDiscoveryService { - rpc StreamRlsConfigs(stream envoy.service.discovery.v3.DiscoveryRequest) - returns (stream envoy.service.discovery.v3.DiscoveryResponse) { - } + // The time unit representing a month. + MONTH = 5; - rpc FetchRlsConfigs(envoy.service.discovery.v3.DiscoveryRequest) - returns (envoy.service.discovery.v3.DiscoveryResponse) { - } + // The time unit representing a year. + YEAR = 6; } diff --git a/api/ratelimit/service/ratelimit/v3/rls_conf_ds.proto b/api/ratelimit/service/ratelimit/v3/rls_conf_ds.proto new file mode 100644 index 000000000..3aa6525c7 --- /dev/null +++ b/api/ratelimit/service/ratelimit/v3/rls_conf_ds.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package ratelimit.service.ratelimit.v3; + +import "envoy/service/discovery/v3/discovery.proto"; + +option java_package = "io.envoyproxy.ratelimit.service.config.v3"; +option java_outer_classname = "RlsConfigProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/ratelimit/service/config/v3;configv3"; +option java_generic_services = true; + +// [#protodoc-title: Rate Limit Config Discovery Service (RLS Conf DS)] + +// Return list of all rate limit configs that rate limit service should be configured with. +service RateLimitConfigDiscoveryService { + + rpc StreamRlsConfigs(stream envoy.service.discovery.v3.DiscoveryRequest) + returns (stream envoy.service.discovery.v3.DiscoveryResponse) { + } + + rpc FetchRlsConfigs(envoy.service.discovery.v3.DiscoveryRequest) + returns (envoy.service.discovery.v3.DiscoveryResponse) { + } +} diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 80488bec5..242a50117 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -43,8 +43,7 @@ services: - USE_STATSD=true - STATSD_HOST=statsd - STATSD_PORT=9125 - - LOG_FORMAT=console - - LOG_LEVEL=DEBUG + - LOG_LEVEL=debug - REDIS_SOCKET_TYPE=tcp - REDIS_URL=redis:6379 - RUNTIME_ROOT=/data diff --git a/docker-compose.yml b/docker-compose.yml index 814828e23..1a35cca8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,54 +18,29 @@ services: networks: - ratelimit-network - # minimal container that builds the ratelimit service binary and exits. - ratelimit-build: - image: golang:1.21.5-alpine - working_dir: /go/src/github.com/envoyproxy/ratelimit - command: go build -o /usr/local/bin/ratelimit ./src/service_cmd/main.go - volumes: - - .:/go/src/github.com/envoyproxy/ratelimit - - binary:/usr/local/bin/ - - ratelimit-client-build: - image: golang:1.21.5-alpine - working_dir: /go/src/github.com/envoyproxy/ratelimit - command: go build -o /usr/local/bin/ratelimit_client ./src/client_cmd/main.go - volumes: - - .:/go/src/github.com/envoyproxy/ratelimit - - binary:/usr/local/bin/ - ratelimit: - image: alpine:3.6 - command: > - sh -c "until test -f /usr/local/bin/ratelimit; do sleep 5; done; /usr/local/bin/ratelimit" + build: + context: . + dockerfile: Dockerfile + command: /bin/ratelimit ports: - 8080:8080 - 8081:8081 - 6070:6070 depends_on: - redis - - ratelimit-build - - ratelimit-client-build networks: - ratelimit-network volumes: - - binary:/usr/local/bin/ - ./examples:/data environment: - USE_STATSD=false - - LOG_FORMAT=console - - LOG_LEVEL=DEBUG - - REDIS_POOL_SIZE=100 + - LOG_LEVEL=debug - REDIS_SOCKET_TYPE=tcp - REDIS_URL=redis:6379 - RUNTIME_ROOT=/data - RUNTIME_SUBDIRECTORY=ratelimit - MEMCACHE_HOST_PORT=memcached:11211 - - EXPIRATION_JITTER_MAX_SECONDS=0 networks: ratelimit-network: - -volumes: - binary: diff --git a/examples/envoy/mock.yaml b/examples/envoy/mock.yaml index e3fe04188..226906abe 100644 --- a/examples/envoy/mock.yaml +++ b/examples/envoy/mock.yaml @@ -23,7 +23,35 @@ static_resources: direct_response: status: "200" body: - inline_string: "Hello World" + inline_string: "Hello World from Service 1" + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + - address: + socket_address: + address: 0.0.0.0 + port_value: 10001 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress + route_config: + name: ingress + virtual_hosts: + - name: backend + domains: + - "*" + routes: + - match: + prefix: "/" + direct_response: + status: "200" + body: + inline_string: "Hello World from Service 2" http_filters: - name: envoy.filters.http.router typed_config: diff --git a/examples/envoy/proxy.yaml b/examples/envoy/proxy.yaml index a4a6a87f5..7a722a05f 100644 --- a/examples/envoy/proxy.yaml +++ b/examples/envoy/proxy.yaml @@ -34,6 +34,50 @@ static_resources: socket_address: address: envoy-mock port_value: 9999 + - name: multiservice + connect_timeout: 1s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + lb_subset_config: + fallback_policy: ANY_ENDPOINT + subset_selectors: + - keys: + - service + load_assignment: + cluster_name: multiservice + endpoints: + - locality: + region: "region_1" + metadata: + filter_metadata: + envoy.cluster.locality: + name: "service_1" + lb_endpoints: + - endpoint: + address: + socket_address: + address: envoy-mock + port_value: 9999 + metadata: + filter_metadata: + envoy.lb: + service: "service_1" + - locality: + region: "region_2" + metadata: + filter_metadata: + envoy.cluster.locality: + name: "service_2" + lb_endpoints: + - endpoint: + address: + socket_address: + address: envoy-mock + port_value: 10001 + metadata: + filter_metadata: + envoy.lb: + service: "service_2" listeners: - address: socket_address: @@ -61,6 +105,26 @@ static_resources: envoy_grpc: cluster_name: ratelimit transport_api_version: V3 + - name: envoy.filters.http.set_header + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.header_mutation.v3.HeaderMutation + mutations: + request_mutations: + - append: + header: + key: "x-envoy-service-with-quota" + value: "%DYNAMIC_METADATA(envoy.filters.http.ratelimit:metadata:service_with_quota:name)%" + append_action: OVERWRITE_IF_EXISTS_OR_ADD + - name: envoy.filters.http.header_to_metadata + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.header_to_metadata.v3.Config + stat_prefix: header_converter + request_rules: + - header: x-envoy-service-with-quota + on_header_present: + metadata_namespace: envoy.lb + key: service + type: STRING - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router @@ -158,3 +222,77 @@ static_resources: - request_headers: header_name: "unspec" descriptor_key: "unspec" + - match: + prefix: /quota + route: + cluster: mock + typed_per_filter_config: + envoy.filters.http.ratelimit: + "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimitPerRoute + vh_rate_limits: INCLUDE + override_option: INCLUDE_POLICY + rate_limits: + - actions: + - generic_key: + descriptor_value: "service_1" + descriptor_key: "service" + - actions: + - generic_key: + descriptor_value: "service_2" + descriptor_key: "service" + - match: + prefix: /tokenquota + route: + cluster: mock + typed_per_filter_config: + envoy.filters.http.ratelimit: + "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimitPerRoute + vh_rate_limits: INCLUDE + override_option: INCLUDE_POLICY + rate_limits: + - actions: + - generic_key: + descriptor_value: "service_2" + descriptor_key: "service" + hits_addend: + number: 0 + - actions: + - generic_key: + descriptor_value: "service_2" + descriptor_key: "service" + hits_addend: + number: 1 + apply_on_stream_done: true + - match: + prefix: /multiservice-tokenquota + route: + cluster: multiservice + typed_per_filter_config: + envoy.filters.http.ratelimit: + "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimitPerRoute + vh_rate_limits: INCLUDE + override_option: INCLUDE_POLICY + rate_limits: + - actions: + - generic_key: + descriptor_value: "service_1" + descriptor_key: "service" + hits_addend: + number: 0 + - actions: + - generic_key: + descriptor_value: "service_2" + descriptor_key: "service" + hits_addend: + number: 0 + - actions: + - metadata: + descriptor_key: "service" + source: CLUSTER_LOCALITY_ENTRY + metadata_key: + key: "envoy.cluster.locality" + path: + - key: "name" + hits_addend: + number: 1 + apply_on_stream_done: true diff --git a/examples/ratelimit/config/example.yaml b/examples/ratelimit/config/example.yaml index 1c3d0c198..556681b0b 100644 --- a/examples/ratelimit/config/example.yaml +++ b/examples/ratelimit/config/example.yaml @@ -84,3 +84,21 @@ descriptors: - key: qux rate_limit: unlimited: true + - key: service + value: service_1 + quota_mode: true + rate_limit: + unit: minute + requests_per_unit: 1 + metadata: + service_with_quota: + name: "service_1" + - key: service + value: service_2 + quota_mode: true + rate_limit: + unit: minute + requests_per_unit: 2 + metadata: + service_with_quota: + name: "service_2" diff --git a/examples/xds-sotw-config-server/Dockerfile b/examples/xds-sotw-config-server/Dockerfile index 98388a721..ce473bcf1 100644 --- a/examples/xds-sotw-config-server/Dockerfile +++ b/examples/xds-sotw-config-server/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.21.5@sha256:672a2286da3ee7a854c3e0a56e0838918d0dbb1c18652992930293312de898a6 AS build +FROM golang:1.26.4@sha256:68cb6d68bed024785b69195b89af7ac7a444f27791435f98647edff595aa0479 AS build WORKDIR /xds-server COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /go/bin/xds-server -v main/main.go -FROM alpine:3.16@sha256:e4cdb7d47b06ba0a062ad2a97a7d154967c8f83934594d9f2bd3efa89292996b AS final +FROM alpine:3.20@sha256:77726ef6b57ddf65bb551896826ec38bc3e53f75cdde31354fbffb4f25238ebd AS final RUN apk --no-cache add ca-certificates && apk --no-cache update COPY --from=build /go/bin/xds-server /bin/xds-server ENTRYPOINT [ "/bin/xds-server" ] diff --git a/examples/xds-sotw-config-server/go.mod b/examples/xds-sotw-config-server/go.mod index a6162ea0a..b0051e1bb 100644 --- a/examples/xds-sotw-config-server/go.mod +++ b/examples/xds-sotw-config-server/go.mod @@ -1,22 +1,23 @@ -module github.com/envoyproxy/ratelimit/examples/xds-sotw-config-server +module github.com/goatapp/ratelimit/examples/xds-sotw-config-server -go 1.21.5 +go 1.26.4 require ( - github.com/envoyproxy/go-control-plane v0.11.1 - google.golang.org/grpc v1.59.0 + github.com/envoyproxy/go-control-plane v0.12.1-0.20240123181358-841e293a220b + google.golang.org/grpc v1.65.0 ) require ( + cel.dev/expr v0.15.0 // indirect github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect - github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.0.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect - google.golang.org/protobuf v1.31.0 // indirect + github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b // indirect + github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/planetscale/vtprotobuf v0.5.1-0.20231212170721-e7d721933795 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/protobuf v1.34.1 // indirect ) diff --git a/examples/xds-sotw-config-server/go.sum b/examples/xds-sotw-config-server/go.sum index b923eb810..6c12801f8 100644 --- a/examples/xds-sotw-config-server/go.sum +++ b/examples/xds-sotw-config-server/go.sum @@ -1,82 +1,38 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +cel.dev/expr v0.15.0 h1:O1jzfJCQBfL5BFoYktaxwIhuttaQPsVWerH9/EEKx0w= +cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k= -github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b h1:ga8SEFjZ60pxLcmhnThWgvH2wg8376yUJmPhEH4H3kw= +github.com/cncf/xds/go v0.0.0-20240423153145-555b57ec207b/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= 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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= -github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= -github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/envoyproxy/go-control-plane v0.12.1-0.20240123181358-841e293a220b h1:M0BhcNaW04UV1haQO8IFSDB64dAeiBSsTMZks/sYDcQ= +github.com/envoyproxy/go-control-plane v0.12.1-0.20240123181358-841e293a220b/go.mod h1:lFu6itz1hckLR2A3aJ+ZKf3lu8HpjTsJSsqvVF6GL6g= +github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/planetscale/vtprotobuf v0.5.1-0.20231212170721-e7d721933795 h1:pH+U6pJP0BhxqQ4njBUjOg0++WMMvv3eByWzB+oATBY= +github.com/planetscale/vtprotobuf v0.5.1-0.20231212170721-e7d721933795/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY= -google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d h1:DoPTO70H+bcDXcd39vOqb2viZxgqeBeSGtZ55yZU4/Q= -google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= +google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +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.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/examples/xds-sotw-config-server/resource.go b/examples/xds-sotw-config-server/resource.go index 71df63249..9bb4afe48 100644 --- a/examples/xds-sotw-config-server/resource.go +++ b/examples/xds-sotw-config-server/resource.go @@ -93,7 +93,8 @@ func makeRlsConfig() []types.Resource { }, Descriptors: []*rls_config.RateLimitDescriptor{ { - Key: "bar", + Key: "bar", + DetailedMetric: true, RateLimit: &rls_config.RateLimitPolicy{ Unit: rls_config.RateLimitUnit_MINUTE, RequestsPerUnit: 3, @@ -161,7 +162,8 @@ func makeRlsConfig() []types.Resource { } func GenerateSnapshot() *cache.Snapshot { - snap, _ := cache.NewSnapshot("1", + snap, _ := cache.NewSnapshot( + "1", map[resource.Type][]types.Resource{ resource.RateLimitConfigType: makeRlsConfig(), }, diff --git a/go.mod b/go.mod index 0b7d2a86e..27e9f72a0 100644 --- a/go.mod +++ b/go.mod @@ -1,61 +1,76 @@ module github.com/goatapp/ratelimit -go 1.21.5 +go 1.26.4 require ( - github.com/alicebob/miniredis/v2 v2.31.0 + github.com/DataDog/datadog-go/v5 v5.5.0 + github.com/alicebob/miniredis/v2 v2.33.0 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 github.com/coocood/freecache v1.2.4 - github.com/envoyproxy/go-control-plane v0.11.1 + github.com/envoyproxy/go-control-plane v0.14.0 + github.com/envoyproxy/go-control-plane/envoy v1.36.0 + github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e + github.com/go-kit/log v0.2.1 github.com/golang/mock v1.6.0 - github.com/golang/protobuf v1.5.3 - github.com/google/uuid v1.4.0 + github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 - github.com/kavu/go_reuseport v1.5.0 + github.com/jpillora/backoff v1.0.0 github.com/kelseyhightower/envconfig v1.4.0 + github.com/libp2p/go-reuseport v0.4.0 github.com/lyft/goruntime v0.3.0 - github.com/lyft/gostats v0.4.12 + github.com/lyft/gostats v0.4.14 github.com/mediocregopher/radix/v4 v4.1.4 - github.com/stretchr/testify v1.8.4 - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 - go.opentelemetry.io/otel v1.21.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 - go.opentelemetry.io/otel/sdk v1.21.0 - go.opentelemetry.io/otel/trace v1.21.0 - go.uber.org/zap v1.25.0 - golang.org/x/net v0.19.0 - google.golang.org/grpc v1.59.0 - google.golang.org/protobuf v1.31.0 + github.com/prometheus/client_golang v1.19.1 + github.com/prometheus/client_model v0.6.2 + github.com/prometheus/statsd_exporter v0.26.1 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 + go.opentelemetry.io/contrib/propagators/b3 v1.40.0 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + go.uber.org/zap v1.28.0 + golang.org/x/net v0.55.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 ) require ( + cel.dev/expr v0.25.1 // indirect + github.com/Microsoft/go-winio v0.5.0 // indirect github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/envoyproxy/protoc-gen-validate v1.0.2 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/objx v0.5.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tilinna/clock v1.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - go.opentelemetry.io/otel/metric v1.21.0 // indirect - go.opentelemetry.io/proto/otlp v1.0.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cfa354156..37c43125d 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,30 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.110.10 h1:LXy9GEO+timppncPIAZoOj3l58LIU9k+kn48AN7IO3Y= -cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk= -cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= -github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/DataDog/datadog-go/v5 v5.5.0 h1:G5KHeB8pWBNXT4Jtw0zAkhdxEAWSpWH00geHI6LDrKU= +github.com/DataDog/datadog-go/v5 v5.5.0/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU= +github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= -github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU= -github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg= +github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= +github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= -github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/PB79y4KOPYVyFYdROxgaCwdTQ= -github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coocood/freecache v1.2.4 h1:UdR6Yz/X1HW4fZOuH0Z94KwG851GWOSknua5VUbb/5M= github.com/coocood/freecache v1.2.4/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -39,24 +33,33 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= -github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e h1:EHL6eLDhQduyYGEKh+QSXE7s7Yhg/hpeeHFT0ET0gBw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.1-0.20260131204543-4ca8b9cded3e/go.mod h1:buWyXJdrI6ayYbeGm3upu3Qf/qHHrdWfUHKnVrTD+vM= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= -github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= @@ -64,23 +67,21 @@ github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+Licev github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -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.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= -github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk= -github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -94,75 +95,98 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= +github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= github.com/lyft/goruntime v0.3.0 h1:VLBYR4s3XazkUT8lLtq9CJrt58YmLQQumrK3ktenEkI= github.com/lyft/goruntime v0.3.0/go.mod h1:BW1gngSpMJR9P9w23BPUPdhdbUWhpirl98TQhOWWMF4= github.com/lyft/gostats v0.4.1/go.mod h1:Tpx2xRzz4t+T2Tx0xdVgIoBdR2UMVz+dKnE3X01XSd8= -github.com/lyft/gostats v0.4.12 h1:vaQMrsY4QH9GOeJUkZ7bHm8kqS92IhHuuwh7vTQ4qyQ= -github.com/lyft/gostats v0.4.12/go.mod h1:rMGud5RRaGYMG0KPS0GAUSBBs69yFMOMYjAnmcPTaG8= +github.com/lyft/gostats v0.4.14 h1:xmP4yMfDvEKtlNZEcS2sYz0cvnps1ri337ZEEbw3ab8= +github.com/lyft/gostats v0.4.14/go.mod h1:cJWqEVL8JIewIJz/olUIios2F1q06Nc51hXejPQmBH0= github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts= github.com/mediocregopher/radix/v4 v4.1.4/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/statsd_exporter v0.26.1 h1:ucbIAdPmwAUcA+dU+Opok8Qt81Aw8HanlO+2N/Wjv7w= +github.com/prometheus/statsd_exporter v0.26.1/go.mod h1:XlDdjAmRmx3JVvPPYuFNUg+Ynyb5kR69iPPkQjxXFMk= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 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/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= -github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI= github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE= -go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= -go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h1:nUeKExfxAQVbiVFn32YXpXZZHZ61Cc3s3Rn1pDBGAb0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= -go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= -go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= -go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= -go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= -go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= -go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= -go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= -go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= -go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= @@ -183,11 +207,9 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -195,25 +217,26 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -228,30 +251,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 h1:W12Pwm4urIbRdGhMEg2NM9O3TWKjNcxQhs46V0ypf/k= -google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 h1:ZcOkrmX74HbKFYnpPY8Qsw93fC29TbJXspYKaBkSXDQ= -google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/integration-test/Dockerfile.tester b/integration-test/Dockerfile.tester index 18979fd96..9d588530e 100644 --- a/integration-test/Dockerfile.tester +++ b/integration-test/Dockerfile.tester @@ -1,4 +1,4 @@ -FROM alpine@sha256:51b67269f354137895d43f3b3d810bfacd3945438e94dc5ac55fdac340352f48 +FROM alpine@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412 USER root diff --git a/integration-test/docker-compose-integration-test.yml b/integration-test/docker-compose-integration-test.yml index 665308bf3..46d16a644 100644 --- a/integration-test/docker-compose-integration-test.yml +++ b/integration-test/docker-compose-integration-test.yml @@ -45,16 +45,16 @@ services: - USE_STATSD=true - STATSD_HOST=statsd - STATSD_PORT=9125 - - LOG_FORMAT=console - - LOG_LEVEL=DEBUG + - LOG_LEVEL=debug - REDIS_SOCKET_TYPE=tcp - REDIS_URL=redis:6379 - RUNTIME_ROOT=/data - RUNTIME_SUBDIRECTORY=ratelimit - RUNTIME_WATCH_ROOT=false + - RESPONSE_DYNAMIC_METADATA=true envoy-proxy: - image: envoyproxy/envoy-dev:latest + image: envoyproxy/envoy:dev entrypoint: "/usr/local/bin/envoy" command: - "--service-node proxy" @@ -92,8 +92,10 @@ services: - ratelimit-network expose: - "9999" + - "10001" ports: - "9999:9999" + - "10001:10001" tester: build: diff --git a/integration-test/run-all.sh b/integration-test/run-all.sh index fe8c94ee9..f90f0267f 100755 --- a/integration-test/run-all.sh +++ b/integration-test/run-all.sh @@ -1,5 +1,7 @@ #!/bin/bash +echo "Waiting for services to be up..." +sleep 2 echo "Running tests" FILES=/test/scripts/* diff --git a/integration-test/scripts/multiservice-tokenquota.sh b/integration-test/scripts/multiservice-tokenquota.sh new file mode 100755 index 000000000..0e276e483 --- /dev/null +++ b/integration-test/scripts/multiservice-tokenquota.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# +# request to /multiservice-tokenquota will produce the (service: service_1), (service: service_2) descriptor +# with 0 addend on request path and on response path descriptor containing the name of service which served the request with addend 1. +# RL service responds with metadata containing the service name with available quota. +# service_1 has 1 req/min, service_2 has 2 req/min. Since quota is depbited on response path a total of 2 requests/minute for service_1 +# and 3 requests/minute for service_2 is allowed. +# + +response=$(curl -f -s -H "request-no: 1" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +response=$(curl -f -s -H "request-no: 2" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +# service_1 is out of quota and service_2 should now be used +response=$(curl -f -s -H "request-no: 3" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") +response=$(curl -f -s -H "request-no: 4" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") +response=$(curl -f -s -H "request-no: 5" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 2") + +if [ $? -ne 0 ]; then + echo "Quota limit should not trigger yet" + exit 1 +fi + +# Quota is debited from service_2 bucket on the response path so only the 5th request should be rejected +response=$(curl -f -s -H "request-no: 6" http://envoy-proxy:8888/multiservice-tokenquota | grep "Hello World from Service") + +if [ $? -eq 0 ]; then + echo "Quota limiting should fail the request" + exit 1 +fi + +echo "Waiting 1 minute for quota buckets to be refreshed" +sleep 60 + +response=$(curl -f -s -H "request-no: 7" http://envoy-proxy:8888/multiservice-tokenquota | grep "from Service 1") +if [ $? -ne 0 ]; then + echo "Quota bucket should be refreshed and service_1 is used" + exit 1 +fi diff --git a/integration-test/scripts/quota.sh b/integration-test/scripts/quota.sh new file mode 100755 index 000000000..b21e7dd16 --- /dev/null +++ b/integration-test/scripts/quota.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# +# request to /quota will produce the (service: service_1), (service: service_2) descriptor +# service_1 has 1 req/min and service_2 has 2 req/min +# + +response=$(curl -f -s http://envoy-proxy:8888/quota) +response=$(curl -f -s http://envoy-proxy:8888/quota) + +if [ $? -ne 0 ]; then + echo "Quota limit should not trigger yet" + exit 1 +fi + +# Quota is debited from all matching buckets and 3rd request should be rejected +response=$(curl -f -s http://envoy-proxy:8888/quota | grep "Too Many Requests") + +if [ $? -eq 0 ]; then + echo "Quota limiting should fail the request" + exit 1 +fi + +echo "Waiting 1 minute for quota buckets to be refreshed" +sleep 60 + +response=$(curl -i -s http://envoy-proxy:8888/quota) +if [ $? -ne 0 ]; then + echo "Quota bucket should be refreshed" + exit 1 +fi diff --git a/integration-test/scripts/token-quota.sh b/integration-test/scripts/token-quota.sh new file mode 100755 index 000000000..195ec998d --- /dev/null +++ b/integration-test/scripts/token-quota.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# +# request to /tokenquota will produce the (service: service_1), (service: service_2) descriptor +# with 0 addend on request path and (service: service_2) descriptor with addend 1 on response path. +# service_2 has 2 req/min so 2 requests should be allowed +# + +response=$(curl -f -s -H "request-no: 1" http://envoy-proxy:8888/tokenquota) +response=$(curl -f -s -H "request-no: 2" http://envoy-proxy:8888/tokenquota) +response=$(curl -f -s -H "request-no: 3" http://envoy-proxy:8888/tokenquota) + +if [ $? -ne 0 ]; then + echo "Quota limit should not trigger yet" + exit 1 +fi + +# Quota is debited from service_2 bucket on the response path so only the 4th request should be rejected +response=$(curl -f -s -H "request-no: 4" http://envoy-proxy:8888/tokenquota) + +if [ $? -eq 0 ]; then + echo "Quota limiting should fail the request" + exit 1 +fi + +echo "Waiting 1 minute for quota buckets to be refreshed" +sleep 60 + +response=$(curl -i -s -H "request-no: 5" http://envoy-proxy:8888/tokenquota) +if [ $? -ne 0 ]; then + echo "Quota bucket should be refreshed" + exit 1 +fi diff --git a/integration-test/scripts/value-included-in-stats-key-when-unspecified.sh b/integration-test/scripts/value-included-in-stats-key-when-unspecified.sh index a3851cef7..6fc8b486b 100755 --- a/integration-test/scripts/value-included-in-stats-key-when-unspecified.sh +++ b/integration-test/scripts/value-included-in-stats-key-when-unspecified.sh @@ -24,7 +24,7 @@ if [ $? -eq 0 ]; then fi # Sleep a bit to allow the stats to be propagated -sleep 2 +sleep 5 # Extract the metric for the unspecified value, which shoulb be there due to the "detailed_metric" stats=$(curl -f -s statsd:9102/metrics | grep -e ratelimit_service_rate_limit_over_limit | grep unspec_unspecified_value | cut -d} -f2 | sed 's/ //g') diff --git a/src/client_cmd/main.go b/src/client_cmd/main.go index 8f872e8d5..bee8509a9 100644 --- a/src/client_cmd/main.go +++ b/src/client_cmd/main.go @@ -63,12 +63,14 @@ func (this *descriptorsValue) String() string { func main() { dialString := flag.String( - "dial_string", "localhost:8081", "url of ratelimit server in : form") + "dial_string", "localhost:8081", "url of ratelimit server in : form", + ) domain := flag.String("domain", "", "rate limit configuration domain to query") descriptorsValue := descriptorsValue{[]*pb_struct.RateLimitDescriptor{}} flag.Var( &descriptorsValue, "descriptors", - "descriptor list to query in =,=,... form") + "descriptor list to query in =,=,... form", + ) oltpProtocol := flag.String("oltp-protocol", "", "protocol to use when exporting tracing span, accept http, grpc or empty (disable tracing) as value, please use OLTP environment variables to set endpoint (refer to README.MD)") grpcServerTlsCACert := flag.String("grpc-server-ca-file", "", "path to the server CA file for TLS connection") grpcUseTLS := flag.Bool("grpc-use-tls", false, "Use TLS for connection to server") @@ -115,7 +117,8 @@ func main() { Domain: *domain, Descriptors: desc, HitsAddend: 1, - }) + }, + ) if err != nil { fmt.Printf("request error: %s\n", err.Error()) os.Exit(1) diff --git a/src/config/config.go b/src/config/config.go index 671a74d65..5dfa1d873 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -4,6 +4,7 @@ import ( pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "golang.org/x/net/context" + "google.golang.org/protobuf/types/known/structpb" "github.com/goatapp/ratelimit/src/stats" ) @@ -17,14 +18,19 @@ func (e RateLimitConfigError) Error() string { // Wrapper for an individual rate limit config entry which includes the defined limit and stats. type RateLimit struct { - FullKey string - Stats stats.RateLimitStats - Limit *pb.RateLimitResponse_RateLimit - Unlimited bool - ShadowMode bool - Name string - Replaces []string - IncludeValueInMetricWhenNotSpecified bool + FullKey string + Stats stats.RateLimitStats + Limit *pb.RateLimitResponse_RateLimit + Unlimited bool + ShadowMode bool + QuotaMode bool + Name string + Replaces []string + DetailedMetric bool + // ShareThresholdKeyPattern is a slice of wildcard patterns for descriptor entries + // The slice index corresponds to the descriptor entry index. + ShareThresholdKeyPattern []string + Metadata *structpb.Struct } // Interface for interacting with a loaded rate limit config. diff --git a/src/config/config_impl.go b/src/config/config_impl.go index a7c7d7a61..aa6a24db8 100644 --- a/src/config/config_impl.go +++ b/src/config/config_impl.go @@ -7,6 +7,7 @@ import ( pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "golang.org/x/net/context" + "google.golang.org/protobuf/types/known/structpb" "gopkg.in/yaml.v2" logger "github.com/goatapp/ratelimit/src/log" @@ -26,12 +27,16 @@ type YamlRateLimit struct { } type YamlDescriptor struct { - Key string - Value string - RateLimit *YamlRateLimit `yaml:"rate_limit"` - Descriptors []YamlDescriptor - ShadowMode bool `yaml:"shadow_mode"` - IncludeMetricsForUnspecifiedValue bool `yaml:"detailed_metric"` + Key string + Value string + RateLimit *YamlRateLimit `yaml:"rate_limit"` + Descriptors []YamlDescriptor + ShadowMode bool `yaml:"shadow_mode"` + QuotaMode bool `yaml:"quota_mode"` + DetailedMetric bool `yaml:"detailed_metric"` + ValueToMetric bool `yaml:"value_to_metric"` + ShareThreshold bool `yaml:"share_threshold"` + Metadata map[string]interface{} } type YamlRoot struct { @@ -39,10 +44,21 @@ type YamlRoot struct { Descriptors []YamlDescriptor } +// wildcardMatchEntry holds a pre-computed wildcard pattern for non-trailing * matching. +// parts is the pattern split on "*" at load time, avoiding allocations per request. +type wildcardMatchEntry struct { + key string // full descriptor key, e.g. "path_bar*baz*qux" + parts []string // pre-split on "*", e.g. ["path_bar", "baz", "qux"] +} + type rateLimitDescriptor struct { - descriptors map[string]*rateLimitDescriptor - limit *RateLimit - wildcardKeys []string + descriptors map[string]*rateLimitDescriptor + limit *RateLimit + wildcardEntries []wildcardMatchEntry // all wildcard patterns, pre-split at load time + valueToMetric bool + shareThreshold bool + wildcardPattern string // stores the wildcard pattern when share_threshold is true + metadata *structpb.Struct } type rateLimitDomain struct { @@ -65,9 +81,13 @@ var validKeys = map[string]bool{ "requests_per_unit": true, "unlimited": true, "shadow_mode": true, + "quota_mode": true, "name": true, "replaces": true, "detailed_metric": true, + "value_to_metric": true, + "share_threshold": true, + "metadata": true, } // Create a new rate limit config entry. @@ -77,20 +97,23 @@ var validKeys = map[string]bool{ // @param unlimited supplies whether the rate limit is unlimited // @return the new config entry. func NewRateLimit(requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Unit, rlStats stats.RateLimitStats, - unlimited bool, shadowMode bool, name string, replaces []string, includeValueInMetricWhenNotSpecified bool) *RateLimit { - + unlimited bool, shadowMode bool, quotaMode bool, name string, replaces []string, detailedMetric bool, +) *RateLimit { return &RateLimit{ FullKey: rlStats.GetKey(), Stats: rlStats, Limit: &pb.RateLimitResponse_RateLimit{ RequestsPerUnit: requestsPerUnit, Unit: unit, + Name: name, }, - Unlimited: unlimited, - ShadowMode: shadowMode, - Name: name, - Replaces: replaces, - IncludeValueInMetricWhenNotSpecified: includeValueInMetricWhenNotSpecified, + Unlimited: unlimited, + ShadowMode: shadowMode, + QuotaMode: quotaMode, + Name: name, + Replaces: replaces, + DetailedMetric: detailedMetric, + ShareThresholdKeyPattern: nil, } } @@ -99,8 +122,9 @@ func (this *rateLimitDescriptor) dump() string { ret := "" if this.limit != nil { ret += fmt.Sprintf( - "%s: unit=%s requests_per_unit=%d, shadow_mode: %t\n", this.limit.FullKey, - this.limit.Limit.Unit.String(), this.limit.Limit.RequestsPerUnit, this.limit.ShadowMode) + "%s: unit=%s requests_per_unit=%d, shadow_mode: %t, quota_mode: %t\n", this.limit.FullKey, + this.limit.Limit.Unit.String(), this.limit.Limit.RequestsPerUnit, this.limit.ShadowMode, this.limit.QuotaMode, + ) } for _, descriptor := range this.descriptors { ret += descriptor.dump() @@ -115,6 +139,105 @@ func newRateLimitConfigError(name string, err string) RateLimitConfigError { return RateLimitConfigError(fmt.Sprintf("%s: %s", name, err)) } +func convertMap(m map[interface{}]interface{}) map[string]interface{} { + res := make(map[string]interface{}) + for k, v := range m { + strKey := fmt.Sprintf("%v", k) + switch val := v.(type) { + case map[interface{}]interface{}: + res[strKey] = convertMap(val) + case []interface{}: + res[strKey] = convertSlice(val) + default: + res[strKey] = v + } + } + return res +} + +func convertSlice(s []interface{}) []interface{} { + res := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[interface{}]interface{}: + res[i] = convertMap(val) + case []interface{}: + res[i] = convertSlice(val) + default: + res[i] = v + } + } + return res +} + +// Create envoyMetadata from YamlMetadata +// @param yamlMetadata supplies metadata parsed from config YAML +func parseMetadata(yamlMetadata map[string]interface{}) (*structpb.Struct, error) { + if len(yamlMetadata) == 0 { + return nil, nil + } + + convertedValue := make(map[string]interface{}) + for k, v := range yamlMetadata { + switch val := v.(type) { + case map[interface{}]interface{}: + convertedValue[k] = convertMap(val) + case []interface{}: + convertedValue[k] = convertSlice(val) + default: + convertedValue[k] = v + } + } + + pbStruct, err := structpb.NewStruct(convertedValue) + if err != nil { + return nil, err + } + + return pbStruct, nil +} + +// wildcardMatch reports whether value matches a pre-split wildcard pattern. +// parts is the pattern split on "*" at load time (e.g. ["path_bar", "baz", "qux"]). +// Each * matches zero or more characters. No allocations are performed per call. +func wildcardMatch(parts []string, value string) bool { + if len(parts) == 1 { + return parts[0] == value + } + + // Value must start with the first literal segment and end with the last. + if !strings.HasPrefix(value, parts[0]) { + return false + } + if !strings.HasSuffix(value, parts[len(parts)-1]) { + return false + } + + // Ensure total fixed characters don't exceed the value length. + totalFixed := 0 + for _, p := range parts { + totalFixed += len(p) + } + if len(value) < totalFixed { + return false + } + + // Scan middle segments in order within the region between the consumed prefix and suffix. + remaining := value[len(parts[0]):] + if last := parts[len(parts)-1]; last != "" { + remaining = remaining[:len(remaining)-len(last)] + } + for _, part := range parts[1 : len(parts)-1] { + idx := strings.Index(remaining, part) + if idx < 0 { + return false + } + remaining = remaining[idx+len(part):] + } + + return true +} + // Load a set of config descriptors from the YAML file and check the input. // @param config supplies the config file that owns the descriptor. // @param parentKey supplies the fully resolved key name that owns this config level. @@ -135,7 +258,8 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p newParentKey := parentKey + finalKey if _, present := this.descriptors[finalKey]; present { panic(newRateLimitConfigError( - config.Name, fmt.Sprintf("duplicate descriptor composite key '%s'", newParentKey))) + config.Name, fmt.Sprintf("duplicate descriptor composite key '%s'", newParentKey), + )) } var rateLimit *RateLimit = nil @@ -143,20 +267,21 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p if descriptorConfig.RateLimit != nil { unlimited := descriptorConfig.RateLimit.Unlimited - value, present := - pb.RateLimitResponse_RateLimit_Unit_value[strings.ToUpper(descriptorConfig.RateLimit.Unit)] + value, present := pb.RateLimitResponse_RateLimit_Unit_value[strings.ToUpper(descriptorConfig.RateLimit.Unit)] validUnit := present && value != int32(pb.RateLimitResponse_RateLimit_UNKNOWN) if unlimited { if validUnit { panic(newRateLimitConfigError( config.Name, - "should not specify rate limit unit when unlimited")) + "should not specify rate limit unit when unlimited", + )) } } else if !validUnit { panic(newRateLimitConfigError( config.Name, - fmt.Sprintf("invalid rate limit unit '%s'", descriptorConfig.RateLimit.Unit))) + fmt.Sprintf("invalid rate limit unit '%s'", descriptorConfig.RateLimit.Unit), + )) } replaces := make([]string, len(descriptorConfig.RateLimit.Replaces)) @@ -166,12 +291,13 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p rateLimit = NewRateLimit( descriptorConfig.RateLimit.RequestsPerUnit, pb.RateLimitResponse_RateLimit_Unit(value), - statsManager.NewStats(newParentKey), unlimited, descriptorConfig.ShadowMode, - descriptorConfig.RateLimit.Name, replaces, descriptorConfig.IncludeMetricsForUnspecifiedValue, + statsManager.NewStats(newParentKey), unlimited, descriptorConfig.ShadowMode, descriptorConfig.QuotaMode, + descriptorConfig.RateLimit.Name, replaces, descriptorConfig.DetailedMetric, ) rateLimitDebugString = fmt.Sprintf( - " ratelimit={requests_per_unit=%d, unit=%s, unlimited=%t, shadow_mode=%t}", rateLimit.Limit.RequestsPerUnit, - rateLimit.Limit.Unit.String(), rateLimit.Unlimited, rateLimit.ShadowMode) + " ratelimit={requests_per_unit=%d, unit=%s, unlimited=%t, shadow_mode=%t, quota_mode=%t}", rateLimit.Limit.RequestsPerUnit, + rateLimit.Limit.Unit.String(), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.QuotaMode, + ) for _, replaces := range descriptorConfig.RateLimit.Replaces { if replaces.Name == "" { @@ -183,16 +309,51 @@ func (this *rateLimitDescriptor) loadDescriptors(config RateLimitConfigToLoad, p } } + // Validate share_threshold can only be used with wildcards (trailing or middle). + if descriptorConfig.ShareThreshold { + if !strings.Contains(finalKey, "*") { + panic(newRateLimitConfigError( + config.Name, + fmt.Sprintf("share_threshold can only be used with wildcard values (containing '*'), but found key '%s'", finalKey), + )) + } + } + + // Store wildcard pattern if share_threshold is enabled. + // Applies to both trailing-* and middle/multi-* patterns. + var wildcardPattern string = "" + if descriptorConfig.ShareThreshold && strings.Contains(finalKey, "*") { + wildcardPattern = finalKey + } + + // All wildcard patterns go into one unified list with pre-split parts. + // wildcardMatch handles trailing-* with the same performance as HasPrefix + // because HasSuffix("", "") is O(1) and there are no middle segments to scan. + if strings.Contains(finalKey, "*") { + this.wildcardEntries = append(this.wildcardEntries, wildcardMatchEntry{ + key: finalKey, + parts: strings.Split(finalKey, "*"), + }) + } + + metadata, err := parseMetadata(descriptorConfig.Metadata) + if err != nil { + panic(newRateLimitConfigError(config.Name, fmt.Sprintf("error parsing metadata: %s", err.Error()))) + } + logger.Debug(context.Background(), fmt.Sprintf("loading descriptor: key=%s%s", newParentKey, rateLimitDebugString)) - newDescriptor := &rateLimitDescriptor{map[string]*rateLimitDescriptor{}, rateLimit, nil} + newDescriptor := &rateLimitDescriptor{ + descriptors: map[string]*rateLimitDescriptor{}, + limit: rateLimit, + wildcardEntries: nil, + valueToMetric: descriptorConfig.ValueToMetric, + shareThreshold: descriptorConfig.ShareThreshold, + wildcardPattern: wildcardPattern, + metadata: metadata, + } newDescriptor.loadDescriptors(config, newParentKey+".", descriptorConfig.Descriptors, statsManager) this.descriptors[finalKey] = newDescriptor - - // Preload keys ending with "*" symbol. - if finalKey[len(finalKey)-1:] == "*" { - this.wildcardKeys = append(this.wildcardKeys, finalKey) - } } } @@ -211,6 +372,11 @@ func validateYamlKeys(fileName string, config_map map[interface{}]interface{}) { logger.Debug(context.Background(), errorText) panic(newRateLimitConfigError(fileName, errorText)) } + if k.(string) == "metadata" { + // Do not validate keys/values in the metadata, since they are arbitrary. If config is invalid the parsing fill fail + // later when it is converted to protobuf.Struct + continue + } switch v := v.(type) { case []interface{}: for _, e := range v { @@ -253,7 +419,8 @@ func (this *rateLimitConfigImpl) loadConfig(config RateLimitConfigToLoad) { if _, present := this.domains[root.Domain]; present { if !this.mergeDomainConfigs { panic(newRateLimitConfigError( - config.Name, fmt.Sprintf("duplicate domain '%s' in config file", root.Domain))) + config.Name, fmt.Sprintf("duplicate domain '%s' in config file", root.Domain), + )) } logger.Debug(context.Background(), fmt.Sprintf("patching domain: %s", root.Domain)) @@ -262,7 +429,14 @@ func (this *rateLimitConfigImpl) loadConfig(config RateLimitConfigToLoad) { } logger.Debug(context.Background(), fmt.Sprintf("loading domain: %s", root.Domain)) - newDomain := &rateLimitDomain{rateLimitDescriptor{map[string]*rateLimitDescriptor{}, nil, nil}} + newDomain := &rateLimitDomain{rateLimitDescriptor{ + descriptors: map[string]*rateLimitDescriptor{}, + limit: nil, + wildcardEntries: nil, + valueToMetric: false, + shareThreshold: false, + wildcardPattern: "", + }} newDomain.loadDescriptors(config, root.Domain+".", root.Descriptors, this.statsManager) this.domains[root.Domain] = newDomain } @@ -277,13 +451,15 @@ func (this *rateLimitConfigImpl) Dump() string { } func (this *rateLimitConfigImpl) GetLimit( - ctx context.Context, domain string, descriptor *pb_struct.RateLimitDescriptor) *RateLimit { - + ctx context.Context, domain string, descriptor *pb_struct.RateLimitDescriptor, +) *RateLimit { logger.Debug(ctx, "starting get limit lookup") var rateLimit *RateLimit = nil value := this.domains[domain] if value == nil { logger.Debug(ctx, fmt.Sprintf("unknown domain '%s'", domain)) + domainStats := this.statsManager.NewDomainStats(domain) + domainStats.NotFound.Inc() return rateLimit } @@ -297,6 +473,7 @@ func (this *rateLimitConfigImpl) GetLimit( this.statsManager.NewStats(rateLimitKey), false, false, + false, "", []string{}, false, @@ -306,33 +483,132 @@ func (this *rateLimitConfigImpl) GetLimit( descriptorsMap := value.descriptors prevDescriptor := &value.rateLimitDescriptor + + // Build detailed metric as we traverse the list of descriptors + var detailedMetricFullKey strings.Builder + detailedMetricFullKey.WriteString(domain) + + // Build value_to_metric-enhanced metric key as we traverse + var valueToMetricFullKey strings.Builder + valueToMetricFullKey.WriteString(domain) + + // Track share_threshold patterns for entries matched via wildcard (using indexes) + // This allows share_threshold to work when wildcard has nested descriptors + var shareThresholdPatterns map[int]string + for i, entry := range descriptor.Entries { // First see if key_value is in the map. If that isn't in the map we look for just key // to check for a default value. finalKey := entry.Key + "_" + entry.Value + + detailedMetricFullKey.WriteString(".") + detailedMetricFullKey.WriteString(finalKey) + logger.Debug(ctx, fmt.Sprintf("looking up key: %s", finalKey)) nextDescriptor := descriptorsMap[finalKey] + var matchedWildcardKey string - if nextDescriptor == nil && len(prevDescriptor.wildcardKeys) > 0 { - for _, wildcardKey := range prevDescriptor.wildcardKeys { - if strings.HasPrefix(finalKey, strings.TrimSuffix(wildcardKey, "*")) { - nextDescriptor = descriptorsMap[wildcardKey] + if nextDescriptor == nil && len(prevDescriptor.wildcardEntries) > 0 { + for _, entry := range prevDescriptor.wildcardEntries { + if wildcardMatch(entry.parts, finalKey) { + nextDescriptor = descriptorsMap[entry.key] + matchedWildcardKey = entry.key break } } } + matchedUsingValue := nextDescriptor != nil if nextDescriptor == nil { finalKey = entry.Key logger.Debug(ctx, fmt.Sprintf("looking up key: %s", finalKey)) nextDescriptor = descriptorsMap[finalKey] + matchedUsingValue = false + } + + // Track share_threshold pattern when matching via wildcard, even if no rate_limit at this level + if matchedWildcardKey != "" && nextDescriptor != nil && nextDescriptor.shareThreshold && nextDescriptor.wildcardPattern != "" { + // Extract the value part from the wildcard pattern (e.g., "key_files*" -> "files*") + if shareThresholdPatterns == nil { + shareThresholdPatterns = make(map[int]string) + } + + wildcardValue := strings.TrimPrefix(nextDescriptor.wildcardPattern, entry.Key+"_") + shareThresholdPatterns[i] = wildcardValue + logger.Debug(ctx, fmt.Sprintf("tracking share_threshold for entry index %d (key %s), wildcard pattern %s", i, entry.Key, wildcardValue)) + } + + // Build value_to_metric metrics path for this level + valueToMetricFullKey.WriteString(".") + if nextDescriptor != nil { + // Determine the value to use for this entry + var valueToUse string + hasShareThreshold := shareThresholdPatterns[i] != "" + + if matchedWildcardKey != "" { + // Matched via wildcard + if hasShareThreshold { + // share_threshold: always use wildcard pattern with * + valueToUse = shareThresholdPatterns[i] + } else if nextDescriptor.valueToMetric { + // value_to_metric: use actual runtime value + valueToUse = entry.Value + } else { + // No flags: preserve wildcard pattern + valueToUse = strings.TrimPrefix(matchedWildcardKey, entry.Key+"_") + } + } else if matchedUsingValue { + // Matched explicit key+value in config (share_threshold can't apply here) + valueToUse = entry.Value + } else { + // Matched default key (no value) in config + if nextDescriptor.valueToMetric { + valueToUse = entry.Value + } + } + + // Write key and value (if any) + valueToMetricFullKey.WriteString(entry.Key) + if valueToUse != "" { + valueToMetricFullKey.WriteString("_") + valueToMetricFullKey.WriteString(valueToUse) + } + } else { + // No next descriptor found; still append something deterministic + valueToMetricFullKey.WriteString(entry.Key) } if nextDescriptor != nil && nextDescriptor.limit != nil { logger.Debug(ctx, fmt.Sprintf("found rate limit: %s", finalKey)) if i == len(descriptor.Entries)-1 { - rateLimit = nextDescriptor.limit + // Create a copy of the rate limit to avoid modifying the shared object + originalLimit := nextDescriptor.limit + rateLimit = &RateLimit{ + FullKey: originalLimit.FullKey, + Stats: originalLimit.Stats, + Limit: originalLimit.Limit, + Unlimited: originalLimit.Unlimited, + ShadowMode: originalLimit.ShadowMode, + QuotaMode: originalLimit.QuotaMode, + Name: originalLimit.Name, + Replaces: originalLimit.Replaces, + DetailedMetric: originalLimit.DetailedMetric, + // Initialize ShareThresholdKeyPattern with correct length, empty strings for entries without share_threshold + ShareThresholdKeyPattern: nil, + Metadata: nextDescriptor.metadata, + } + // Apply all tracked share_threshold patterns when we find the rate_limit + // This works whether the rate_limit is at the wildcard level or deeper + // Only entries with share_threshold will have non-empty patterns + if len(shareThresholdPatterns) > 0 { + rateLimit.ShareThresholdKeyPattern = make([]string, len(descriptor.Entries)) + } + + for idx, pattern := range shareThresholdPatterns { + rateLimit.ShareThresholdKeyPattern[idx] = pattern + logger.Debug(ctx, fmt.Sprintf("share_threshold enabled for entry index %d, using wildcard pattern %s", idx, pattern)) + } } else { logger.Debug(ctx, "request depth does not match config depth, there are more entries in the request's descriptor") } @@ -342,8 +618,11 @@ func (this *rateLimitConfigImpl) GetLimit( logger.Debug(ctx, "iterating to next level") descriptorsMap = nextDescriptor.descriptors } else { - if rateLimit != nil && rateLimit.IncludeValueInMetricWhenNotSpecified { - rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(rateLimit.FullKey+"_"+entry.Value), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.Name, rateLimit.Replaces, false) + if rateLimit != nil && rateLimit.DetailedMetric { + // Preserve ShareThresholdKeyPattern when recreating rate limit + originalShareThresholdKeyPattern := rateLimit.ShareThresholdKeyPattern + rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(rateLimit.FullKey), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.QuotaMode, rateLimit.Name, rateLimit.Replaces, rateLimit.DetailedMetric) + rateLimit.ShareThresholdKeyPattern = originalShareThresholdKeyPattern } break @@ -351,6 +630,52 @@ func (this *rateLimitConfigImpl) GetLimit( prevDescriptor = nextDescriptor } + // Replace metric with detailed metric, if leaf descriptor is detailed. + // If share_threshold is enabled, always use wildcard pattern with * + if rateLimit != nil && rateLimit.DetailedMetric { + // Check if any entry has share_threshold enabled + hasShareThreshold := rateLimit.ShareThresholdKeyPattern != nil && len(rateLimit.ShareThresholdKeyPattern) > 0 + if hasShareThreshold { + // Build metric key with wildcard pattern (including *) for entries with share_threshold + var shareThresholdMetricKey strings.Builder + shareThresholdMetricKey.WriteString(domain) + for i, entry := range descriptor.Entries { + shareThresholdMetricKey.WriteString(".") + if i < len(rateLimit.ShareThresholdKeyPattern) && rateLimit.ShareThresholdKeyPattern[i] != "" { + shareThresholdMetricKey.WriteString(entry.Key) + shareThresholdMetricKey.WriteString("_") + shareThresholdMetricKey.WriteString(rateLimit.ShareThresholdKeyPattern[i]) + } else { + // Include full key_value for entries without share_threshold + shareThresholdMetricKey.WriteString(entry.Key) + if entry.Value != "" { + shareThresholdMetricKey.WriteString("_") + shareThresholdMetricKey.WriteString(entry.Value) + } + } + } + shareThresholdKey := shareThresholdMetricKey.String() + rateLimit.FullKey = shareThresholdKey + rateLimit.Stats = this.statsManager.NewStats(shareThresholdKey) + } else { + detailedKey := detailedMetricFullKey.String() + rateLimit.FullKey = detailedKey + rateLimit.Stats = this.statsManager.NewStats(detailedKey) + } + } + + // If not using detailed metric, but any value_to_metric path produced a different key, + // override stats to use the value_to_metric-enhanced key + if rateLimit != nil && !rateLimit.DetailedMetric { + enhancedKey := valueToMetricFullKey.String() + if enhancedKey != rateLimit.FullKey { + // Recreate to ensure a clean stats struct, then set to enhanced stats + originalShareThresholdKeyPattern := rateLimit.ShareThresholdKeyPattern + rateLimit = NewRateLimit(rateLimit.Limit.RequestsPerUnit, rateLimit.Limit.Unit, this.statsManager.NewStats(enhancedKey), rateLimit.Unlimited, rateLimit.ShadowMode, rateLimit.QuotaMode, rateLimit.Name, rateLimit.Replaces, rateLimit.DetailedMetric) + rateLimit.ShareThresholdKeyPattern = originalShareThresholdKeyPattern + } + } + return rateLimit } @@ -403,8 +728,8 @@ func ConfigFileContentToYaml(fileName, content string) *YamlRoot { // @param mergeDomainConfigs defines whether multiple configurations referencing the same domain will be merged or rejected throwing an error. // @return a new config. func NewRateLimitConfigImpl( - configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool) RateLimitConfig { - + configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool, +) RateLimitConfig { ret := &rateLimitConfigImpl{map[string]*rateLimitDomain{}, statsManager, mergeDomainConfigs} for _, config := range configs { ret.loadConfig(config) @@ -416,8 +741,8 @@ func NewRateLimitConfigImpl( type rateLimitConfigLoaderImpl struct{} func (this *rateLimitConfigLoaderImpl) Load( - configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool) RateLimitConfig { - + configs []RateLimitConfigToLoad, statsManager stats.Manager, mergeDomainConfigs bool, +) RateLimitConfig { return NewRateLimitConfigImpl(configs, statsManager, mergeDomainConfigs) } diff --git a/src/config/config_xds.go b/src/config/config_xds.go index 1e772c361..90ab9fb8d 100644 --- a/src/config/config_xds.go +++ b/src/config/config_xds.go @@ -16,11 +16,13 @@ func rateLimitDescriptorsPbToYaml(pb []*rls_conf_v3.RateLimitDescriptor) []YamlD descriptors := make([]YamlDescriptor, len(pb)) for i, d := range pb { descriptors[i] = YamlDescriptor{ - Key: d.Key, - Value: d.Value, - RateLimit: rateLimitPolicyPbToYaml(d.RateLimit), - Descriptors: rateLimitDescriptorsPbToYaml(d.Descriptors), - ShadowMode: d.ShadowMode, + Key: d.Key, + Value: d.Value, + RateLimit: rateLimitPolicyPbToYaml(d.RateLimit), + Descriptors: rateLimitDescriptorsPbToYaml(d.Descriptors), + ShadowMode: d.ShadowMode, + DetailedMetric: d.DetailedMetric, + QuotaMode: d.QuotaMode, } } diff --git a/src/config_check_cmd/main.go b/src/config_check_cmd/main.go index 89edec1ad..cfafff5a9 100644 --- a/src/config_check_cmd/main.go +++ b/src/config_check_cmd/main.go @@ -28,9 +28,11 @@ func loadConfigs(allConfigs []config.RateLimitConfigToLoad, mergeDomainConfigs b func main() { configDirectory := flag.String( - "config_dir", "", "path to directory containing rate limit configs") + "config_dir", "", "path to directory containing rate limit configs", + ) mergeDomainConfigs := flag.Bool( - "merge_domain_configs", false, "whether to merge configurations, referencing the same domain") + "merge_domain_configs", false, "whether to merge configurations, referencing the same domain", + ) flag.Parse() fmt.Printf("checking rate limit configs...\n") fmt.Printf("loading config directory: %s\n", *configDirectory) diff --git a/src/godogstats/dogstatsd_sink.go b/src/godogstats/dogstatsd_sink.go new file mode 100644 index 000000000..c63b5dbe2 --- /dev/null +++ b/src/godogstats/dogstatsd_sink.go @@ -0,0 +1,135 @@ +package godogstats + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/DataDog/datadog-go/v5/statsd" + gostats "github.com/lyft/gostats" + + logger "github.com/goatapp/ratelimit/src/log" +) + +type godogStatsSink struct { + client *statsd.Client + config struct { + host string + port int + } + + mogrifier mogrifierMap +} + +// ensure that godogStatsSink implements gostats.Sink +var _ gostats.Sink = (*godogStatsSink)(nil) + +type goDogStatsSinkOption func(*godogStatsSink) + +func WithStatsdHost(host string) goDogStatsSinkOption { + return func(g *godogStatsSink) { + g.config.host = host + } +} + +func WithStatsdPort(port int) goDogStatsSinkOption { + return func(g *godogStatsSink) { + g.config.port = port + } +} + +// WithMogrifier adds a mogrifier to the sink. Map iteration order is randomized, to control order call multiple times. +func WithMogrifier(mogrifiers map[*regexp.Regexp]func([]string) (string, []string)) goDogStatsSinkOption { + return func(g *godogStatsSink) { + for m, h := range mogrifiers { + g.mogrifier = append(g.mogrifier, mogrifierEntry{ + matcher: m, + handler: h, + }) + } + } +} + +func WithMogrifierFromEnv(keys []string) goDogStatsSinkOption { + return func(g *godogStatsSink) { + mogrifier, err := newMogrifierMapFromEnv(keys) + if err != nil { + panic(err) + } + g.mogrifier = mogrifier + } +} + +func NewSink(opts ...goDogStatsSinkOption) (*godogStatsSink, error) { + sink := &godogStatsSink{} + for _, opt := range opts { + opt(sink) + } + client, err := statsd.New(sink.config.host+":"+strconv.Itoa(sink.config.port), statsd.WithoutClientSideAggregation()) + if err != nil { + return nil, err + } + sink.client = client + return sink, nil +} + +// separateTags separates the metric name and tags from the combined serialized metric name. +// e.g. given input: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT=12345.__DEPLOY=67890" +// this should produce output: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", ["COMMIT:12345", "DEPLOY:67890"] +// Aligns to how tags are serialized here https://github.com/lyft/gostats/blob/49e70f1b7932d146fecd991be04f8e1ad235452c/internal/tags/tags.go#L335 +func separateTags(name string) (string, []string) { + const ( + prefix = ".__" + sep = "=" + ) + + // split the name and tags about the first prefix for extra tags + shortName, tagString, hasTags := strings.Cut(name, prefix) + if !hasTags { + return name, nil + } + + // split the tags at every instance of prefix + tagPairs := strings.Split(tagString, prefix) + tags := make([]string, 0, len(tagPairs)) + for _, tagPair := range tagPairs { + // split the name + value by the seperator + tagName, tagValue, isValid := strings.Cut(tagPair, sep) + if !isValid { + logger.Debug(context.Background(), fmt.Sprintf("godogstats sink found malformed extra tag: %v, string: %v", tagPair, name)) + continue + } + tags = append(tags, tagName+":"+tagValue) + } + + return shortName, tags +} + +// mogrify takes a serialized metric name as input (internal gostats format) +// and returns a metric name and list of tags (dogstatsd output format) +// the output list of tags includes any "tags" that are serialized into the metric name, +// as well as any other tags emitted by the mogrifier config +func (g *godogStatsSink) mogrify(name string) (string, []string) { + name, extraTags := separateTags(name) + name, tags := g.mogrifier.mogrify(name) + return name, append(extraTags, tags...) +} + +func (g *godogStatsSink) FlushCounter(name string, value uint64) { + name, tags := g.mogrify(name) + g.client.Count(name, int64(value), tags, 1.0) +} + +func (g *godogStatsSink) FlushGauge(name string, value uint64) { + name, tags := g.mogrify(name) + g.client.Gauge(name, float64(value), tags, 1.0) +} + +func (g *godogStatsSink) FlushTimer(name string, milliseconds float64) { + name, tags := g.mogrify(name) + duration := time.Duration(milliseconds) * time.Millisecond + g.client.Timing(name, duration, tags, 1.0) +} diff --git a/src/godogstats/dogstatsd_sink_test.go b/src/godogstats/dogstatsd_sink_test.go new file mode 100644 index 000000000..0e021e923 --- /dev/null +++ b/src/godogstats/dogstatsd_sink_test.go @@ -0,0 +1,101 @@ +package godogstats + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSeparateExtraTags(t *testing.T) { + tests := []struct { + name string + givenMetric string + expectOutput string + expectTags []string + }{ + { + name: "no extra tags", + givenMetric: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectOutput: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectTags: nil, + }, + { + name: "one extra tags", + givenMetric: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT=12345", + expectOutput: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectTags: []string{"COMMIT:12345"}, + }, + { + name: "two extra tags", + givenMetric: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT=12345.__DEPLOY=6890", + expectOutput: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectTags: []string{"COMMIT:12345", "DEPLOY:6890"}, + }, + { + name: "invalid extra tag no value", + givenMetric: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT", + expectOutput: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectTags: []string{}, + }, + } + + for _, tt := range tests { + actualName, actualTags := separateTags(tt.givenMetric) + + assert.Equal(t, tt.expectOutput, actualName) + assert.Equal(t, tt.expectTags, actualTags) + } +} + +func TestSinkMogrify(t *testing.T) { + g := &godogStatsSink{ + mogrifier: mogrifierMap{ + { + matcher: regexp.MustCompile(`^ratelimit\.(.*)$`), + handler: func(matches []string) (string, []string) { + return "custom." + matches[1], []string{"tag1:value1", "tag2:value2"} + }, + }, + }, + } + + tests := []struct { + name string + input string + expectedName string + expectedTags []string + }{ + { + name: "mogrify with match and extra tags", + input: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT=12345.__DEPLOY=67890", + expectedName: "custom.service.rate_limit.mongo_cps.database_users.total_hits", + expectedTags: []string{"COMMIT:12345", "DEPLOY:67890", "tag1:value1", "tag2:value2"}, + }, + { + name: "mogrify with match without extra tags", + input: "ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", + expectedName: "custom.service.rate_limit.mongo_cps.database_users.total_hits", + expectedTags: []string{"tag1:value1", "tag2:value2"}, + }, + { + name: "extra tags with no match", + input: "foo.service.rate_limit.mongo_cps.database_users.total_hits.__COMMIT=12345.__DEPLOY=67890", + expectedName: "foo.service.rate_limit.mongo_cps.database_users.total_hits", + expectedTags: []string{"COMMIT:12345", "DEPLOY:67890"}, + }, + { + name: "no mogrification", + input: "other.metric.name", + expectedName: "other.metric.name", + expectedTags: nil, + }, + } + + for _, tt := range tests { + actualName, actualTags := g.mogrify(tt.input) + + assert.Equal(t, tt.expectedName, actualName) + assert.Equal(t, tt.expectedTags, actualTags) + } +} diff --git a/src/godogstats/mogrifier_map.go b/src/godogstats/mogrifier_map.go new file mode 100644 index 000000000..50114aebb --- /dev/null +++ b/src/godogstats/mogrifier_map.go @@ -0,0 +1,117 @@ +package godogstats + +import ( + "fmt" + "regexp" + "strconv" + + "github.com/kelseyhightower/envconfig" +) + +var varFinder = regexp.MustCompile(`\$\d+`) // matches $0, $1, etc. + +const envPrefix = "DOG_STATSD_MOGRIFIER" // prefix for environment variables + +type mogrifierEntry struct { + matcher *regexp.Regexp // the matcher determines whether a mogrifier should run on a metric at all + handler func(matches []string) (name string, tags []string) // the handler takes the list of matches, and returns metric name and list of tags +} + +// mogrifierMap is an ordered map of regular expressions to functions that mogrify a name and return tags +type mogrifierMap []mogrifierEntry + +// makePatternHandler returns a function that replaces $0, $1, etc. in the pattern with the corresponding match +func makePatternHandler(pattern string) func([]string) string { + return func(matches []string) string { + return varFinder.ReplaceAllStringFunc(pattern, func(s string) string { + i, err := strconv.Atoi(s[1:]) + if i >= len(matches) || err != nil { + // Return the original placeholder if the index is out of bounds + // or the Atoi fails, though given the varFinder regex it should + // not be possible. + return s + } + return matches[i] + }) + } +} + +// newMogrifierMapFromEnv loads mogrifiers from environment variables +// keys is a list of mogrifier names to load +func newMogrifierMapFromEnv(keys []string) (mogrifierMap, error) { + mogrifiers := mogrifierMap{} + + type config struct { + Pattern string `envconfig:"PATTERN"` + Tags map[string]string `envconfig:"TAGS"` + Name string `envconfig:"NAME"` + } + + for _, mogrifier := range keys { + cfg := config{} + if err := envconfig.Process(envPrefix+"_"+mogrifier, &cfg); err != nil { + return nil, fmt.Errorf("failed to load mogrifier %s: %v", mogrifier, err) + } + + if cfg.Pattern == "" { + return nil, fmt.Errorf("no PATTERN specified for mogrifier %s", mogrifier) + } + + re, err := regexp.Compile(cfg.Pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile pattern for %s: %s: %v", mogrifier, cfg.Pattern, err) + } + + if cfg.Name == "" { + return nil, fmt.Errorf("no NAME specified for mogrifier %s", mogrifier) + } + + nameHandler := makePatternHandler(cfg.Name) + tagHandlers := make(map[string]func([]string) string, len(cfg.Tags)) + for key, value := range cfg.Tags { + if key == "" { + return nil, fmt.Errorf("no key specified for tag %s for mogrifier %s", key, mogrifier) + } + tagHandlers[key] = makePatternHandler(value) + if value == "" { + return nil, fmt.Errorf("no value specified for tag %s for mogrifier %s", key, mogrifier) + } + } + + mogrifiers = append( + mogrifiers, mogrifierEntry{ + matcher: re, + handler: func(matches []string) (string, []string) { + name := nameHandler(matches) + tags := make([]string, 0, len(tagHandlers)) + for tagKey, handler := range tagHandlers { + tagValue := handler(matches) + tags = append(tags, tagKey+":"+tagValue) + } + return name, tags + }, + }, + ) + + } + return mogrifiers, nil +} + +// mogrify applies the first mogrifier in the map that matches the name +func (m *mogrifierMap) mogrify(name string) (string, []string) { + if m == nil { + return name, nil + } + for _, mogrifier := range *m { + matches := mogrifier.matcher.FindStringSubmatch(name) + if len(matches) == 0 { + continue + } + + mogrifiedName, tags := mogrifier.handler(matches) + return mogrifiedName, tags + } + + // no mogrification + return name, nil +} diff --git a/src/godogstats/mogrifier_map_test.go b/src/godogstats/mogrifier_map_test.go new file mode 100644 index 000000000..f44d2bce3 --- /dev/null +++ b/src/godogstats/mogrifier_map_test.go @@ -0,0 +1,205 @@ +package godogstats + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func testMogrifier() mogrifierMap { + return mogrifierMap{ + { + matcher: regexp.MustCompile(`^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`), + handler: func(matches []string) (string, []string) { + name := "ratelimit.service.rate_limit." + matches[3] + tags := []string{"domain:" + matches[1], "descriptor:" + matches[2]} + return name, tags + }, + }, + } +} + +func TestMogrify(t *testing.T) { + m := testMogrifier() + // Test case 1 + name1 := "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit" + expectedMogrifiedName1 := "ratelimit.service.rate_limit.within_limit" + expectedTags1 := []string{"domain:mongo_cps", "descriptor:database_users"} + mogrifiedName1, tags1 := m.mogrify(name1) + assert.Equal(t, expectedMogrifiedName1, mogrifiedName1) + assert.Equal(t, expectedTags1, tags1) +} + +func TestEmpty(t *testing.T) { + m := mogrifierMap{} + name, tags := m.mogrify("ratelimit.service.rate_limit.mongo_cps.database_users.within_limit") + assert.Equal(t, "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", name) + assert.Empty(t, tags) +} + +func TestNil(t *testing.T) { + var m mogrifierMap + name, tags := m.mogrify("ratelimit.service.rate_limit.mongo_cps.database_users.within_limit") + assert.Equal(t, "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", name) + assert.Empty(t, tags) +} + +func TestLoadMogrifiersFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + input string + expectOutput string + expectedTags []string + keys []string + }{ + { + name: "Simple replacement", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_TAG_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_TAG_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_TAG_TAGS": "domain:$1,descriptor:$2", + }, + input: "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", + expectOutput: "ratelimit.service.rate_limit.within_limit", + expectedTags: []string{"domain:mongo_cps", "descriptor:database_users"}, + keys: []string{"TAG"}, + }, + { + name: "Out of bounds index", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_TAG_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_TAG_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_TAG_TAGS": "domain:$1,descriptor:$5", + }, + input: "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", + expectOutput: "ratelimit.service.rate_limit.within_limit", + expectedTags: []string{"domain:mongo_cps", "descriptor:$5"}, + keys: []string{"TAG"}, + }, + { + name: "No placeholders in tags", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_TAG_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_TAG_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_TAG_TAGS": "domain:mongo_cps,descriptor:database_users", + }, + input: "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", + expectOutput: "ratelimit.service.rate_limit.within_limit", + expectedTags: []string{"domain:mongo_cps", "descriptor:database_users"}, + keys: []string{"TAG"}, + }, + { + name: "No matches", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_TAG_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_TAG_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_TAG_TAGS": "domain:$1,descriptor:$4", + }, + input: "some.unmatched.metric", + expectOutput: "some.unmatched.metric", + keys: []string{"TAG"}, + }, + { + name: "Two mogrifiers: First match", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_SPECIFIC_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.foo$`, + "DOG_STATSD_MOGRIFIER_SPECIFIC_NAME": "custom.foo", + "DOG_STATSD_MOGRIFIER_SPECIFIC_TAGS": "domain:$1,descriptor:$2", + "DOG_STATSD_MOGRIFIER_WILDCARD_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_WILDCARD_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_WILDCARD_TAGS": "domain:$1,descriptor:$2", + }, + input: "ratelimit.service.rate_limit.mongo_cps.database_users.foo", + expectOutput: "custom.foo", + expectedTags: []string{"domain:mongo_cps", "descriptor:database_users"}, + keys: []string{"SPECIFIC", "WILDCARD"}, + }, + { + name: "Two mogrifiers: second match", + envVars: map[string]string{ + "DOG_STATSD_MOGRIFIER_SPECIFIC_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.foo$`, + "DOG_STATSD_MOGRIFIER_SPECIFIC_NAME": "custom.foo", + "DOG_STATSD_MOGRIFIER_SPECIFIC_TAGS": "domain:$1,descriptor:$2", + "DOG_STATSD_MOGRIFIER_WILDCARD_PATTERN": `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`, + "DOG_STATSD_MOGRIFIER_WILDCARD_NAME": "ratelimit.service.rate_limit.$3", + "DOG_STATSD_MOGRIFIER_WILDCARD_TAGS": "domain:$1,descriptor:$2", + }, + input: "ratelimit.service.rate_limit.mongo_cps.database_users.within_limit", + expectOutput: "ratelimit.service.rate_limit.within_limit", + expectedTags: []string{"domain:mongo_cps", "descriptor:database_users"}, + keys: []string{"SPECIFIC", "WILDCARD"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + mogrifiers, err := newMogrifierMapFromEnv(tt.keys) + assert.NoError(t, err) + assert.NotNil(t, mogrifiers) + assert.Len(t, mogrifiers, len(tt.keys)) + + name, tags := mogrifiers.mogrify(tt.input) + assert.Equal(t, tt.expectOutput, name) + assert.ElementsMatch(t, tt.expectedTags, tags) + }) + } +} + +func TestValidation(t *testing.T) { + t.Run("No settings will fail", func(t *testing.T) { + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.Error(t, err) + }) + + t.Run("EmptyPattern", func(t *testing.T) { + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_PATTERN", "") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_NAME", "ratelimit.service.rate_limit.$3") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_TAGS", "domain:$1,descriptor:$2") + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.Error(t, err) + }) + + t.Run("EmptyName", func(t *testing.T) { + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_PATTERN", `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`) + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_NAME", "") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_TAGS", "domain:$1,descriptor:$2") + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.Error(t, err) + }) + + t.Run("EmptyTagKey", func(t *testing.T) { + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_PATTERN", `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`) + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_NAME", "ratelimit.service.rate_limit.$3") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_TAGS", ":5") + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.Error(t, err) + }) + + t.Run("EmptyTagValue", func(t *testing.T) { + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_PATTERN", `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`) + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_NAME", "ratelimit.service.rate_limit.$3") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_TAGS", "domain:$1,descriptor:") + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.Error(t, err) + }) + + t.Run("Success w/ No mogrifiers", func(t *testing.T) { + _, err := newMogrifierMapFromEnv([]string{}) + assert.NoError(t, err) + }) + + t.Run("Success w/ mogrifier", func(t *testing.T) { + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_PATTERN", `^ratelimit\.service\.rate_limit\.(.*)\.(.*)\.(.*)$`) + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_NAME", "ratelimit.service.rate_limit.$3") + t.Setenv("DOG_STATSD_MOGRIFIER_TAG_TAGS", "domain:$1,descriptor:$2") + _, err := newMogrifierMapFromEnv([]string{"TAG"}) + assert.NoError(t, err) + }) +} diff --git a/src/limiter/base_limiter.go b/src/limiter/base_limiter.go index 91c2477b8..193e09158 100644 --- a/src/limiter/base_limiter.go +++ b/src/limiter/base_limiter.go @@ -9,9 +9,10 @@ import ( "github.com/coocood/freecache" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + logger "github.com/goatapp/ratelimit/src/log" + "github.com/goatapp/ratelimit/src/assert" "github.com/goatapp/ratelimit/src/config" - logger "github.com/goatapp/ratelimit/src/log" "github.com/goatapp/ratelimit/src/stats" "github.com/goatapp/ratelimit/src/utils" ) @@ -28,14 +29,15 @@ type BaseRateLimiter struct { type LimitInfo struct { limit *config.RateLimit - limitBeforeIncrease uint32 - limitAfterIncrease uint32 - nearLimitThreshold uint32 - overLimitThreshold uint32 + limitBeforeIncrease uint64 + limitAfterIncrease uint64 + nearLimitThreshold uint64 + overLimitThreshold uint64 } -func NewRateLimitInfo(limit *config.RateLimit, limitBeforeIncrease uint32, limitAfterIncrease uint32, - nearLimitThreshold uint32, overLimitThreshold uint32) *LimitInfo { +func NewRateLimitInfo(limit *config.RateLimit, limitBeforeIncrease uint64, limitAfterIncrease uint64, + nearLimitThreshold uint64, overLimitThreshold uint64, +) *LimitInfo { return &LimitInfo{ limit: limit, limitBeforeIncrease: limitBeforeIncrease, limitAfterIncrease: limitAfterIncrease, nearLimitThreshold: nearLimitThreshold, overLimitThreshold: overLimitThreshold, @@ -45,17 +47,17 @@ func NewRateLimitInfo(limit *config.RateLimit, limitBeforeIncrease uint32, limit // Generates cache keys for given rate limit request. Each cache key is represented by a concatenation of // domain, descriptor and current timestamp. func (this *BaseRateLimiter) GenerateCacheKeys(request *pb.RateLimitRequest, - limits []*config.RateLimit, hitsAddend uint32) []CacheKey { + limits []*config.RateLimit, hitsAddends []uint64, +) []CacheKey { assert.Assert(len(request.Descriptors) == len(limits)) cacheKeys := make([]CacheKey, len(request.Descriptors)) - for i := 0; i < len(request.Descriptors); i++ { // generateCacheKey() returns an empty string in the key if there is no limit // so that we can keep the arrays all the same size. cacheKeys[i] = this.cacheKeyGenerator.GenerateCacheKey(request.Domain, request.Descriptors[i], limits[i]) // Increase statistics for limits hit by their respective requests. if limits[i] != nil { - limits[i].Stats.TotalHits.Add(uint64(hitsAddend)) + limits[i].Stats.TotalHits.Add(hitsAddends[i]) } } return cacheKeys @@ -74,14 +76,15 @@ func (this *BaseRateLimiter) IsOverLimitWithLocalCache(key string) bool { } func (this *BaseRateLimiter) IsOverLimitThresholdReached(limitInfo *LimitInfo) bool { - limitInfo.overLimitThreshold = limitInfo.limit.Limit.RequestsPerUnit + limitInfo.overLimitThreshold = uint64(limitInfo.limit.Limit.RequestsPerUnit) return limitInfo.limitAfterIncrease > limitInfo.overLimitThreshold } // Generates response descriptor status based on cache key, over the limit with local cache, over the limit and // near the limit thresholds. Thresholds are checked in order and are mutually exclusive. func (this *BaseRateLimiter) GetResponseDescriptorStatus(ctx context.Context, key string, limitInfo *LimitInfo, - isOverLimitWithLocalCache bool, hitsAddend uint32) *pb.RateLimitResponse_DescriptorStatus { + isOverLimitWithLocalCache bool, hitsAddend uint64, +) *pb.RateLimitResponse_DescriptorStatus { if key == "" { return this.generateResponseDescriptorStatus(pb.RateLimitResponse_OK, nil, 0) @@ -90,16 +93,16 @@ func (this *BaseRateLimiter) GetResponseDescriptorStatus(ctx context.Context, ke isOverLimit := false if isOverLimitWithLocalCache { isOverLimit = true - limitInfo.limit.Stats.OverLimit.Add(uint64(hitsAddend)) - limitInfo.limit.Stats.OverLimitWithLocalCache.Add(uint64(hitsAddend)) + limitInfo.limit.Stats.OverLimit.Add(hitsAddend) + limitInfo.limit.Stats.OverLimitWithLocalCache.Add(hitsAddend) responseDescriptorStatus = this.generateResponseDescriptorStatus(pb.RateLimitResponse_OVER_LIMIT, limitInfo.limit.Limit, 0) } else { - limitInfo.overLimitThreshold = limitInfo.limit.Limit.RequestsPerUnit + limitInfo.overLimitThreshold = uint64(limitInfo.limit.Limit.RequestsPerUnit) // The nearLimitThreshold is the number of requests that can be made before hitting the nearLimitRatio. // We need to know it in both the OK and OVER_LIMIT scenarios. - limitInfo.nearLimitThreshold = uint32(math.Floor(float64(float32(limitInfo.overLimitThreshold) * this.nearLimitRatio))) - + limitInfo.nearLimitThreshold = uint64(math.Floor(float64(float32(limitInfo.overLimitThreshold) * this.nearLimitRatio))) + logger.Debug(ctx, fmt.Sprintf("cache key: %s current: %d limit: %d", key, limitInfo.limitAfterIncrease, limitInfo.overLimitThreshold)) if limitInfo.limitAfterIncrease > limitInfo.overLimitThreshold { isOverLimit = true responseDescriptorStatus = this.generateResponseDescriptorStatus(pb.RateLimitResponse_OVER_LIMIT, @@ -117,35 +120,33 @@ func (this *BaseRateLimiter) GetResponseDescriptorStatus(ctx context.Context, ke // In the time of 1h1m, since the cache key becomes different (mongo_2h), it won't get ratelimited. err := this.localCache.Set([]byte(key), []byte{}, int(utils.UnitToDivider(limitInfo.limit.Limit.Unit))) if err != nil { - logger.Error(ctx, fmt.Sprintf("Failing to set local cache key: %s", key), logger.WithError(err)) + logger.Error(ctx, fmt.Sprintf("Failing to set local cache key: %s", key)) } } } else { responseDescriptorStatus = this.generateResponseDescriptorStatus(pb.RateLimitResponse_OK, - limitInfo.limit.Limit, limitInfo.overLimitThreshold-limitInfo.limitAfterIncrease) + limitInfo.limit.Limit, uint32(limitInfo.overLimitThreshold-limitInfo.limitAfterIncrease)) - // The limit is OK, but we additionally want to know if we are near the limit. + // The limit is OK but we additionally want to know if we are near the limit. this.checkNearLimitThreshold(limitInfo, hitsAddend) limitInfo.limit.Stats.WithinLimit.Add(uint64(hitsAddend)) } } - if isOverLimit { - if limitInfo.limit.ShadowMode { - logger.Debug(ctx, fmt.Sprintf("Limit with key %s, is in shadow_mode", limitInfo.limit.FullKey)) - responseDescriptorStatus.Code = pb.RateLimitResponse_OK - // Increase shadow mode stats if the limit was actually over the limit - this.increaseShadowModeStats(isOverLimitWithLocalCache, limitInfo, hitsAddend) - } else { - logger.Debug(ctx, fmt.Sprintf("Limit with key %s, is over_limit", limitInfo.limit.FullKey)) - } + // If the limit is in ShadowMode, it should be always return OK + if isOverLimit && limitInfo.limit.ShadowMode { + logger.Debug(ctx, fmt.Sprintf("Limit with key %s, is in shadow_mode", limitInfo.limit.FullKey)) + responseDescriptorStatus.Code = pb.RateLimitResponse_OK + // Increase shadow mode stats if the limit was actually over the limit + this.increaseShadowModeStats(isOverLimitWithLocalCache, limitInfo, hitsAddend) } return responseDescriptorStatus } func NewBaseRateLimit(timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, - localCache *freecache.Cache, nearLimitRatio float32, cacheKeyPrefix string, statsManager stats.Manager) *BaseRateLimiter { + localCache *freecache.Cache, nearLimitRatio float32, cacheKeyPrefix string, statsManager stats.Manager, +) *BaseRateLimiter { return &BaseRateLimiter{ TimeSource: timeSource, JitterRand: jitterRand, @@ -157,38 +158,38 @@ func NewBaseRateLimit(timeSource utils.TimeSource, jitterRand *rand.Rand, expira } } -func (this *BaseRateLimiter) checkOverLimitThreshold(limitInfo *LimitInfo, hitsAddend uint32) { +func (this *BaseRateLimiter) checkOverLimitThreshold(limitInfo *LimitInfo, hitsAddend uint64) { // Increase over limit statistics. Because we support += behavior for increasing the limit, we need to // assess if the entire hitsAddend were over the limit. That is, if the limit's value before adding the // N hits was over the limit, then all the N hits were over limit. // Otherwise, only the difference between the current limit value and the over limit threshold // were over limit hits. if limitInfo.limitBeforeIncrease >= limitInfo.overLimitThreshold { - limitInfo.limit.Stats.OverLimit.Add(uint64(hitsAddend)) + limitInfo.limit.Stats.OverLimit.Add(hitsAddend) } else { - limitInfo.limit.Stats.OverLimit.Add(uint64(limitInfo.limitAfterIncrease - limitInfo.overLimitThreshold)) + limitInfo.limit.Stats.OverLimit.Add(limitInfo.limitAfterIncrease - limitInfo.overLimitThreshold) // If the limit before increase was below the over limit value, then some of the hits were // in the near limit range. - limitInfo.limit.Stats.NearLimit.Add(uint64(limitInfo.overLimitThreshold - utils.Max(limitInfo.nearLimitThreshold, limitInfo.limitBeforeIncrease))) + limitInfo.limit.Stats.NearLimit.Add(limitInfo.overLimitThreshold - max(limitInfo.nearLimitThreshold, limitInfo.limitBeforeIncrease)) } } -func (this *BaseRateLimiter) checkNearLimitThreshold(limitInfo *LimitInfo, hitsAddend uint32) { +func (this *BaseRateLimiter) checkNearLimitThreshold(limitInfo *LimitInfo, hitsAddend uint64) { if limitInfo.limitAfterIncrease > limitInfo.nearLimitThreshold { // Here we also need to assess which portion of the hitsAddend were in the near limit range. // If all the hits were over the nearLimitThreshold, then all hits are near limit. Otherwise, // only the difference between the current limit value and the near limit threshold were near // limit hits. if limitInfo.limitBeforeIncrease >= limitInfo.nearLimitThreshold { - limitInfo.limit.Stats.NearLimit.Add(uint64(hitsAddend)) + limitInfo.limit.Stats.NearLimit.Add(hitsAddend) } else { - limitInfo.limit.Stats.NearLimit.Add(uint64(limitInfo.limitAfterIncrease - limitInfo.nearLimitThreshold)) + limitInfo.limit.Stats.NearLimit.Add(limitInfo.limitAfterIncrease - limitInfo.nearLimitThreshold) } } } -func (this *BaseRateLimiter) increaseShadowModeStats(isOverLimitWithLocalCache bool, limitInfo *LimitInfo, hitsAddend uint32) { +func (this *BaseRateLimiter) increaseShadowModeStats(isOverLimitWithLocalCache bool, limitInfo *LimitInfo, hitsAddend uint64) { // Increase shadow mode statistics. For the same reason as over limit stats, // if the limit value before adding the N hits over the limit, then all N hits were over limit. if isOverLimitWithLocalCache || limitInfo.limitBeforeIncrease >= limitInfo.overLimitThreshold { @@ -199,7 +200,8 @@ func (this *BaseRateLimiter) increaseShadowModeStats(isOverLimitWithLocalCache b } func (this *BaseRateLimiter) generateResponseDescriptorStatus(responseCode pb.RateLimitResponse_Code, - limit *pb.RateLimitResponse_RateLimit, limitRemaining uint32) *pb.RateLimitResponse_DescriptorStatus { + limit *pb.RateLimitResponse_RateLimit, limitRemaining uint32, +) *pb.RateLimitResponse_DescriptorStatus { if limit != nil { return &pb.RateLimitResponse_DescriptorStatus{ Code: responseCode, diff --git a/src/limiter/cache_key.go b/src/limiter/cache_key.go index 8f6b03af5..1fb5d40ea 100644 --- a/src/limiter/cache_key.go +++ b/src/limiter/cache_key.go @@ -41,10 +41,11 @@ func isPerSecondLimit(unit pb.RateLimitResponse_RateLimit_Unit) bool { // @param domain supplies the cache key domain. // @param descriptor supplies the descriptor to generate the key for. // @param limit supplies the rate limit to generate the key for (may be nil). +// @param now supplies the current unix time. // @return CacheKey struct. func (this *CacheKeyGenerator) GenerateCacheKey( - domain string, descriptor *pb_struct.RateLimitDescriptor, limit *config.RateLimit) CacheKey { - + domain string, descriptor *pb_struct.RateLimitDescriptor, limit *config.RateLimit, +) CacheKey { if limit == nil { return CacheKey{ Key: "", @@ -67,7 +68,13 @@ func (this *CacheKeyGenerator) GenerateCacheKey( b.WriteString(entry.Key) b.WriteByte('_') - b.WriteString(entry.Value) + valueToUse := entry.Value + if limit != nil && limit.ShareThresholdKeyPattern != nil && i < len(limit.ShareThresholdKeyPattern) { + if wildcardPattern := limit.ShareThresholdKeyPattern[i]; wildcardPattern != "" { + valueToUse = wildcardPattern + } + } + b.WriteString(valueToUse) } return CacheKey{ diff --git a/src/log/logger.go b/src/log/logger.go index 717e64378..78e69382a 100644 --- a/src/log/logger.go +++ b/src/log/logger.go @@ -228,7 +228,6 @@ type scopedFieldsCtxKey struct{} func (k *scopedFieldsCtxKey) String() string { return "go-services/common/log scoped fields" } -// AddField adds a field to be logged along with the context. If the field already exists, it will be overwritten. func AddField(ctx context.Context, key string, value interface{}) context.Context { if key == "" { return ctx @@ -237,7 +236,6 @@ func AddField(ctx context.Context, key string, value interface{}) context.Contex return AddFields(ctx, LogField{Key: key, Value: value}) } -// AddFields adds fields to be logged along with the context. If a field already exists, it will be overwritten. func AddFields(ctx context.Context, fields ...LogField) context.Context { if len(fields) == 0 { return ctx @@ -274,12 +272,8 @@ func grpcContextLogParser(ctx context.Context) []LogOption { method, ok := grpc.Method(ctx) if ok { - // Method looks something like - goat.protos.Api/Rpc splitMethod := strings.Split(method, ".") - - // Get the last value - Api/Rpc method = splitMethod[len(splitMethod)-1] - // Do a char replacement to . to properly index - Api.Rpc method = strings.ReplaceAll(method, "/", ".") logOptions = append(logOptions, WithValue(pathKey, method)) diff --git a/src/memcached/cache_impl.go b/src/memcached/cache_impl.go index 1dcc9a099..e128a62be 100644 --- a/src/memcached/cache_impl.go +++ b/src/memcached/cache_impl.go @@ -17,8 +17,10 @@ package memcached import ( "context" + "crypto/tls" "fmt" "math/rand" + "net" "strconv" "sync" "time" @@ -34,11 +36,12 @@ import ( "github.com/bradfitz/gomemcache/memcache" + logger "github.com/goatapp/ratelimit/src/log" + pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "github.com/goatapp/ratelimit/src/config" "github.com/goatapp/ratelimit/src/limiter" - logger "github.com/goatapp/ratelimit/src/log" "github.com/goatapp/ratelimit/src/settings" "github.com/goatapp/ratelimit/src/srv" "github.com/goatapp/ratelimit/src/utils" @@ -64,15 +67,15 @@ var _ limiter.RateLimitCache = (*rateLimitMemcacheImpl)(nil) func (this *rateLimitMemcacheImpl) DoLimit( ctx context.Context, request *pb.RateLimitRequest, - limits []*config.RateLimit) []*pb.RateLimitResponse_DescriptorStatus { - + limits []*config.RateLimit, +) []*pb.RateLimitResponse_DescriptorStatus { logger.Debug(ctx, "starting cache lookup") // request.HitsAddend could be 0 (default value) if not specified by the caller in the Ratelimit request. - hitsAddend := utils.Max(1, request.HitsAddend) + hitsAddends := utils.GetHitsAddends(request) // First build a list of all cache keys that we are actually going to hit. - cacheKeys := this.baseRateLimiter.GenerateCacheKeys(request, limits, hitsAddend) + cacheKeys := this.baseRateLimiter.GenerateCacheKeys(request, limits, hitsAddends) isOverLimitWithLocalCache := make([]bool, len(request.Descriptors)) @@ -95,7 +98,8 @@ func (this *rateLimitMemcacheImpl) DoLimit( } // Generate trace - _, span := tracer.Start(ctx, "Memcached Fetch Execution", + _, span := tracer.Start( + ctx, "Memcached Fetch Execution", trace.WithAttributes( attribute.Int("keysToGet length", len(keysToGet)), ), @@ -112,34 +116,34 @@ func (this *rateLimitMemcacheImpl) DoLimit( if len(keysToGet) > 0 { memcacheValues, err = this.client.GetMulti(keysToGet) if err != nil { - logger.Error(ctx, fmt.Sprintf("Error multi-getting memcache keys (%s)", keysToGet), logger.WithError(err)) + logger.Error(ctx, fmt.Sprintf("Error multi-getting memcache keys (%s): %s", keysToGet, err)) } } for i, cacheKey := range cacheKeys { rawMemcacheValue, ok := memcacheValues[cacheKey.Key] - var limitBeforeIncrease uint32 + var limitBeforeIncrease uint64 if ok { decoded, err := strconv.ParseInt(string(rawMemcacheValue.Value), 10, 32) if err != nil { logger.Error(ctx, fmt.Sprintf("Unexpected non-numeric value in memcached: %v", rawMemcacheValue)) } else { - limitBeforeIncrease = uint32(decoded) + limitBeforeIncrease = uint64(decoded) } } - limitAfterIncrease := limitBeforeIncrease + hitsAddend + limitAfterIncrease := limitBeforeIncrease + hitsAddends[i] limitInfo := limiter.NewRateLimitInfo(limits[i], limitBeforeIncrease, limitAfterIncrease, 0, 0) responseDescriptorStatuses[i] = this.baseRateLimiter.GetResponseDescriptorStatus(ctx, cacheKey.Key, - limitInfo, isOverLimitWithLocalCache[i], hitsAddend) + limitInfo, isOverLimitWithLocalCache[i], hitsAddends[i]) } this.waitGroup.Add(1) - runAsync(func() { this.increaseAsync(ctx, cacheKeys, isOverLimitWithLocalCache, limits, uint64(hitsAddend)) }) + runAsync(func() { this.increaseAsync(cacheKeys, isOverLimitWithLocalCache, limits, hitsAddends) }) if AutoFlushForIntegrationTests { this.Flush() } @@ -147,15 +151,16 @@ func (this *rateLimitMemcacheImpl) DoLimit( return responseDescriptorStatuses } -func (this *rateLimitMemcacheImpl) increaseAsync(ctx context.Context, cacheKeys []limiter.CacheKey, isOverLimitWithLocalCache []bool, - limits []*config.RateLimit, hitsAddend uint64) { +func (this *rateLimitMemcacheImpl) increaseAsync(cacheKeys []limiter.CacheKey, isOverLimitWithLocalCache []bool, + limits []*config.RateLimit, hitsAddends []uint64, +) { defer this.waitGroup.Done() for i, cacheKey := range cacheKeys { if cacheKey.Key == "" || isOverLimitWithLocalCache[i] { continue } - _, err := this.client.Increment(cacheKey.Key, hitsAddend) + _, err := this.client.Increment(cacheKey.Key, hitsAddends[i]) if err == memcache.ErrCacheMiss { expirationSeconds := utils.UnitToDivider(limits[i].Limit.Unit) if this.expirationJitterMaxSeconds > 0 { @@ -165,23 +170,23 @@ func (this *rateLimitMemcacheImpl) increaseAsync(ctx context.Context, cacheKeys // Need to add instead of increment. err = this.client.Add(&memcache.Item{ Key: cacheKey.Key, - Value: []byte(strconv.FormatUint(hitsAddend, 10)), + Value: []byte(strconv.FormatUint(hitsAddends[i], 10)), Expiration: int32(expirationSeconds), }) if err == memcache.ErrNotStored { // There was a race condition to do this add. We should be able to increment // now instead. - _, err := this.client.Increment(cacheKey.Key, hitsAddend) + _, err := this.client.Increment(cacheKey.Key, hitsAddends[i]) if err != nil { - logger.Error(ctx, fmt.Sprintf("Failed to increment key %s after failing to add", cacheKey.Key), logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Failed to increment key %s after failing to add: %s", cacheKey.Key, err)) continue } } else if err != nil { - logger.Error(ctx, fmt.Sprintf("Failed to add key %s", cacheKey.Key), logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Failed to add key %s: %s", cacheKey.Key, err)) continue } } else if err != nil { - logger.Error(ctx, fmt.Sprintf("Failed to increment key %s", cacheKey.Key), logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Failed to increment key %s: %s", cacheKey.Key, err)) continue } } @@ -199,7 +204,7 @@ func refreshServersPeriodically(serverList *memcache.ServerList, srv string, d t case <-t.C: err := refreshServers(serverList, srv, resolver) if err != nil { - logger.Warn(context.Background(), "failed to refresh memcahce hosts") + logger.Warn(context.Background(), "failed to refresh memcache hosts") } else { logger.Debug(context.Background(), "refreshed memcache hosts") } @@ -221,7 +226,7 @@ func refreshServers(serverList *memcache.ServerList, srv string, resolver srv.Sr return nil } -func newMemcachedFromSrv(srv string, d time.Duration, resolver srv.SrvResolver) Client { +func newMemcachedFromSrv(srv string, d time.Duration, resolver srv.SrvResolver) *memcache.Client { serverList := new(memcache.ServerList) err := refreshServers(serverList, srv, resolver) if err != nil { @@ -243,15 +248,24 @@ func newMemcachedFromSrv(srv string, d time.Duration, resolver srv.SrvResolver) func newMemcacheFromSettings(s settings.Settings) Client { if s.MemcacheSrv != "" && len(s.MemcacheHostPort) > 0 { - panic(MemcacheError("Both MEMCADHE_HOST_PORT and MEMCACHE_SRV are set")) + panic(MemcacheError("Both MEMCACHE_HOST_PORT and MEMCACHE_SRV are set")) } + var client *memcache.Client if s.MemcacheSrv != "" { logger.Debug(context.Background(), fmt.Sprintf("Using MEMCACHE_SRV: %v", s.MemcacheSrv)) - return newMemcachedFromSrv(s.MemcacheSrv, s.MemcacheSrvRefresh, new(srv.DnsSrvResolver)) + client = newMemcachedFromSrv(s.MemcacheSrv, s.MemcacheSrvRefresh, new(srv.DnsSrvResolver)) + } else { + logger.Debug(context.Background(), fmt.Sprintf("Using MEMCACHE_HOST_PORT: %v", s.MemcacheHostPort)) + client = memcache.New(s.MemcacheHostPort...) } - logger.Debug(context.Background(), fmt.Sprintf("Usng MEMCACHE_HOST_PORT:: %v", s.MemcacheHostPort)) - client := memcache.New(s.MemcacheHostPort...) client.MaxIdleConns = s.MemcacheMaxIdleConns + if s.MemcacheTls { + client.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + var td tls.Dialer + td.Config = s.MemcacheTlsConfig + return td.DialContext(ctx, network, address) + } + } return client } @@ -290,7 +304,8 @@ func runAsync(task func()) { } func NewRateLimitCacheImpl(client Client, timeSource utils.TimeSource, jitterRand *rand.Rand, - expirationJitterMaxSeconds int64, localCache *freecache.Cache, statsManager stats.Manager, nearLimitRatio float32, cacheKeyPrefix string) limiter.RateLimitCache { + expirationJitterMaxSeconds int64, localCache *freecache.Cache, statsManager stats.Manager, nearLimitRatio float32, cacheKeyPrefix string, +) limiter.RateLimitCache { return &rateLimitMemcacheImpl{ client: client, timeSource: timeSource, @@ -303,7 +318,8 @@ func NewRateLimitCacheImpl(client Client, timeSource utils.TimeSource, jitterRan } func NewRateLimitCacheImplFromSettings(s settings.Settings, timeSource utils.TimeSource, jitterRand *rand.Rand, - localCache *freecache.Cache, scope gostats.Scope, statsManager stats.Manager) limiter.RateLimitCache { + localCache *freecache.Cache, scope gostats.Scope, statsManager stats.Manager, +) limiter.RateLimitCache { return NewRateLimitCacheImpl( CollectStats(newMemcacheFromSettings(s), scope.Scope("memcache")), timeSource, diff --git a/src/provider/cert_provider.go b/src/provider/cert_provider.go new file mode 100644 index 000000000..3ec68456c --- /dev/null +++ b/src/provider/cert_provider.go @@ -0,0 +1,112 @@ +package provider + +import ( + "context" + "crypto/tls" + "fmt" + "path/filepath" + "sync" + + "github.com/lyft/goruntime/loader" + gostats "github.com/lyft/gostats" + + logger "github.com/goatapp/ratelimit/src/log" + + "github.com/goatapp/ratelimit/src/settings" +) + +// CertProvider will watch certDirectory for changes via goruntime/loader and reload the cert and key files +type CertProvider struct { + settings settings.Settings + runtime loader.IFace + runtimeUpdateEvent chan int + rootStore gostats.Store + certLock sync.RWMutex + cert *tls.Certificate + certDirectory string + certFile string + keyFile string +} + +// GetCertificateFunc returns a function compatible with tls.Config.GetCertificate, fetching the current certificate +func (p *CertProvider) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + p.certLock.RLock() + defer p.certLock.RUnlock() + return p.cert, nil + } +} + +func (p *CertProvider) watch() { + p.runtime.AddUpdateCallback(p.runtimeUpdateEvent) + + go func() { + for { + logger.Debug(context.Background(), "CertProvider: waiting for runtime update") + <-p.runtimeUpdateEvent + logger.Debug(context.Background(), "CertProvider: got runtime update and reloading config") + p.reloadCert() + } + }() +} + +// reloadCert loads the cert and key files and updates the tls.Certificate in memory +func (p *CertProvider) reloadCert() { + tlsKeyPair, err := tls.LoadX509KeyPair(p.certFile, p.keyFile) + if err != nil { + logger.Error(context.Background(), fmt.Sprintf("CertProvider failed to load TLS key pair (%s, %s): %v", p.certFile, p.keyFile, err)) + // panic in case there is no cert already loaded as this would mean starting up without TLS + if p.cert == nil { + logger.Fatal(context.Background(), "CertProvider failed to load any certificate, exiting.") + } + return // keep the old cert if we have one + } + p.certLock.Lock() + defer p.certLock.Unlock() + p.cert = &tlsKeyPair + logger.Info(context.Background(), fmt.Sprintf("CertProvider reloaded cert from (%s, %s)", p.certFile, p.keyFile)) +} + +// setupRuntime sets up the goruntime loader to watch the certDirectory +// Will panic if it fails to set up the loader +func (p *CertProvider) setupRuntime() { + var err error + + // runtimePath is the parent folder of certPath + runtimePath := filepath.Dir(p.certDirectory) + // runtimeSubdirectory is the name of the folder to watch, containing the certs + runtimeSubdirectory := filepath.Base(p.certDirectory) + + p.runtime, err = loader.New2( + runtimePath, + runtimeSubdirectory, + p.rootStore.ScopeWithTags("certs", p.settings.ExtraTags), + &loader.DirectoryRefresher{}, + loader.IgnoreDotFiles, + ) + if err != nil { + logger.Fatal(context.Background(), fmt.Sprintf("Failed to set up goruntime loader: %v", err)) + } +} + +// NewCertProvider creates a new CertProvider +// Will panic if it fails to set up gruntime or fails to load the initial certificate +func NewCertProvider(settings settings.Settings, rootStore gostats.Store, certFile, keyFile string) *CertProvider { + certDirectory := filepath.Dir(certFile) + if certDirectory != filepath.Dir(keyFile) { + logger.Fatal(context.Background(), "certFile and keyFile must be in the same directory") + } + p := &CertProvider{ + settings: settings, + runtimeUpdateEvent: make(chan int), + rootStore: rootStore, + certDirectory: certDirectory, + certFile: certFile, + keyFile: keyFile, + } + p.setupRuntime() + // Initially load the certificate (or panic) + p.reloadCert() + go p.watch() + return p +} diff --git a/src/provider/file_provider.go b/src/provider/file_provider.go index c4c46806b..8eb83815c 100644 --- a/src/provider/file_provider.go +++ b/src/provider/file_provider.go @@ -8,8 +8,9 @@ import ( "github.com/lyft/goruntime/loader" gostats "github.com/lyft/gostats" - "github.com/goatapp/ratelimit/src/config" logger "github.com/goatapp/ratelimit/src/log" + + "github.com/goatapp/ratelimit/src/config" "github.com/goatapp/ratelimit/src/settings" "github.com/goatapp/ratelimit/src/stats" ) @@ -84,7 +85,8 @@ func (p *FileProvider) setupRuntime() { p.settings.RuntimeSubdirectory, p.rootStore.ScopeWithTags("runtime", p.settings.ExtraTags), &loader.SymlinkRefresher{RuntimePath: p.settings.RuntimePath}, - loaderOpts...) + loaderOpts..., + ) } else { directoryRefresher := &loader.DirectoryRefresher{} // Adding loader.Remove to the default set of goruntime's FileSystemOps. @@ -95,7 +97,8 @@ func (p *FileProvider) setupRuntime() { p.settings.RuntimeAppDirectory, p.rootStore.ScopeWithTags("runtime", p.settings.ExtraTags), directoryRefresher, - loaderOpts...) + loaderOpts..., + ) } if err != nil { diff --git a/src/provider/xds_grpc_sotw_provider.go b/src/provider/xds_grpc_sotw_provider.go index 18683c073..913fd3af3 100644 --- a/src/provider/xds_grpc_sotw_provider.go +++ b/src/provider/xds_grpc_sotw_provider.go @@ -4,13 +4,14 @@ import ( "context" "fmt" "strings" + "time" "google.golang.org/grpc/metadata" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" "github.com/envoyproxy/go-control-plane/pkg/resource/v3" - "github.com/golang/protobuf/ptypes/any" grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry" + "github.com/jpillora/backoff" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -18,8 +19,9 @@ import ( "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/structpb" - "github.com/goatapp/ratelimit/src/config" logger "github.com/goatapp/ratelimit/src/log" + + "github.com/goatapp/ratelimit/src/config" "github.com/goatapp/ratelimit/src/settings" "github.com/goatapp/ratelimit/src/stats" @@ -67,6 +69,12 @@ func (p *XdsGrpcSotwProvider) Stop() { func (p *XdsGrpcSotwProvider) initXdsClient() { logger.Info(context.Background(), "Starting xDS client connection for rate limit configurations") conn := p.initializeAndWatch() + b := &backoff.Backoff{ + Min: p.settings.XdsClientBackoffInitialInterval, + Max: p.settings.XdsClientBackoffMaxInterval, + Factor: p.settings.XdsClientBackoffRandomFactor, + Jitter: p.settings.XdsClientBackoffJitter, + } for retryEvent := range p.connectionRetryChannel { if conn != nil { @@ -76,14 +84,22 @@ func (p *XdsGrpcSotwProvider) initXdsClient() { logger.Info(context.Background(), "Stopping xDS client watch for rate limit configurations") break } + d := p.getJitteredExponentialBackOffDuration(b) + logger.Debug(context.Background(), fmt.Sprintf("Sleeping for %s using exponential backoff\n", d)) + time.Sleep(d) conn = p.initializeAndWatch() } } +func (p *XdsGrpcSotwProvider) getJitteredExponentialBackOffDuration(b *backoff.Backoff) time.Duration { + logger.Debug(context.Background(), fmt.Sprintf("Retry attempt# %f", b.Attempt())) + return b.Duration() +} + func (p *XdsGrpcSotwProvider) initializeAndWatch() *grpc.ClientConn { conn, err := p.getGrpcConnection() if err != nil { - logger.Error(context.Background(), "Error initializing gRPC connection to xDS Management Server", logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Error initializing gRPC connection to xDS Management Server: %s", err.Error())) p.retryGrpcConn() return nil } @@ -98,12 +114,9 @@ func (p *XdsGrpcSotwProvider) watchConfigs() { for { resp, err := p.adsClient.Fetch() if err != nil { - logger.Error(context.Background(), "Failed to receive configuration from xDS Management Server", logger.WithError(err)) - if sotw.IsConnError(err) { - p.retryGrpcConn() - return - } - p.adsClient.Nack(err.Error()) + logger.Error(context.Background(), fmt.Sprintf("Failed to receive configuration from xDS Management Server: %s", err.Error())) + p.retryGrpcConn() + return } else { logger.Debug(context.Background(), fmt.Sprintf("Response received from xDS Management Server: %v", resp)) p.sendConfigs(resp.Resources) @@ -114,13 +127,23 @@ func (p *XdsGrpcSotwProvider) watchConfigs() { func (p *XdsGrpcSotwProvider) getGrpcConnection() (*grpc.ClientConn, error) { backOff := grpc_retry.BackoffLinearWithJitter(p.settings.ConfigGrpcXdsServerConnectRetryInterval, 0.5) logger.Info(context.Background(), fmt.Sprintf("Dialing xDS Management Server: '%s'", p.settings.ConfigGrpcXdsServerUrl)) - return grpc.Dial( - p.settings.ConfigGrpcXdsServerUrl, + grpcOptions := []grpc.DialOption{ p.getGrpcTransportCredentials(), grpc.WithBlock(), grpc.WithStreamInterceptor( grpc_retry.StreamClientInterceptor(grpc_retry.WithBackoff(backOff)), - )) + ), + } + maxRecvMsgSize := p.settings.XdsClientGrpcOptionsMaxMsgSizeInBytes + if maxRecvMsgSize != 0 { + logger.Info(context.Background(), fmt.Sprintf("Setting xDS gRPC max receive message size to %d bytes", maxRecvMsgSize)) + grpcOptions = append(grpcOptions, + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxRecvMsgSize))) + } + return grpc.Dial( + p.settings.ConfigGrpcXdsServerUrl, + grpcOptions..., + ) } func (p *XdsGrpcSotwProvider) getGrpcTransportCredentials() grpc.DialOption { @@ -136,7 +159,7 @@ func (p *XdsGrpcSotwProvider) getGrpcTransportCredentials() grpc.DialOption { return grpc.WithTransportCredentials(credentials.NewTLS(configGrpcXdsTlsConfig)) } -func (p *XdsGrpcSotwProvider) sendConfigs(resources []*any.Any) { +func (p *XdsGrpcSotwProvider) sendConfigs(resources []*anypb.Any) { defer func() { if e := recover(); e != nil { p.configUpdateEventChan <- &ConfigUpdateEventImpl{err: e} @@ -149,7 +172,7 @@ func (p *XdsGrpcSotwProvider) sendConfigs(resources []*any.Any) { confPb := &rls_conf_v3.RateLimitConfig{} err := anypb.UnmarshalTo(res, confPb, proto.UnmarshalOptions{}) if err != nil { - logger.Error(context.Background(), "Error while unmarshalling config from xDS Management Server", logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Error while unmarshalling config from xDS Management Server: %s", err.Error())) p.adsClient.Nack(err.Error()) return } diff --git a/src/redis/cache_impl.go b/src/redis/cache_impl.go index 53086d140..690378109 100644 --- a/src/redis/cache_impl.go +++ b/src/redis/cache_impl.go @@ -2,6 +2,7 @@ package redis import ( "context" + "io" "math/rand" "github.com/coocood/freecache" @@ -13,16 +14,24 @@ import ( "github.com/goatapp/ratelimit/src/utils" ) -func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Settings, localCache *freecache.Cache, srv server.Server, - timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, statsManager stats.Manager) limiter.RateLimitCache { +func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Settings, localCache *freecache.Cache, srv server.Server, timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { + closer := &utils.MultiCloser{} var perSecondPool Client if s.RedisPerSecond { - perSecondPool = NewClientImpl(ctx, srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, - s.RedisPerSecondType, s.RedisPerSecondUrl, s.RedisPerSecondPoolSize, s.RedisImplicitPipeline, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv) + perSecondPool = newClientImpl(ctx, srv.Scope().Scope("redis_per_second_pool"), s.RedisPerSecondTls, s.RedisPerSecondAuth, s.RedisPerSecondSocketType, + s.RedisPerSecondType, s.RedisPerSecondUrl, s.RedisPerSecondPoolSize, s.RedisPerSecondPipelineWindow, s.RedisPerSecondPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisPerSecondTimeout, + s.RedisPerSecondPoolOnEmptyBehavior, s.RedisPerSecondSentinelAuth, + s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime, + s.RedisPerSecondClusterPipelineParallelism) + closer.Closers = append(closer.Closers, perSecondPool) } - otherPool := NewClientImpl(ctx, srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, - s.RedisImplicitPipeline, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv) + otherPool := newClientImpl(ctx, srv.Scope().Scope("redis_pool"), s.RedisTls, s.RedisAuth, s.RedisSocketType, s.RedisType, s.RedisUrl, s.RedisPoolSize, + s.RedisPipelineWindow, s.RedisPipelineLimit, s.RedisTlsConfig, s.RedisHealthCheckActiveConnection, srv, s.RedisTimeout, + s.RedisPoolOnEmptyBehavior, s.RedisSentinelAuth, + s.RedisStartupInitialInterval, s.RedisStartupMaxInterval, s.RedisStartupMaxElapsedTime, + s.RedisClusterPipelineParallelism) + closer.Closers = append(closer.Closers, otherPool) return NewFixedRateLimitCacheImpl( otherPool, @@ -35,5 +44,5 @@ func NewRateLimiterCacheImplFromSettings(ctx context.Context, s settings.Setting s.CacheKeyPrefix, statsManager, s.StopCacheKeyIncrementWhenOverlimit, - ) + ), closer } diff --git a/src/redis/driver.go b/src/redis/driver.go index f85a8e9dc..db750d0a1 100644 --- a/src/redis/driver.go +++ b/src/redis/driver.go @@ -16,33 +16,16 @@ func (e RedisError) Error() string { // Interface for a redis client. type Client interface { // DoCmd is used to perform a redis command and retrieve a result. - // - // @param rcv supplies receiver for the result. - // @param cmd supplies the command to append. - // @param args supplies the additional arguments. DoCmd(ctx context.Context, rcv interface{}, cmd string, args ...interface{}) error // PipeAppend append a command onto the pipeline queue. - // - // @param pipeline supplies the queue for pending commands. - // @param rcv supplies receiver for the result. - // @param cmd supplies the command to append. - // @param args supplies the additional arguments. PipeAppend(pipeline Pipeline, rcv interface{}, cmd string, args ...interface{}) Pipeline // PipeScriptAppend append a script command onto the pipeline queue. - // - // @param pipeline supplies the queue for pending commands. - // @param rcv supplies receiver for the result. - // @param script supplies the script to append. - // @param args supplies the additional arguments. - PipeScriptAppend(pipeline Pipeline, rcv interface{}, script radix.EvalScript, args ...string) Pipeline - - // PipeDo writes multiple commands to a Conn in - // a single write, then reads their responses in a single read. This reduces - // network delay into a single round-trip. - // - // @param pipeline supplies the queue for pending commands. + PipeScriptAppend(pipeline Pipeline, rcv interface{}, script radix.EvalScript, keys []string, args ...string) Pipeline + + // PipeDo writes multiple commands to a Conn in a single write, then reads + // their responses in a single read. PipeDo(ctx context.Context, pipeline Pipeline) error // Once Close() is called all future method calls on the Client will return diff --git a/src/redis/driver_impl.go b/src/redis/driver_impl.go index ce1653322..f2fd55b2f 100644 --- a/src/redis/driver_impl.go +++ b/src/redis/driver_impl.go @@ -4,25 +4,21 @@ import ( "context" "crypto/tls" "fmt" + "net" "strings" + "time" + "github.com/jpillora/backoff" stats "github.com/lyft/gostats" "github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4/trace" logger "github.com/goatapp/ratelimit/src/log" + "github.com/goatapp/ratelimit/src/server" "github.com/goatapp/ratelimit/src/utils" ) -type commonClient interface { - // Do perform an Action on a Conn from a primary instance. - Do(context.Context, radix.Action) error - // Once Close() is called all future method calls on the Client will return - // an error - Close() error -} - type poolStats struct { connectionActive stats.Gauge connectionTotal stats.Counter @@ -46,11 +42,11 @@ func poolTrace(ps *poolStats, healthCheckActiveConnection bool, srv server.Serve if healthCheckActiveConnection && srv != nil { err := srv.HealthChecker().Ok(server.RedisHealthComponentName) if err != nil { - logger.Error(context.Background(), "Unable to update health status", logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Unable to update health status: %s", err)) } } } else { - fmt.Println("creating redis connection error :", newConn.Err) + logger.Error(context.Background(), fmt.Sprintf("creating redis connection error : %v", newConn.Err)) } }, ConnClosed: func(_ trace.PoolConnClosed) { @@ -59,17 +55,26 @@ func poolTrace(ps *poolStats, healthCheckActiveConnection bool, srv server.Serve if healthCheckActiveConnection && srv != nil && ps.connectionActive.Value() == 0 { err := srv.HealthChecker().Fail(server.RedisHealthComponentName) if err != nil { - logger.Error(context.Background(), "Unable to update health status", logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("Unable to update health status: %s", err)) } } }, } } +// redisClient is an interface that abstracts radix Client, Cluster, and Sentinel +// All of these types have Do(context.Context, Action) and Close() methods +type redisClient interface { + Do(context.Context, radix.Action) error + Close() error +} + type clientImpl struct { - client commonClient - stats poolStats - implicitPipelining bool + client redisClient + stats poolStats + implicitPipelining bool + isCluster bool + clusterPipelineParallelism int } func checkError(err error) { @@ -78,68 +83,257 @@ func checkError(err error) { } } -func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, - implicitPipelining bool, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server) Client { - maskedUrl := utils.MaskCredentialsInUrl(url) - logger.Warn(ctx, fmt.Sprintf("connecting to redis on %s with pool size %d", maskedUrl, poolSize)) +func effectiveClusterPipelineParallelism(configuredParallelism, poolSize int) int { + if configuredParallelism < 0 { + panic(RedisError("redis cluster pipeline parallelism must be >= 0")) + } - stats := newPoolStats(scope) + if configuredParallelism == 1 { + return 1 + } + + poolCeiling := poolSize + if poolCeiling < 1 { + poolCeiling = 1 + } + + if configuredParallelism == 0 || configuredParallelism > poolCeiling { + return poolCeiling + } + + return configuredParallelism +} + +// createDialer creates a radix.Dialer with timeout, TLS, and auth configuration +// targetName is used for logging to identify the connection target (e.g., URL, "sentinel(url)") +func createDialer(timeout time.Duration, useTls bool, tlsConfig *tls.Config, auth string, targetName string) radix.Dialer { + var netDialer net.Dialer + if timeout > 0 { + netDialer.Timeout = timeout + } - logger.Debug(ctx, fmt.Sprintf("Implicit pipelining enabled: %v", implicitPipelining)) + dialer := radix.Dialer{ + NetDialer: &netDialer, + } - poolConfig := radix.PoolConfig{Size: poolSize, Trace: poolTrace(&stats, healthCheckActiveConnection, srv)} + // Setup TLS if needed + if useTls { + tlsNetDialer := tls.Dialer{ + NetDialer: &netDialer, + Config: tlsConfig, + } + dialer.NetDialer = &tlsNetDialer + if targetName != "" { + logger.Warn(context.Background(), fmt.Sprintf("enabling TLS to redis %s", targetName)) + } + } + + // Setup auth if provided if auth != "" { user, pass, found := strings.Cut(auth, ":") if found { - logger.Warn(ctx, fmt.Sprintf("enabling authentication to redis on %s with user %s", maskedUrl, user)) + logger.Warn(context.Background(), fmt.Sprintf("enabling authentication to redis %s with user %s", targetName, user)) + dialer.AuthUser = user + dialer.AuthPass = pass } else { - logger.Warn(ctx, fmt.Sprintf("enabling authentication to redis on %s without user", maskedUrl)) - pass = user - user = "" + logger.Warn(context.Background(), fmt.Sprintf("enabling authentication to redis %s without user", targetName)) + dialer.AuthPass = auth } + } - poolConfig.Dialer = radix.Dialer{AuthUser: user, AuthPass: pass} + return dialer +} + +func NewClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, + pipelineWindow time.Duration, pipelineLimit int, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server, + timeout time.Duration, poolOnEmptyBehavior string, sentinelAuth string, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime time.Duration, +) Client { + return newClientImpl(ctx, scope, useTls, auth, redisSocketType, redisType, url, poolSize, + pipelineWindow, pipelineLimit, tlsConfig, healthCheckActiveConnection, srv, + timeout, poolOnEmptyBehavior, sentinelAuth, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime, 1) +} + +func newClientImpl(ctx context.Context, scope stats.Scope, useTls bool, auth, redisSocketType, redisType, url string, poolSize int, + pipelineWindow time.Duration, pipelineLimit int, tlsConfig *tls.Config, healthCheckActiveConnection bool, srv server.Server, + timeout time.Duration, poolOnEmptyBehavior string, sentinelAuth string, + startupInitialInterval, startupMaxInterval, startupMaxElapsedTime time.Duration, + clusterPipelineParallelism int, +) Client { + maskedUrl := utils.MaskCredentialsInUrl(url) + logger.Warn(context.Background(), fmt.Sprintf("connecting to redis on %s with pool size %d", maskedUrl, poolSize)) + + // Create Dialer for connecting to Redis + dialer := createDialer(timeout, useTls, tlsConfig, auth, maskedUrl) + + stats := newPoolStats(scope) + + // Create PoolConfig + poolConfig := radix.PoolConfig{ + Dialer: dialer, + Size: poolSize, + Trace: poolTrace(&stats, healthCheckActiveConnection, srv), } - if useTls { - poolConfig.Dialer.NetDialer = &tls.Dialer{Config: tlsConfig} + // Determine pipeline mode based on Redis type: + // - Cluster: uses grouped pipeline (same-key commands batched together) + // - Single/Sentinel: uses explicit pipeline (all commands batched together) + isCluster := strings.ToLower(redisType) == "cluster" + + // pipelineLimit parameter is deprecated and ignored in radix v4. + if pipelineLimit > 0 { + logger.Warn(context.Background(), fmt.Sprintf("REDIS_PIPELINE_LIMIT=%d is deprecated and has no effect in radix v4. Write buffering is controlled solely by REDIS_PIPELINE_WINDOW.", pipelineLimit)) } - var client commonClient - var err error - switch strings.ToLower(redisType) { - case "single": - client, err = poolConfig.New(ctx, redisSocketType, url) - case "cluster": - urls := strings.Split(url, ",") - if !implicitPipelining { - panic(RedisError("Implicit Pipelining must be enabled to work with Redis Cluster Mode. Set values for REDIS_PIPELINE_WINDOW or REDIS_PIPELINE_LIMIT to enable implicit pipelining")) + // Set WriteFlushInterval for cluster mode (grouped pipeline uses auto buffering) + if isCluster && pipelineWindow > 0 { + poolConfig.Dialer.WriteFlushInterval = pipelineWindow + logger.Debug(context.Background(), fmt.Sprintf("Cluster mode: setting WriteFlushInterval to %v", pipelineWindow)) + } + + effectivePipelineParallelism := clusterPipelineParallelism + if isCluster { + effectivePipelineParallelism = effectiveClusterPipelineParallelism(clusterPipelineParallelism, poolSize) + switch { + case clusterPipelineParallelism == 0: + logger.Warn(context.Background(), fmt.Sprintf("Redis cluster pipeline parallelism: auto bounded to Redis pool size (%d concurrent groups)", effectivePipelineParallelism)) + case clusterPipelineParallelism != effectivePipelineParallelism: + logger.Warn(context.Background(), fmt.Sprintf("Redis cluster pipeline parallelism: configured value %d exceeds Redis pool size %d; bounded to %d concurrent groups", clusterPipelineParallelism, poolSize, effectivePipelineParallelism)) + case effectivePipelineParallelism == 1: + logger.Warn(context.Background(), "Redis cluster pipeline parallelism: disabled (serial legacy behavior)") + default: + logger.Warn(context.Background(), fmt.Sprintf("Redis cluster pipeline parallelism: bounded to %d concurrent groups", effectivePipelineParallelism)) } - logger.Warn(ctx, fmt.Sprintf("Creating cluster with urls %v", urls)) - client, err = radix.ClusterConfig{PoolConfig: poolConfig}.New(ctx, urls) - case "sentinel": + } + + // IMPORTANT: radix v4 pool behavior changes from v3 + // + // v4 uses a FIXED pool size and BLOCKS when all connections are in use. + // This is the same as v3's WAIT behavior. + // + // v3 CREATE and ERROR behaviors are NOT supported in v4: + // - v3 WAIT → v4 supported (blocks until connection available) + // - v3 CREATE → v4 NOT SUPPORTED (would block instead of creating overflow connections) + // - v3 ERROR → v4 NOT SUPPORTED (would block instead of failing fast) + // + // Migration requirements: + // - Remove REDIS_POOL_ON_EMPTY_BEHAVIOR setting if set to CREATE or ERROR + // - Use WAIT or leave unset (WAIT is default) + // - Consider increasing REDIS_POOL_SIZE if you previously relied on CREATE + // - Use context timeouts to prevent indefinite blocking: + // ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + // defer cancel() + // client.Do(ctx, cmd) + switch strings.ToUpper(poolOnEmptyBehavior) { + case "WAIT": + logger.Warn(context.Background(), fmt.Sprintf("Redis pool %s: WAIT is default in radix v4 (blocks until connection available)", maskedUrl)) + case "CREATE": + // v3 CREATE created overflow connections when pool was full + // v4 does NOT support this - fail fast to prevent unexpected blocking behavior + panic(RedisError("REDIS_POOL_ON_EMPTY_BEHAVIOR=CREATE is not supported in radix v4. Pool will block instead of creating overflow connections. Remove this setting or set to WAIT, and consider increasing REDIS_POOL_SIZE.")) + case "ERROR": + // v3 ERROR failed fast when pool was full + // v4 does NOT support this - fail fast to prevent unexpected blocking behavior + panic(RedisError("REDIS_POOL_ON_EMPTY_BEHAVIOR=ERROR is not supported in radix v4. Pool will block instead of failing fast. Remove this setting or set to WAIT, and use context timeouts for fail-fast behavior.")) + default: + logger.Warn(context.Background(), fmt.Sprintf("Redis pool %s: using v4 default (fixed size=%d, blocks when full)", maskedUrl, poolSize)) + } + + poolFunc := func(ctx context.Context, network, addr string) (radix.Client, error) { + return poolConfig.New(ctx, network, addr) + } + + // Validate sentinel URL format early (before retry loop) since it's a configuration error. + if strings.ToLower(redisType) == "sentinel" { urls := strings.Split(url, ",") if len(urls) < 2 { panic(RedisError("Expected master name and a list of urls for the sentinels, in the format: ,,...,")) } - client, err = radix.SentinelConfig{PoolConfig: poolConfig}.New(ctx, urls[0], urls[1:]) - default: - panic(RedisError("Unrecognized redis type " + redisType)) } - checkError(err) + b := &backoff.Backoff{ + Min: startupInitialInterval, + Max: startupMaxInterval, + Factor: 2, + Jitter: true, + } + + startTime := time.Now() + + retryOrDie := func(lastErr error) { + elapsed := time.Since(startTime) + if startupMaxElapsedTime > 0 && elapsed >= startupMaxElapsedTime { + panic(RedisError(fmt.Sprintf("timed out waiting for Redis connection to %s after %s: %v", maskedUrl, elapsed.Round(time.Millisecond), lastErr))) + } + d := b.Duration() + logger.Warn(context.Background(), fmt.Sprintf("Retrying Redis connection to %s in %s (elapsed: %s): %v", maskedUrl, d, elapsed.Round(time.Millisecond), lastErr)) + select { + case <-time.After(d): + case <-ctx.Done(): + panic(RedisError(fmt.Sprintf("context cancelled while waiting for Redis connection to %s: %v", maskedUrl, ctx.Err()))) + } + } + + var client redisClient + for { + var err error + switch strings.ToLower(redisType) { + case "single": + logger.Warn(context.Background(), fmt.Sprintf("Creating single with urls %v", url)) + client, err = poolFunc(ctx, redisSocketType, url) + case "cluster": + urls := strings.Split(url, ",") + logger.Warn(context.Background(), fmt.Sprintf("Creating cluster with urls %v", urls)) + clusterConfig := radix.ClusterConfig{ + PoolConfig: poolConfig, + } + client, err = clusterConfig.New(ctx, urls) + case "sentinel": + urls := strings.Split(url, ",") + sentinelDialer := createDialer(timeout, useTls, tlsConfig, sentinelAuth, fmt.Sprintf("sentinel(%s)", maskedUrl)) + sentinelConfig := radix.SentinelConfig{ + PoolConfig: poolConfig, + SentinelDialer: sentinelDialer, + } + client, err = sentinelConfig.New(ctx, urls[0], urls[1:]) + default: + panic(RedisError("Unrecognized redis type " + redisType)) + } + + if err != nil { + retryOrDie(err) + continue + } + + var pingResponse string + if pingErr := client.Do(ctx, radix.Cmd(&pingResponse, "PING")); pingErr != nil { + _ = client.Close() + retryOrDie(pingErr) + continue + } + if pingResponse != "PONG" { + _ = client.Close() + retryOrDie(fmt.Errorf("unexpected PING response: %q", pingResponse)) + continue + } - // Check if connection is good - var pingResponse string - checkError(client.Do(ctx, radix.Cmd(&pingResponse, "PING"))) - if pingResponse != "PONG" { - checkError(fmt.Errorf("connecting redis error: %s", pingResponse)) + // Successfully connected. + break + } + + if srv != nil { + if err := srv.HealthChecker().Ok(server.RedisHealthComponentName); err != nil { + logger.Error(context.Background(), fmt.Sprintf("Unable to update health status after Redis connection: %s", err)) + } } return &clientImpl{ - client: client, - stats: stats, - implicitPipelining: implicitPipelining, + client: client, + stats: stats, + implicitPipelining: pipelineWindow > 0 || isCluster, + isCluster: isCluster, + clusterPipelineParallelism: effectivePipelineParallelism, } } @@ -159,28 +353,19 @@ func (c *clientImpl) PipeAppend(pipeline Pipeline, rcv interface{}, cmd string, return append(pipeline, radix.FlatCmd(rcv, cmd, args...)) } -func (c *clientImpl) PipeScriptAppend(pipeline Pipeline, rcv interface{}, script radix.EvalScript, args ...string) Pipeline { - return append(pipeline, script.FlatCmd(rcv, nil, args)) +func (c *clientImpl) PipeScriptAppend(pipeline Pipeline, rcv interface{}, script radix.EvalScript, keys []string, args ...string) Pipeline { + return append(pipeline, script.FlatCmd(rcv, keys, args)) } -func (c *clientImpl) PipeDo(ctx context.Context, pipeline Pipeline) error { - if c.implicitPipelining { - for _, action := range pipeline { - if err := c.client.Do(ctx, action); err != nil { - return err - } - } - return nil - } +func (c *clientImpl) ImplicitPipeliningEnabled() bool { + return c.implicitPipelining +} - newPipeline := radix.NewPipeline() +func (c *clientImpl) PipeDo(ctx context.Context, pipeline Pipeline) error { for _, action := range pipeline { - newPipeline.Append(action) + if err := c.client.Do(ctx, action); err != nil { + return err + } } - - return c.client.Do(ctx, newPipeline) -} - -func (c *clientImpl) ImplicitPipeliningEnabled() bool { - return c.implicitPipelining + return nil } diff --git a/src/redis/fixed_cache_impl.go b/src/redis/fixed_cache_impl.go index 5c8f9e2de..b886e952f 100644 --- a/src/redis/fixed_cache_impl.go +++ b/src/redis/fixed_cache_impl.go @@ -1,6 +1,7 @@ package redis import ( + "context" "fmt" "math/rand" "strconv" @@ -15,7 +16,6 @@ import ( "github.com/coocood/freecache" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "github.com/mediocregopher/radix/v4" - "golang.org/x/net/context" "github.com/goatapp/ratelimit/src/config" "github.com/goatapp/ratelimit/src/limiter" @@ -24,58 +24,47 @@ import ( ) var script = ` --- ARGV[1] = rate limit key --- ARGV[2] = timestamp key --- ARGV[3] = tokens per replenish period --- ARGV[4] = token limit --- ARGV[5] = replenish period (milliseconds) --- ARGV[6] = permit count --- ARGV[7] = current time (unix time milliseconds) --- Prepare the input and force the correct data types. -local limit = tonumber(ARGV[4]) -local rate = tonumber(ARGV[3]) -local period = tonumber(ARGV[5]) -local requested = tonumber(ARGV[6]) -local now = tonumber(ARGV[7]) - --- Load the current state from Redis. We use MGET to save a round-trip. -local state = redis.call('MGET', ARGV[1], ARGV[2]) +-- KEYS[1] = token count key +-- KEYS[2] = timestamp key +-- ARGV[1] = tokens per replenish period +-- ARGV[2] = token limit +-- ARGV[3] = replenish period (milliseconds) +-- ARGV[4] = permit count +-- ARGV[5] = current time (unix time milliseconds) +local limit = tonumber(ARGV[2]) +local rate = tonumber(ARGV[1]) +local period = tonumber(ARGV[3]) +local requested = tonumber(ARGV[4]) +local now = tonumber(ARGV[5]) + +local state = redis.call('MGET', KEYS[1], KEYS[2]) local current_tokens = tonumber(state[1]) or limit local last_refreshed = tonumber(state[2]) or 0 --- Calculate the time and replenishment periods elapsed since the last call. local time_since_last_refreshed = math.max(0, now - last_refreshed) local periods_since_last_refreshed = math.floor(time_since_last_refreshed / period) --- We are also able to calculate the time of the last replenishment, which we store and use --- to calculate the time after which a client may retry if they are rate limited. local time_of_last_replenishment = now if last_refreshed > 0 then time_of_last_replenishment = last_refreshed + (periods_since_last_refreshed * period) end --- Now we have all the info we need to calculate the current tokens based on the elapsed time. current_tokens = math.min(limit, current_tokens + (periods_since_last_refreshed * rate)) --- If the bucket contains enough tokens for the current request, we remove the tokens. local allowed = 0 local retry_after = 0 +local periods_until_full = math.ceil(limit / rate) +local ttl = math.ceil(periods_until_full * period) + if current_tokens >= requested then allowed = 1 current_tokens = current_tokens - requested +end --- In order to remove rate limit keys automatically from the database, we calculate a TTL --- based on the worst-case scenario for the bucket to fill up again. --- The worst case is when the bucket is empty and the last replenishment adds less tokens than available. - local periods_until_full = math.ceil(limit / rate) - local ttl = math.ceil(periods_until_full * period) - --- We only store the new state in the database if the request was granted. --- This avoids rounding issues and edge cases which can occur if many requests are rate limited. - redis.call('SET', ARGV[1], current_tokens, 'PXAT', ttl + now) - redis.call('SET', ARGV[2], time_of_last_replenishment, 'PXAT', ttl + now) -else --- Before we return, we can now also calculate when the client may retry again if they are rate limited. +redis.call('SET', KEYS[1], current_tokens, 'PXAT', ttl + now) +redis.call('SET', KEYS[2], time_of_last_replenishment, 'PXAT', ttl + now) + +if allowed == 0 then retry_after = period - (now - time_of_last_replenishment) end @@ -86,20 +75,15 @@ var evalScript = radix.NewEvalScript(script) var tracer = otel.Tracer("redis.fixedCacheImpl") type fixedRateLimitCacheImpl struct { - client Client - // Optional Client for a dedicated cache of per second limits. - // If this client is nil, then the Cache will use the client for all - // limits regardless of unit. If this client is not nil, then it - // is used for limits that have a SECOND unit. + client Client perSecondClient Client stopCacheKeyIncrementWhenOverlimit bool baseRateLimiter *limiter.BaseRateLimiter } func pipelineAppendScript(client Client, pipeline *Pipeline, key string, hitsAddend, tokenLimit, tokensPerReplenishPeriod uint32, replenishPeriod, currentTime int64, result *[]int64) { - *pipeline = client.PipeScriptAppend(*pipeline, result, evalScript, - key, - fmt.Sprintf("%s:expires", key), + keys := []string{fmt.Sprintf("{%s}", key), fmt.Sprintf("{%s}:expires", key)} + *pipeline = client.PipeScriptAppend(*pipeline, result, evalScript, keys, strconv.FormatInt(int64(tokensPerReplenishPeriod), 10), strconv.FormatInt(int64(tokenLimit), 10), strconv.FormatInt(replenishPeriod, 10), @@ -107,44 +91,43 @@ func pipelineAppendScript(client Client, pipeline *Pipeline, key string, hitsAdd strconv.FormatInt(currentTime, 10)) } -func pipelineAppendtoGet(client Client, pipeline *Pipeline, key string, result *uint32) { +func pipelineAppendtoGet(client Client, pipeline *Pipeline, key string, result *string) { *pipeline = client.PipeAppend(*pipeline, result, "GET", key) } func (this *fixedRateLimitCacheImpl) DoLimit( ctx context.Context, request *pb.RateLimitRequest, - limits []*config.RateLimit) []*pb.RateLimitResponse_DescriptorStatus { - + limits []*config.RateLimit, +) []*pb.RateLimitResponse_DescriptorStatus { logger.Debug(ctx, "starting cache lookup") - // request.HitsAddend could be 0 (default value) if not specified by the caller in the RateLimit request. - hitsAddend := utils.Max(1, request.HitsAddend) + hitsAddend := max(uint32(1), request.HitsAddend) - // First build a list of all cache keys that we are actually going to hit. - cacheKeys := this.baseRateLimiter.GenerateCacheKeys(request, limits, hitsAddend) + hitsAddends := make([]uint64, len(request.Descriptors)) + for i := range hitsAddends { + hitsAddends[i] = uint64(hitsAddend) + } + cacheKeys := this.baseRateLimiter.GenerateCacheKeys(request, limits, hitsAddends) isOverLimitWithLocalCache := make([]bool, len(request.Descriptors)) results := make([][]int64, len(request.Descriptors)) for i := range results { results[i] = make([]int64, 3) } - currentCount := make([]uint32, len(request.Descriptors)) + currentCount := make([]string, len(request.Descriptors)) var pipeline, perSecondPipeline, pipelineToGet, perSecondPipelineToGet Pipeline hitsAddendForRedis := hitsAddend overlimitIndexes := make([]bool, len(request.Descriptors)) - nearlimitIndexes := make([]bool, len(request.Descriptors)) isCacheKeyOverlimit := false if this.stopCacheKeyIncrementWhenOverlimit { - // Check if any of the keys are reaching to the over limit in redis cache. for i, cacheKey := range cacheKeys { if cacheKey.Key == "" { continue } - // Check if key is over the limit in local cache. if this.baseRateLimiter.IsOverLimitWithLocalCache(cacheKey.Key) { if limits[i].ShadowMode { logger.Debug(ctx, fmt.Sprintf("Cache key %s would be rate limited but shadow mode is enabled on this rule", cacheKey.Key)) @@ -161,18 +144,17 @@ func (this *fixedRateLimitCacheImpl) DoLimit( if perSecondPipelineToGet == nil { perSecondPipelineToGet = Pipeline{} } - pipelineAppendtoGet(this.perSecondClient, &perSecondPipelineToGet, cacheKey.Key, ¤tCount[i]) + pipelineAppendtoGet(this.perSecondClient, &perSecondPipelineToGet, fmt.Sprintf("{%s}", cacheKey.Key), ¤tCount[i]) } else { if pipelineToGet == nil { pipelineToGet = Pipeline{} } - pipelineAppendtoGet(this.client, &pipelineToGet, cacheKey.Key, ¤tCount[i]) + pipelineAppendtoGet(this.client, &pipelineToGet, fmt.Sprintf("{%s}", cacheKey.Key), ¤tCount[i]) } } } - // Only if none of the cache keys exceed the limit, call Redis to check whether the cache keys are becoming overlimited. - if len(cacheKeys) > 1 && !isCacheKeyOverlimit { + if !isCacheKeyOverlimit { if pipelineToGet != nil { checkError(this.client.PipeDo(ctx, pipelineToGet)) } @@ -184,27 +166,27 @@ func (this *fixedRateLimitCacheImpl) DoLimit( if cacheKey.Key == "" { continue } - // Now fetch the pipeline. - allowed := currentCount[i] >= hitsAddend - limitAfterIncrease := getLimitAfterIncrease(currentCount[i], limits[i].Limit.RequestsPerUnit, hitsAddend, allowed) - limitBeforeIncrease := limitAfterIncrease - hitsAddend - - limitInfo := limiter.NewRateLimitInfo(limits[i], limitBeforeIncrease, limitAfterIncrease, 0, 0) + // In token bucket mode: empty string means key doesn't exist (bucket is full). + // A value of "0" means the bucket is actually empty. + var tokensRemaining uint32 + if currentCount[i] == "" { + tokensRemaining = limits[i].Limit.RequestsPerUnit + } else { + parsed, _ := strconv.ParseUint(currentCount[i], 10, 32) + tokensRemaining = uint32(parsed) + } - if this.baseRateLimiter.IsOverLimitThresholdReached(limitInfo) { + if tokensRemaining < hitsAddend { hitsAddendForRedis = 0 - nearlimitIndexes[i] = true } } } } else { - // Check if any of the keys are reaching to the over limit in redis cache. for i, cacheKey := range cacheKeys { if cacheKey.Key == "" { continue } - // Check if key is over the limit in local cache. if this.baseRateLimiter.IsOverLimitWithLocalCache(cacheKey.Key) { if limits[i].ShadowMode { logger.Debug(ctx, fmt.Sprintf("Cache key %s would be rate limited but shadow mode is enabled on this rule", cacheKey.Key)) @@ -218,7 +200,6 @@ func (this *fixedRateLimitCacheImpl) DoLimit( } } - // Now, actually set up the pipeline, skipping empty cache keys. for i, cacheKey := range cacheKeys { if cacheKey.Key == "" || overlimitIndexes[i] { continue @@ -227,40 +208,30 @@ func (this *fixedRateLimitCacheImpl) DoLimit( logger.Debug(ctx, fmt.Sprintf("looking up cache key: %s", cacheKey.Key)) replenishPeriod := time.Duration(utils.UnitToDivider(limits[i].Limit.Unit) * int64(time.Second)).Milliseconds() - if replenishPeriod == 1000 { // adjusting the period for RPS since in practice the TTL expires later than expected leading to over-counting + // Compensate for network/processing overhead on sub-second limits + if replenishPeriod == 1000 { replenishPeriod = 775 } - unixTime := this.baseRateLimiter.TimeSource.UnixNow() + unixTime := this.baseRateLimiter.TimeSource.UnixNow() * 1000 - // Use the perSecondConn if it is not nil and the cacheKey represents a per second Limit. if this.perSecondClient != nil && cacheKey.PerSecond { if perSecondPipeline == nil { perSecondPipeline = Pipeline{} } - hitsAddendToUse := hitsAddendForRedis - if !nearlimitIndexes[i] { - hitsAddendToUse = hitsAddend - } - - pipelineAppendScript(this.perSecondClient, &perSecondPipeline, cacheKey.Key, hitsAddendToUse, limits[i].Limit.RequestsPerUnit, limits[i].Limit.RequestsPerUnit, replenishPeriod, unixTime, &results[i]) + pipelineAppendScript(this.perSecondClient, &perSecondPipeline, cacheKey.Key, hitsAddendForRedis, limits[i].Limit.RequestsPerUnit, limits[i].Limit.RequestsPerUnit, replenishPeriod, unixTime, &results[i]) } else { if pipeline == nil { pipeline = Pipeline{} } - hitsAddendToUse := hitsAddendForRedis - if !nearlimitIndexes[i] { - hitsAddendToUse = hitsAddend - } - - pipelineAppendScript(this.client, &pipeline, cacheKey.Key, hitsAddendToUse, limits[i].Limit.RequestsPerUnit, limits[i].Limit.RequestsPerUnit, replenishPeriod, unixTime, &results[i]) + pipelineAppendScript(this.client, &pipeline, cacheKey.Key, hitsAddendForRedis, limits[i].Limit.RequestsPerUnit, limits[i].Limit.RequestsPerUnit, replenishPeriod, unixTime, &results[i]) } } - // Generate trace - _, span := tracer.Start(ctx, "Redis Pipeline Execution", + _, span := tracer.Start( + ctx, "Redis Pipeline Execution", trace.WithAttributes( attribute.Int("pipeline length", len(pipeline)), attribute.Int("perSecondPipeline length", len(perSecondPipeline)), @@ -275,57 +246,59 @@ func (this *fixedRateLimitCacheImpl) DoLimit( checkError(this.perSecondClient.PipeDo(ctx, perSecondPipeline)) } - // Now fetch the pipeline. responseDescriptorStatuses := make([]*pb.RateLimitResponse_DescriptorStatus, len(request.Descriptors)) for i, cacheKey := range cacheKeys { - limitAfterIncrease := uint32(0) - limitBeforeIncrease := uint32(0) - if limits[i] != nil { - currentTokens := uint32(results[i][0]) - allowed := results[i][2] != 0 + if limits[i] == nil { + limitInfo := limiter.NewRateLimitInfo(nil, 0, 0, 0, 0) + responseDescriptorStatuses[i] = this.baseRateLimiter.GetResponseDescriptorStatus(ctx, cacheKey.Key, + limitInfo, isOverLimitWithLocalCache[i], uint64(hitsAddend)) + continue + } - limitAfterIncrease = getLimitAfterIncrease(currentTokens, limits[i].Limit.RequestsPerUnit, hitsAddend, allowed) - limitBeforeIncrease = limitAfterIncrease - hitsAddend + currentTokens := uint32(results[i][0]) + requestsPerUnit := limits[i].Limit.RequestsPerUnit - logger.Debug(ctx, fmt.Sprintf("pipeline result cache key %s current: %d", cacheKey.Key, limitAfterIncrease), logger.WithValue("redisKey", cacheKey.Key), logger.WithValue("redisCurrentTokens", currentTokens), - logger.WithValue("redisAllowed", allowed), logger.WithValue("redisRetryAfter", results[i][1]), logger.WithValue("redisLimitAfterIncrease", limitAfterIncrease)) + // When hitsAddendForRedis=0, the Lua script was a no-op probe. + // Determine actual allow/deny based on whether the bucket has enough tokens. + allowed := results[i][2] != 0 + if hitsAddendForRedis == 0 && !isOverLimitWithLocalCache[i] { + allowed = currentTokens >= hitsAddend } - limitInfo := limiter.NewRateLimitInfo(limits[i], limitBeforeIncrease, limitAfterIncrease, 0, 0) - - responseDescriptorStatuses[i] = this.baseRateLimiter.GetResponseDescriptorStatus(ctx, cacheKey.Key, - limitInfo, isOverLimitWithLocalCache[i], hitsAddend) + // Translate token bucket state to the counter model expected by base_limiter: + // limitAfterIncrease = total tokens consumed (requestsPerUnit - tokensRemaining) + // When limitAfterIncrease > requestsPerUnit, it's over limit. + var limitAfterIncrease uint64 + if allowed { + limitAfterIncrease = uint64(requestsPerUnit - currentTokens) + } else { + limitAfterIncrease = uint64(requestsPerUnit) + uint64(hitsAddend) + } - } + var limitBeforeIncrease uint64 + if limitAfterIncrease >= uint64(hitsAddend) { + limitBeforeIncrease = limitAfterIncrease - uint64(hitsAddend) + } - return responseDescriptorStatuses -} + logger.Debug(ctx, fmt.Sprintf("pipeline result cache key %s current: %d", cacheKey.Key, limitAfterIncrease), logger.WithValue("redisKey", cacheKey.Key), logger.WithValue("redisCurrentTokens", currentTokens), + logger.WithValue("redisAllowed", allowed), logger.WithValue("redisRetryAfter", results[i][1]), logger.WithValue("redisLimitAfterIncrease", limitAfterIncrease)) -func getLimitAfterIncrease(currentTokens, requestsPerUnit, hitsAddend uint32, allowed bool) uint32 { - limitAfterIncrease := uint32(0) + limitInfo := limiter.NewRateLimitInfo(limits[i], limitBeforeIncrease, limitAfterIncrease, 0, 0) - if currentTokens == 0 { - limitAfterIncrease = requestsPerUnit - if !allowed { - limitAfterIncrease = limitAfterIncrease + hitsAddend - } - } else { - limitAfterIncrease = hitsAddend + requestsPerUnit - currentTokens - if allowed { - limitAfterIncrease = limitAfterIncrease - 1 - } + responseDescriptorStatuses[i] = this.baseRateLimiter.GetResponseDescriptorStatus(ctx, cacheKey.Key, + limitInfo, isOverLimitWithLocalCache[i], uint64(hitsAddend)) } - return limitAfterIncrease + return responseDescriptorStatuses } -// Flush() is a no-op with redis since quota reads and updates happen synchronously. func (this *fixedRateLimitCacheImpl) Flush() {} func NewFixedRateLimitCacheImpl(client Client, perSecondClient Client, timeSource utils.TimeSource, jitterRand *rand.Rand, expirationJitterMaxSeconds int64, localCache *freecache.Cache, nearLimitRatio float32, cacheKeyPrefix string, statsManager stats.Manager, - stopCacheKeyIncrementWhenOverlimit bool) limiter.RateLimitCache { + stopCacheKeyIncrementWhenOverlimit bool, +) limiter.RateLimitCache { return &fixedRateLimitCacheImpl{ client: client, perSecondClient: perSecondClient, diff --git a/src/server/health.go b/src/server/health.go index 8a356fe55..452910d64 100644 --- a/src/server/health.go +++ b/src/server/health.go @@ -11,10 +11,10 @@ import ( "sync/atomic" "syscall" + logger "github.com/goatapp/ratelimit/src/log" + "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" - - logger "github.com/goatapp/ratelimit/src/log" ) type HealthChecker struct { @@ -50,7 +50,8 @@ func NewHealthChecker(grpcHealthServer *health.Server, name string, healthyWithA ret.healthMap = make(map[string]bool) // Store health states of components into map - ret.healthMap[RedisHealthComponentName] = true + // Redis starts unhealthy; it is marked healthy once the connection is confirmed at startup. + ret.healthMap[RedisHealthComponentName] = false if healthyWithAtLeastOneConfigLoad { // config starts in failed state since we need at least one config loaded to be healthy ret.healthMap[ConfigHealthComponentName] = false @@ -99,7 +100,6 @@ func (hc *HealthChecker) Fail(componentName string) error { } else { errorText := fmt.Sprintf("Invalid component: %s", componentName) logger.Error(context.Background(), errorText) - return errors.New(errorText) } return nil @@ -113,17 +113,15 @@ func (hc *HealthChecker) Ok(componentName string) error { // Set component to be healthy hc.healthMap[componentName] = true allComponentsHealthy := areAllComponentsHealthy(hc.healthMap) - + logger.Debug(context.Background(), fmt.Sprintf("Health status of components: %v, all healthy: %t", hc.healthMap, allComponentsHealthy)) if allComponentsHealthy { atomic.StoreUint32(&hc.ok, 1) hc.grpc.SetServingStatus(hc.name, healthpb.HealthCheckResponse_SERVING) } } else { errorText := fmt.Sprintf("Invalid component: %s", componentName) - err := errors.New(errorText) logger.Error(context.Background(), errorText) - - return err + return errors.New(errorText) } return nil diff --git a/src/server/server.go b/src/server/server.go index d770b26b8..2768594da 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" @@ -17,7 +18,7 @@ type Server interface { * all endpoints have been registered through 'AddHttpEndpoint' * and 'GrpcServer'. */ - Start() + Start(ctx context.Context) /** * Returns the root of the stats tree for the server diff --git a/src/server/server_impl.go b/src/server/server_impl.go index 92f047908..d0dbbe39c 100644 --- a/src/server/server_impl.go +++ b/src/server/server_impl.go @@ -8,12 +8,9 @@ import ( "net" "net/http" "net/http/pprof" - "os" - "os/signal" "sort" "strconv" "sync" - "syscall" "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" @@ -25,15 +22,15 @@ import ( "github.com/coocood/freecache" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "github.com/gorilla/mux" - reuseport "github.com/kavu/go_reuseport" - "github.com/lyft/goruntime/loader" + "github.com/libp2p/go-reuseport" gostats "github.com/lyft/gostats" "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" - "github.com/goatapp/ratelimit/src/limiter" logger "github.com/goatapp/ratelimit/src/log" + + "github.com/goatapp/ratelimit/src/limiter" "github.com/goatapp/ratelimit/src/settings" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" @@ -50,20 +47,28 @@ type serverDebugListener struct { listener net.Listener } +type grpcListenType int + +const ( + tcp grpcListenType = 0 + unixDomainSocket grpcListenType = 1 +) + type server struct { - httpAddress string - grpcAddress string - debugAddress string - router *mux.Router - grpcServer *grpc.Server - store gostats.Store - scope gostats.Scope - provider provider.RateLimitConfigProvider - runtime loader.IFace - debugListener serverDebugListener - httpServer *http.Server - listenerMu sync.Mutex - health *HealthChecker + httpAddress string + grpcAddress string + grpcListenType grpcListenType + debugAddress string + router *mux.Router + grpcServer *grpc.Server + store gostats.Store + scope gostats.Scope + provider provider.RateLimitConfigProvider + debugListener serverDebugListener + httpServer *http.Server + listenerMu sync.Mutex + health *HealthChecker + grpcCertProvider *provider.CertProvider } func (server *server) AddDebugHttpEndpoint(path string, help string, handler http.HandlerFunc) { @@ -85,42 +90,43 @@ func NewJsonHandler(svc pb.RateLimitServiceServer) func(http.ResponseWriter, *ht body, err := io.ReadAll(request.Body) if err != nil { - logger.Error(ctx, "", logger.WithError(err)) + logger.Warn(context.Background(), fmt.Sprintf("error: %s", err.Error())) writeHttpStatus(writer, http.StatusBadRequest) return } if err := protojson.Unmarshal(body, &req); err != nil { - logger.Error(ctx, "", logger.WithError(err)) + logger.Warn(context.Background(), fmt.Sprintf("error: %s", err.Error())) writeHttpStatus(writer, http.StatusBadRequest) return } resp, err := svc.ShouldRateLimit(ctx, &req) if err != nil { - logger.Error(ctx, "", logger.WithError(err)) + logger.Warn(context.Background(), fmt.Sprintf("error: %s", err.Error())) writeHttpStatus(writer, http.StatusBadRequest) return } // Generate trace - _, span := tracer.Start(ctx, "NewJsonHandler Remaining Execution", + _, span := tracer.Start( + ctx, "NewJsonHandler Remaining Execution", trace.WithAttributes( attribute.String("response", resp.String()), ), ) defer span.End() - logger.Debug(ctx, fmt.Sprintf("resp:%s", resp)) + logger.Debug(context.Background(), fmt.Sprintf("resp:%s", resp)) if resp == nil { - logger.Error(ctx, "nil response") + logger.Error(context.Background(), "nil response") writeHttpStatus(writer, http.StatusInternalServerError) return } jsonResp, err := protojson.Marshal(resp) if err != nil { - logger.Error(ctx, "error marshaling proto3 to json", logger.WithError(err)) + logger.Error(context.Background(), fmt.Sprintf("error marshaling proto3 to json: %s", err.Error())) writeHttpStatus(writer, http.StatusInternalServerError) return } @@ -159,17 +165,28 @@ func (server *server) GrpcServer() *grpc.Server { return server.grpcServer } -func (server *server) Start() { - server.startGrpc() +func (server *server) Start(ctx context.Context) { + go server.startGrpc() - server.handleGracefulShutdown() + server.handleGracefulShutdown(ctx) } func (server *server) startGrpc() { logger.Warn(context.Background(), fmt.Sprintf("Listening for gRPC on '%s'", server.grpcAddress)) - lis, err := reuseport.Listen("tcp", server.grpcAddress) + var lis net.Listener + var err error + + switch server.grpcListenType { + case tcp: + lis, err = reuseport.Listen("tcp", server.grpcAddress) + case unixDomainSocket: + lis, err = net.Listen("unix", server.grpcAddress) + default: + logger.Fatal(context.Background(), fmt.Sprintf("Invalid gRPC listen type %v", server.grpcListenType)) + } + if err != nil { - logger.Fatal(context.Background(), fmt.Sprintf("Failed to listen for gRPC: %v", err)) + logger.Fatal(context.Background(), fmt.Sprintf("Failed to listen for gRPC on '%s': %v", server.grpcAddress, err)) } server.grpcServer.Serve(lis) } @@ -193,6 +210,14 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc ret := new(server) + // setup stats + ret.store = statsManager.GetStatsStore() + ret.scope = ret.store.ScopeWithTags(name, s.ExtraTags) + ret.store.AddStatGenerator(gostats.NewRuntimeStats(ret.scope.Scope("go"))) + if localCache != nil { + ret.store.AddStatGenerator(limiter.NewLocalCacheStats(localCache, ret.scope.Scope("localcache"))) + } + keepaliveOpt := grpc.KeepaliveParams(keepalive.ServerParameters{ MaxConnectionAge: s.GrpcMaxConnectionAge, MaxConnectionAgeGrace: s.GrpcMaxConnectionAgeGrace, @@ -207,6 +232,10 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc } if s.GrpcServerUseTLS { grpcServerTlsConfig := s.GrpcServerTlsConfig + ret.grpcCertProvider = provider.NewCertProvider(s, ret.store, s.GrpcServerTlsCert, s.GrpcServerTlsKey) + // Remove the static certificates and use the provider via the GetCertificate function + grpcServerTlsConfig.Certificates = nil + grpcServerTlsConfig.GetCertificate = ret.grpcCertProvider.GetCertificateFunc() // Verify client SAN if provided if s.GrpcClientTlsSAN != "" { grpcServerTlsConfig.VerifyPeerCertificate = verifyClient(grpcServerTlsConfig.ClientCAs, s.GrpcClientTlsSAN) @@ -217,15 +246,14 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc // setup listen addresses ret.httpAddress = net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) - ret.grpcAddress = net.JoinHostPort(s.GrpcHost, strconv.Itoa(s.GrpcPort)) - ret.debugAddress = net.JoinHostPort(s.DebugHost, strconv.Itoa(s.DebugPort)) - - // setup stats - ret.store = statsManager.GetStatsStore() - ret.scope = ret.store.ScopeWithTags(stats.GetStatsScope(), s.ExtraTags) - if localCache != nil { - ret.store.AddStatGenerator(limiter.NewLocalCacheStats(localCache, ret.scope.Scope("localcache"))) + if s.GrpcUds != "" { + ret.grpcAddress = s.GrpcUds + ret.grpcListenType = unixDomainSocket + } else { + ret.grpcAddress = net.JoinHostPort(s.GrpcHost, strconv.Itoa(s.GrpcPort)) + ret.grpcListenType = tcp } + ret.debugAddress = net.JoinHostPort(s.DebugHost, strconv.Itoa(s.DebugPort)) // setup config provider ret.provider = getProviderImpl(s, statsManager, ret.store) @@ -246,7 +274,8 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc "root of various pprof endpoints. hit for help.", func(writer http.ResponseWriter, request *http.Request) { pprof.Index(writer, request) - }) + }, + ) // setup cpu profiling endpoint ret.AddDebugHttpEndpoint( @@ -254,7 +283,8 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc "CPU profiling endpoint", func(writer http.ResponseWriter, request *http.Request) { pprof.Profile(writer, request) - }) + }, + ) // setup stats endpoint ret.AddDebugHttpEndpoint( @@ -264,7 +294,8 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc expvar.Do(func(kv expvar.KeyValue) { io.WriteString(writer, fmt.Sprintf("%s: %s\n", kv.Key, kv.Value)) }) - }) + }, + ) // setup trace endpoint ret.AddDebugHttpEndpoint( @@ -272,7 +303,8 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc "trace endpoint", func(writer http.ResponseWriter, request *http.Request) { pprof.Trace(writer, request) - }) + }, + ) // setup debug root ret.debugListener.debugMux.HandleFunc( @@ -286,9 +318,11 @@ func newServer(s settings.Settings, name string, statsManager stats.Manager, loc sort.Strings(sortedKeys) for _, key := range sortedKeys { io.WriteString( - writer, fmt.Sprintf("%s: %s\n", key, ret.debugListener.endpoints[key])) + writer, fmt.Sprintf("%s: %s\n", key, ret.debugListener.endpoints[key]), + ) } - }) + }, + ) return ret } @@ -306,16 +340,11 @@ func (server *server) Stop() { server.provider.Stop() } -func (server *server) handleGracefulShutdown() { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - +func (server *server) handleGracefulShutdown(ctx context.Context) { go func() { - sig := <-sigs - - logger.Info(context.Background(), fmt.Sprintf("Ratelimit server received %v, shutting down gracefully", sig)) + <-ctx.Done() + logger.Info(context.Background(), "Context cancelled, stopping server") server.Stop() - os.Exit(0) }() } diff --git a/src/service/ratelimit.go b/src/service/ratelimit.go index 79ba9ef5b..e16cc67b8 100644 --- a/src/service/ratelimit.go +++ b/src/service/ratelimit.go @@ -10,6 +10,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/structpb" "github.com/goatapp/ratelimit/src/settings" "github.com/goatapp/ratelimit/src/stats" @@ -17,13 +18,15 @@ import ( "github.com/goatapp/ratelimit/src/utils" core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" "golang.org/x/net/context" + logger "github.com/goatapp/ratelimit/src/log" + "github.com/goatapp/ratelimit/src/assert" "github.com/goatapp/ratelimit/src/config" "github.com/goatapp/ratelimit/src/limiter" - logger "github.com/goatapp/ratelimit/src/log" "github.com/goatapp/ratelimit/src/provider" "github.com/goatapp/ratelimit/src/redis" "github.com/goatapp/ratelimit/src/server" @@ -33,23 +36,25 @@ var tracer = otel.Tracer("ratelimit") type RateLimitServiceServer interface { pb.RateLimitServiceServer - GetCurrentConfig() (config.RateLimitConfig, bool) + GetCurrentConfig() (config.RateLimitConfig, bool, bool) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWithAtLeastOneConfigLoad bool) } type service struct { - configLock sync.RWMutex - configUpdateEvent <-chan provider.ConfigUpdateEvent - config config.RateLimitConfig - cache limiter.RateLimitCache - stats stats.ServiceStats - health *server.HealthChecker - customHeadersEnabled bool - customHeaderLimitHeader string - customHeaderRemainingHeader string - customHeaderResetHeader string - customHeaderClock utils.TimeSource - globalShadowMode bool + configLock sync.RWMutex + configUpdateEvent <-chan provider.ConfigUpdateEvent + config config.RateLimitConfig + cache limiter.RateLimitCache + stats stats.ServiceStats + health *server.HealthChecker + customHeadersEnabled bool + customHeaderLimitHeader string + customHeaderRemainingHeader string + customHeaderResetHeader string + customHeaderClock utils.TimeSource + globalShadowMode bool + globalQuotaMode bool + responseDynamicMetadataEnabled bool } func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWithAtLeastOneConfigLoad bool) { @@ -84,6 +89,8 @@ func (this *service) SetConfig(updateEvent provider.ConfigUpdateEvent, healthyWi rlSettings := settings.NewSettings() this.globalShadowMode = rlSettings.GlobalShadowMode + this.globalQuotaMode = rlSettings.GlobalQuotaMode + this.responseDynamicMetadataEnabled = rlSettings.ResponseDynamicMetadata if rlSettings.RateLimitResponseHeadersEnabled { this.customHeadersEnabled = true @@ -119,28 +126,32 @@ func (this *service) constructLimitsToCheck(request *pb.RateLimitRequest, ctx co replacing := make(map[string]bool) for i, descriptor := range request.Descriptors { - var descriptorEntryStrings []string - for _, descriptorEntry := range descriptor.GetEntries() { - descriptorEntryStrings = append( - descriptorEntryStrings, - fmt.Sprintf("(%s=%s)", descriptorEntry.Key, descriptorEntry.Value), - ) + if true { + var descriptorEntryStrings []string + for _, descriptorEntry := range descriptor.GetEntries() { + descriptorEntryStrings = append( + descriptorEntryStrings, + fmt.Sprintf("(%s=%s)", descriptorEntry.Key, descriptorEntry.Value), + ) + } + logger.Debug(context.Background(), fmt.Sprintf("got descriptor: %s", strings.Join(descriptorEntryStrings, ","))) } - logger.Debug(ctx, fmt.Sprintf("got descriptor: %s", strings.Join(descriptorEntryStrings, ","))) - limitsToCheck[i] = snappedConfig.GetLimit(ctx, request.Domain, descriptor) - if limitsToCheck[i] == nil { - logger.Debug(ctx, "descriptor does not match any limit, no limits applied") - } else { - if limitsToCheck[i].Unlimited { - logger.Debug(ctx, "descriptor is unlimited, not passing to the cache") + if true { + if limitsToCheck[i] == nil { + logger.Debug(context.Background(), "descriptor does not match any limit, no limits applied") } else { - logger.Debug(ctx, - fmt.Sprintf("applying limit: %d requests per %s, shadow_mode: %t", + if limitsToCheck[i].Unlimited { + logger.Debug(context.Background(), "descriptor is unlimited, not passing to the cache") + } else { + logger.Debug(context.Background(), fmt.Sprintf( + "applying limit: %d requests per %s, shadow_mode: %t, quota: %t", limitsToCheck[i].Limit.RequestsPerUnit, limitsToCheck[i].Limit.Unit.String(), limitsToCheck[i].ShadowMode, + limitsToCheck[i].QuotaMode, )) + } } } @@ -163,7 +174,9 @@ func (this *service) constructLimitsToCheck(request *pb.RateLimitRequest, ctx co _, exists := replacing[limit.Name] if exists { limitsToCheck[i] = nil - logger.Debug(ctx, fmt.Sprintf("replacing %s", limit.Name)) + if true { + logger.Debug(context.Background(), fmt.Sprintf("replacing %s", limit.Name)) + } } } return limitsToCheck, isUnlimited @@ -172,25 +185,34 @@ func (this *service) constructLimitsToCheck(request *pb.RateLimitRequest, ctx co const MaxUint32 = uint32(1<<32 - 1) func (this *service) shouldRateLimitWorker( - ctx context.Context, request *pb.RateLimitRequest) *pb.RateLimitResponse { - + ctx context.Context, request *pb.RateLimitRequest, +) *pb.RateLimitResponse { checkServiceErr(request.Domain != "", "rate limit domain must not be empty") checkServiceErr(len(request.Descriptors) != 0, "rate limit descriptor list must not be empty") - snappedConfig, globalShadowMode := this.GetCurrentConfig() + snappedConfig, globalShadowMode, globalQuotaMode := this.GetCurrentConfig() limitsToCheck, isUnlimited := this.constructLimitsToCheck(request, ctx, snappedConfig) + assert.Assert(len(limitsToCheck) == len(isUnlimited)) + assert.Assert(len(limitsToCheck) == len(request.Descriptors)) + responseDescriptorStatuses := this.cache.DoLimit(ctx, request, limitsToCheck) + logger.Debug(context.Background(), fmt.Sprintf("descriptor statuses: %+v", responseDescriptorStatuses)) assert.Assert(len(limitsToCheck) == len(responseDescriptorStatuses)) response := &pb.RateLimitResponse{} response.Statuses = make([]*pb.RateLimitResponse_DescriptorStatus, len(request.Descriptors)) - finalCode := pb.RateLimitResponse_OK // Keep track of the descriptor which is closest to hit the ratelimit minLimitRemaining := MaxUint32 var minimumDescriptor *pb.RateLimitResponse_DescriptorStatus = nil + // Track quota mode violations for metadata + var passedDescriptors []int + failedRateLimitDescriptors := 0 + failedQuotaDescriptors := 0 + totalQuotaDescriptors := 0 + for i, descriptorStatus := range responseDescriptorStatuses { // Keep track of the descriptor closest to hit the ratelimit if this.customHeadersEnabled && @@ -207,15 +229,32 @@ func (this *service) shouldRateLimitWorker( } } else { response.Statuses[i] = descriptorStatus + isQuotaMode := globalQuotaMode || (limitsToCheck[i] != nil && limitsToCheck[i].QuotaMode) if descriptorStatus.Code == pb.RateLimitResponse_OVER_LIMIT { - finalCode = descriptorStatus.Code - - minimumDescriptor = descriptorStatus - minLimitRemaining = 0 + if isQuotaMode { + failedQuotaDescriptors += 1 + } else { + failedRateLimitDescriptors += 1 + minimumDescriptor = descriptorStatus + minLimitRemaining = 0 + } + } else { + // Keep track of the descriptors that have passed + passedDescriptors = append(passedDescriptors, i) + } + if isQuotaMode { + totalQuotaDescriptors += 1 } } } + finalCode := pb.RateLimitResponse_OK + // The final code is OVER_LIMIT iff at least one rate limit descriptor is over the limit + // or all quota descriptors are over the limit. + if failedRateLimitDescriptors > 0 || (totalQuotaDescriptors > 0 && totalQuotaDescriptors == failedQuotaDescriptors) { + finalCode = pb.RateLimitResponse_OVER_LIMIT + } + // Add Headers if requested if this.customHeadersEnabled && minimumDescriptor != nil { response.ResponseHeadersToAdd = []*core.HeaderValue{ @@ -231,10 +270,108 @@ func (this *service) shouldRateLimitWorker( this.stats.GlobalShadowMode.Inc() } + // If response dynamic data enabled, set dynamic data on response. + if this.responseDynamicMetadataEnabled { + response.DynamicMetadata = ratelimitToMetadata(request, passedDescriptors, limitsToCheck) + } + response.OverallCode = finalCode return response } +func ratelimitToMetadata(req *pb.RateLimitRequest, passedDescriptors []int, limitsToCheck []*config.RateLimit) *structpb.Struct { + fields := make(map[string]*structpb.Value) + + // Domain + fields["domain"] = structpb.NewStringValue(req.Domain) + + // Descriptors + descriptorsValues := make([]*structpb.Value, 0, len(req.Descriptors)) + for _, descriptor := range req.Descriptors { + s := descriptorToStruct(descriptor) + if s == nil { + continue + } + descriptorsValues = append(descriptorsValues, structpb.NewStructValue(s)) + } + fields["descriptors"] = structpb.NewListValue(&structpb.ListValue{ + Values: descriptorsValues, + }) + + // HitsAddend + if hitsAddend := req.GetHitsAddend(); hitsAddend != 0 { + fields["hitsAddend"] = structpb.NewNumberValue(float64(hitsAddend)) + } + + passedMetadata := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + for _, idx := range passedDescriptors { + if idx < len(limitsToCheck) { + limit := limitsToCheck[idx] + if limit != nil && limit.Metadata != nil { + mergeMetadata(passedMetadata, limit.Metadata) + } + } + } + + if len(passedMetadata.GetFields()) > 0 { + fields["metadata"] = structpb.NewStructValue(passedMetadata) + } + + return &structpb.Struct{Fields: fields} +} + +func descriptorToStruct(descriptor *ratelimitv3.RateLimitDescriptor) *structpb.Struct { + if descriptor == nil { + return nil + } + + fields := make(map[string]*structpb.Value) + + // Entries + entriesValues := make([]*structpb.Value, 0, len(descriptor.Entries)) + for _, entry := range descriptor.Entries { + val := fmt.Sprintf("%s=%s", entry.GetKey(), entry.GetValue()) + entriesValues = append(entriesValues, structpb.NewStringValue(val)) + } + fields["entries"] = structpb.NewListValue(&structpb.ListValue{ + Values: entriesValues, + }) + + // Limit + if descriptor.GetLimit() != nil { + fields["limit"] = structpb.NewStringValue(descriptor.Limit.String()) + } + + // HitsAddend + if hitsAddend := descriptor.GetHitsAddend(); hitsAddend != nil { + fields["hitsAddend"] = structpb.NewNumberValue(float64(hitsAddend.GetValue())) + } + + return &structpb.Struct{Fields: fields} +} + +func mergeMetadata(dest *structpb.Struct, src *structpb.Struct) { + if src == nil { + return + } + for k, v := range src.GetFields() { + destVal, exists := dest.GetFields()[k] + if exists { + // If both are structs, merge them recursively + if destStruct := destVal.GetStructValue(); destStruct != nil { + if srcStruct := v.GetStructValue(); srcStruct != nil { + mergeMetadata(destStruct, srcStruct) + continue + } + } + // TODO(yanavlasov): add option to overwrite or add if type is a list + } else { + // Otherwise overwrite or add + dest.GetFields()[k] = v + } + } +} + func (this *service) rateLimitLimitHeader(descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { // Limit header only provides the mandatory part from the spec, the actual limit // the optional quota policy is currently not provided @@ -253,8 +390,8 @@ func (this *service) rateLimitRemainingHeader(descriptor *pb.RateLimitResponse_D } func (this *service) rateLimitResetHeader( - descriptor *pb.RateLimitResponse_DescriptorStatus) *core.HeaderValue { - + descriptor *pb.RateLimitResponse_DescriptorStatus, +) *core.HeaderValue { return &core.HeaderValue{ Key: this.customHeaderResetHeader, Value: strconv.FormatInt(utils.CalculateReset(&descriptor.CurrentLimit.Unit, this.customHeaderClock).GetSeconds(), 10), @@ -263,10 +400,12 @@ func (this *service) rateLimitResetHeader( func (this *service) ShouldRateLimit( ctx context.Context, - request *pb.RateLimitRequest) (finalResponse *pb.RateLimitResponse, finalError error) { - + request *pb.RateLimitRequest, +) (finalResponse *pb.RateLimitResponse, finalError error) { + logger.Debug(context.Background(), fmt.Sprintf("ShouldRateLimit: %+v", request)) // Generate trace - _, span := tracer.Start(ctx, "ShouldRateLimit Execution", + _, span := tracer.Start( + ctx, "ShouldRateLimit Execution", trace.WithAttributes( attribute.String("domain", request.Domain), attribute.String("request string", request.String()), @@ -280,7 +419,8 @@ func (this *service) ShouldRateLimit( return } - logger.Debug(ctx, "caught error during call") + logger.Debug(context.Background(), fmt.Sprintf("caught error during call: %v", err)) + finalResponse = nil switch t := err.(type) { case redis.RedisError: @@ -299,20 +439,20 @@ func (this *service) ShouldRateLimit( }() response := this.shouldRateLimitWorker(ctx, request) - logger.Debug(ctx, "returning normal response") + logger.Debug(context.Background(), fmt.Sprintf("returning normal response: %+v", response)) return response, nil } -func (this *service) GetCurrentConfig() (config.RateLimitConfig, bool) { +func (this *service) GetCurrentConfig() (config.RateLimitConfig, bool, bool) { this.configLock.RLock() defer this.configLock.RUnlock() - return this.config, this.globalShadowMode + return this.config, this.globalShadowMode, this.globalQuotaMode } func NewService(cache limiter.RateLimitCache, configProvider provider.RateLimitConfigProvider, statsManager stats.Manager, - health *server.HealthChecker, clock utils.TimeSource, shadowMode, forceStart bool, healthyWithAtLeastOneConfigLoad bool) RateLimitServiceServer { - + health *server.HealthChecker, clock utils.TimeSource, shadowMode, forceStart bool, healthyWithAtLeastOneConfigLoad bool, +) RateLimitServiceServer { newService := &service{ configLock: sync.RWMutex{}, configUpdateEvent: configProvider.ConfigUpdateEvent(), @@ -321,6 +461,7 @@ func NewService(cache limiter.RateLimitCache, configProvider provider.RateLimitC stats: statsManager.NewServiceStats(), health: health, globalShadowMode: shadowMode, + globalQuotaMode: false, customHeaderClock: clock, } diff --git a/src/service/ratelimit_test.go b/src/service/ratelimit_test.go new file mode 100644 index 000000000..ab1c0a72e --- /dev/null +++ b/src/service/ratelimit_test.go @@ -0,0 +1,223 @@ +package ratelimit + +import ( + "testing" + + ratelimitv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" + pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/goatapp/ratelimit/src/config" +) + +func TestRatelimitToMetadata(t *testing.T) { + cases := []struct { + name string + req *pb.RateLimitRequest + passedDescriptors []int + limitsToCheck []*config.RateLimit + expected string + }{ + { + name: "Single descriptor with single entry, no quota violations", + req: &pb.RateLimitRequest{ + Domain: "fake-domain", + Descriptors: []*ratelimitv3.RateLimitDescriptor{ + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "key1", + Value: "val1", + }, + }, + }, + }, + }, + passedDescriptors: nil, + limitsToCheck: []*config.RateLimit{nil}, + expected: `{ + "descriptors": [ + { + "entries": [ + "key1=val1" + ] + } + ], + "domain": "fake-domain" +}`, + }, + { + name: "Single descriptor with quota mode violation", + req: &pb.RateLimitRequest{ + Domain: "quota-domain", + Descriptors: []*ratelimitv3.RateLimitDescriptor{ + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "quota_key", + Value: "quota_val", + }, + }, + }, + }, + }, + passedDescriptors: []int{0}, + limitsToCheck: []*config.RateLimit{ + { + QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_1"), + }, + }, + }, + }, + expected: `{ + "descriptors": [ + { + "entries": [ + "quota_key=quota_val" + ] + } + ], + "domain": "quota-domain", + "metadata": { + "name": "service_1" + } +}`, + }, + { + name: "Multiple descriptors with mixed quota violations", + req: &pb.RateLimitRequest{ + Domain: "mixed-domain", + Descriptors: []*ratelimitv3.RateLimitDescriptor{ + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "regular_key", + Value: "regular_val", + }, + }, + }, + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "quota_key", + Value: "quota_val", + }, + }, + }, + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "another_quota", + Value: "another_val", + }, + }, + }, + }, + }, + passedDescriptors: []int{1, 2}, + limitsToCheck: []*config.RateLimit{ + { + QuotaMode: false, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_1"), + }, + }, + }, + { + QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_2"), + }, + }, + }, + { + QuotaMode: true, + Metadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": structpb.NewStringValue("service_3"), + }, + }, + }, + }, + expected: `{ + "descriptors": [ + { + "entries": [ + "regular_key=regular_val" + ] + }, + { + "entries": [ + "quota_key=quota_val" + ] + }, + { + "entries": [ + "another_quota=another_val" + ] + } + ], + "domain": "mixed-domain", + "metadata": { + "name": "service_2" + } +}`, + }, + { + name: "Request with hits addend", + req: &pb.RateLimitRequest{ + Domain: "addend-domain", + HitsAddend: 5, + Descriptors: []*ratelimitv3.RateLimitDescriptor{ + { + Entries: []*ratelimitv3.RateLimitDescriptor_Entry{ + { + Key: "test_key", + Value: "test_val", + }, + }, + }, + }, + }, + passedDescriptors: []int{0}, + limitsToCheck: []*config.RateLimit{ + { + QuotaMode: true, + }, + }, + expected: `{ + "descriptors": [ + { + "entries": [ + "test_key=test_val" + ] + } + ], + "domain": "addend-domain", + "hitsAddend": 5 +}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ratelimitToMetadata(tc.req, tc.passedDescriptors, tc.limitsToCheck) + expected := &structpb.Struct{} + err := protojson.Unmarshal([]byte(tc.expected), expected) + require.NoError(t, err) + + if diff := cmp.Diff(got, expected, protocmp.Transform()); diff != "" { + t.Errorf("diff: %s", diff) + } + }) + } +} diff --git a/src/service_cmd/runner/runner.go b/src/service_cmd/runner/runner.go index 992a9a1b7..a2d58044b 100644 --- a/src/service_cmd/runner/runner.go +++ b/src/service_cmd/runner/runner.go @@ -6,43 +6,92 @@ import ( "io" "math/rand" "net/http" + "os" + "os/signal" "sync" + "syscall" "time" - "github.com/goatapp/ratelimit/src/metrics" - "github.com/goatapp/ratelimit/src/stats" - "github.com/goatapp/ratelimit/src/trace" - - gostats "github.com/lyft/gostats" - "google.golang.org/grpc/reflection" - "github.com/coocood/freecache" - pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" + gostats "github.com/lyft/gostats" - "github.com/goatapp/ratelimit/src/limiter" logger "github.com/goatapp/ratelimit/src/log" + + "github.com/goatapp/ratelimit/src/godogstats" + "github.com/goatapp/ratelimit/src/limiter" "github.com/goatapp/ratelimit/src/memcached" + "github.com/goatapp/ratelimit/src/metrics" "github.com/goatapp/ratelimit/src/redis" "github.com/goatapp/ratelimit/src/server" ratelimit "github.com/goatapp/ratelimit/src/service" "github.com/goatapp/ratelimit/src/settings" + "github.com/goatapp/ratelimit/src/stats" + "github.com/goatapp/ratelimit/src/stats/prom" + "github.com/goatapp/ratelimit/src/trace" "github.com/goatapp/ratelimit/src/utils" ) type Runner struct { - name string - statsManager stats.Manager - settings settings.Settings - srv server.Server - mu sync.Mutex + name string + statsManager stats.Manager + settings settings.Settings + srv server.Server + mu sync.Mutex + ratelimitCloser io.Closer + cancel context.CancelFunc + done chan struct{} } func NewRunner(name string, s settings.Settings) Runner { + var store gostats.Store + + switch { + case s.DisableStats: + logger.Info(context.Background(), "Stats disabled") + store = gostats.NewStore(gostats.NewNullSink(), false) + case s.UseDogStatsd: + if s.UseStatsd || s.UsePrometheus { + logger.Fatal(context.Background(), "Error: unable to use more than one stats sink at the same time. Set one of USE_DOG_STATSD, USE_STATSD, USE_PROMETHEUS.") + } + sink, err := godogstats.NewSink( + godogstats.WithStatsdHost(s.StatsdHost), + godogstats.WithStatsdPort(s.StatsdPort), + godogstats.WithMogrifierFromEnv(s.UseDogStatsdMogrifiers), + ) + if err != nil { + logger.Fatal(context.Background(), fmt.Sprintf("Failed to create dogstatsd sink: %v", err)) + } + logger.Info(context.Background(), "Stats initialized for dogstatsd") + store = gostats.NewStore(sink, false) + case s.UseStatsd: + if s.UseDogStatsd || s.UsePrometheus { + logger.Fatal(context.Background(), "Error: unable to use more than one stats sink at the same time. Set one of USE_DOG_STATSD, USE_STATSD, USE_PROMETHEUS.") + } + logger.Info(context.Background(), "Stats initialized for statsd") + store = gostats.NewStore(gostats.NewTCPStatsdSink(gostats.WithStatsdHost(s.StatsdHost), gostats.WithStatsdPort(s.StatsdPort)), false) + case s.UsePrometheus: + if s.UseDogStatsd || s.UseStatsd { + logger.Fatal(context.Background(), "Error: unable to use more than one stats sink at the same time. Set one of USE_DOG_STATSD, USE_STATSD, USE_PROMETHEUS.") + } + logger.Info(context.Background(), "Stats initialized for Prometheus") + store = gostats.NewStore(prom.NewPrometheusSink(prom.WithAddr(s.PrometheusAddr), + prom.WithPath(s.PrometheusPath), prom.WithMapperYamlPath(s.PrometheusMapperYaml), + prom.WithResponseTimeAsMilliseconds(s.PrometheusResponseTimeAsMilliseconds)), false) + default: + logger.Info(context.Background(), "Stats initialized for stdout") + store = gostats.NewStore(gostats.NewLoggingSink(), false) + } + + logger.Info(context.Background(), fmt.Sprintf("Stats flush interval: %s", s.StatsFlushInterval)) + + go store.Start(time.NewTicker(s.StatsFlushInterval)) + return Runner{ name: name, - statsManager: stats.NewStatManager(gostats.NewDefaultStore(), s), + statsManager: stats.NewStatManager(store, s), settings: s, + done: make(chan struct{}), } } @@ -50,7 +99,7 @@ func (runner *Runner) GetStatsStore() gostats.Store { return runner.statsManager.GetStatsStore() } -func createLimiter(ctx context.Context, srv server.Server, s settings.Settings, localCache *freecache.Cache, statsManager stats.Manager) limiter.RateLimitCache { +func createLimiter(ctx context.Context, srv server.Server, s settings.Settings, localCache *freecache.Cache, statsManager stats.Manager) (limiter.RateLimitCache, io.Closer) { switch s.BackendType { case "redis", "": return redis.NewRateLimiterCacheImplFromSettings( @@ -70,20 +119,43 @@ func createLimiter(ctx context.Context, srv server.Server, s settings.Settings, rand.New(utils.NewLockedSource(time.Now().Unix())), localCache, srv.Scope(), - statsManager) + statsManager, + ), &utils.MultiCloser{} // memcache client can't be closed default: - logger.Fatal(ctx, fmt.Sprintf("Invalid setting for BackendType: %s", s.BackendType)) + logger.Fatal(context.Background(), fmt.Sprintf("Invalid setting for BackendType: %s", s.BackendType)) panic("This line should not be reachable") } } func (runner *Runner) Run() { + defer close(runner.done) + + ctx, cancel := context.WithCancel(context.Background()) + runner.mu.Lock() + runner.cancel = cancel + runner.mu.Unlock() + defer cancel() + + // Set up signal handling for graceful shutdown + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + select { + case sig := <-sigs: + logger.Info(context.Background(), fmt.Sprintf("Received signal %v, initiating shutdown", sig)) + cancel() + case <-ctx.Done(): + } + }() + s := runner.settings if s.TracingEnabled { tp := trace.InitProductionTraceProvider(s.TracingExporterProtocol, s.TracingServiceName, s.TracingServiceNamespace, s.TracingServiceInstanceId, s.TracingSamplingRate) defer func() { - if err := tp.Shutdown(context.Background()); err != nil { - logger.Error(context.Background(), "Error shutting down tracer provider", logger.WithError(err)) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := tp.Shutdown(shutdownCtx); err != nil { + logger.Error(context.Background(), fmt.Sprintf("Error shutting down tracer provider: %v", err)) } }() } else { @@ -102,8 +174,16 @@ func (runner *Runner) Run() { runner.srv = srv runner.mu.Unlock() + limiter, limiterCloser := createLimiter(ctx, srv, s, localCache, runner.statsManager) + runner.ratelimitCloser = limiterCloser + defer func() { + if err := limiterCloser.Close(); err != nil { + logger.Error(context.Background(), fmt.Sprintf("Error closing rate limiter resources: %v", err)) + } + }() + service := ratelimit.NewService( - createLimiter(context.Background(), srv, s, localCache, runner.statsManager), + limiter, srv.Provider(), runner.statsManager, srv.HealthChecker(), @@ -117,10 +197,11 @@ func (runner *Runner) Run() { "/rlconfig", "print out the currently loaded configuration for debugging", func(writer http.ResponseWriter, request *http.Request) { - if current, _ := service.GetCurrentConfig(); current != nil { + if current, _, _ := service.GetCurrentConfig(); current != nil { io.WriteString(writer, current.Dump()) } - }) + }, + ) srv.AddJsonHandler(service) @@ -129,17 +210,17 @@ func (runner *Runner) Run() { // v2 proto is no longer supported pb.RegisterRateLimitServiceServer(srv.GrpcServer(), service) - // allows grpc clients to discover the definition of the server without having the protos - reflection.Register(srv.GrpcServer()) + srv.Start(ctx) - srv.Start() + <-ctx.Done() } func (runner *Runner) Stop() { runner.mu.Lock() - srv := runner.srv + cancel := runner.cancel runner.mu.Unlock() - if srv != nil { - srv.Stop() + if cancel != nil { + cancel() } + <-runner.done } diff --git a/src/settings/settings.go b/src/settings/settings.go index 36c672f8d..b00a74ddb 100644 --- a/src/settings/settings.go +++ b/src/settings/settings.go @@ -21,6 +21,9 @@ type Settings struct { DebugPort int `envconfig:"DEBUG_PORT" default:"6070"` // GRPC server settings + // If GrpcUds is set we'll listen on the specified unix domain socket address + // rather then GrpcHost:GrpcPort. e.g. GrpcUds=/tmp/ratelimit.sock + GrpcUds string `envconfig:"GRPC_UDS" default:""` GrpcHost string `envconfig:"GRPC_HOST" default:"0.0.0.0"` GrpcPort int `envconfig:"GRPC_PORT" default:"8081"` // GrpcServerTlsConfig configures grpc for the server @@ -42,6 +45,9 @@ type Settings struct { GrpcClientTlsCACert string `envconfig:"GRPC_CLIENT_TLS_CACERT" default:""` // GrpcClientTlsSAN is the SAN to validate from the client cert during mTLS auth GrpcClientTlsSAN string `envconfig:"GRPC_CLIENT_TLS_SAN" default:""` + // Logging settings + LogLevel string `envconfig:"LOG_LEVEL" default:"WARN"` + LogFormat string `envconfig:"LOG_FORMAT" default:"text"` // Rate limit configuration // ConfigType is the method of configuring rate limits. Possible values "FILE", "GRPC_XDS_SOTW". @@ -66,11 +72,29 @@ type Settings struct { // GrpcClientTlsSAN is the SAN to validate from the client cert during mTLS auth ConfigGrpcXdsServerTlsSAN string `envconfig:"CONFIG_GRPC_XDS_SERVER_TLS_SAN" default:""` + // xDS client backoff configuration + XdsClientBackoffInitialInterval time.Duration `envconfig:"XDS_CLIENT_BACKOFF_INITIAL_INTERVAL" default:"10s"` + XdsClientBackoffMaxInterval time.Duration `envconfig:"XDS_CLIENT_BACKOFF_MAX_INTERVAL" default:"60s"` + XdsClientBackoffRandomFactor float64 `envconfig:"XDS_CLIENT_BACKOFF_RANDOM_FACTOR" default:"0.5"` + XdsClientBackoffJitter bool `envconfig:"XDS_CLIENT_BACKOFF_JITTER" default:"true"` + + // xDS client gRPC options + XdsClientGrpcOptionsMaxMsgSizeInBytes int `envconfig:"XDS_CLIENT_MAX_MSG_SIZE_IN_BYTES" default:""` + // Stats-related settings - UseStatsd bool `envconfig:"USE_STATSD" default:"true"` - StatsdHost string `envconfig:"STATSD_HOST" default:"localhost"` - StatsdPort int `envconfig:"STATSD_PORT" default:"8125"` - ExtraTags map[string]string `envconfig:"EXTRA_TAGS" default:""` + UseDogStatsd bool `envconfig:"USE_DOG_STATSD" default:"false"` + UseDogStatsdMogrifiers []string `envconfig:"USE_DOG_STATSD_MOGRIFIERS" default:""` + UseStatsd bool `envconfig:"USE_STATSD" default:"true"` + StatsdHost string `envconfig:"STATSD_HOST" default:"localhost"` + StatsdPort int `envconfig:"STATSD_PORT" default:"8125"` + ExtraTags map[string]string `envconfig:"EXTRA_TAGS" default:""` + StatsFlushInterval time.Duration `envconfig:"STATS_FLUSH_INTERVAL" default:"10s"` + DisableStats bool `envconfig:"DISABLE_STATS" default:"false"` + UsePrometheus bool `envconfig:"USE_PROMETHEUS" default:"false"` + PrometheusAddr string `envconfig:"PROMETHEUS_ADDR" default:":9090"` + PrometheusPath string `envconfig:"PROMETHEUS_PATH" default:"/metrics"` + PrometheusMapperYaml string `envconfig:"PROMETHEUS_MAPPER_YAML" default:""` + PrometheusResponseTimeAsMilliseconds bool `envconfig:"PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS" default:"false"` // Settings for rate limit configuration RuntimePath string `envconfig:"RUNTIME_ROOT" default:"/srv/runtime_data/current"` @@ -114,17 +138,77 @@ type Settings struct { RedisTlsCACert string `envconfig:"REDIS_TLS_CACERT" default:""` RedisTlsSkipHostnameVerification bool `envconfig:"REDIS_TLS_SKIP_HOSTNAME_VERIFICATION" default:"false"` - // RedisImplicitPipeline if implicit pipelining for redis should be enabled - RedisImplicitPipeline bool `envconfig:"REDIS_IMPLICIT_PIPELINE" default:"true"` - RedisPerSecond bool `envconfig:"REDIS_PERSECOND" default:"false"` - RedisPerSecondSocketType string `envconfig:"REDIS_PERSECOND_SOCKET_TYPE" default:"unix"` - RedisPerSecondType string `envconfig:"REDIS_PERSECOND_TYPE" default:"SINGLE"` - RedisPerSecondUrl string `envconfig:"REDIS_PERSECOND_URL" default:"/var/run/nutcracker/ratelimitpersecond.sock"` - RedisPerSecondPoolSize int `envconfig:"REDIS_PERSECOND_POOL_SIZE" default:"10"` - RedisPerSecondAuth string `envconfig:"REDIS_PERSECOND_AUTH" default:""` - RedisPerSecondTls bool `envconfig:"REDIS_PERSECOND_TLS" default:"false"` + // RedisPipelineWindow sets the WriteFlushInterval for radix v4 connections. + // This controls how often buffered writes are flushed to the network connection. + // When set to a non-zero value, radix v4 will buffer multiple concurrent write operations + // and flush them together, reducing system calls and improving throughput. + // If zero, each write is flushed immediately (no buffering). + // Required for Redis Cluster mode. Recommended value: 150us-500us. + // See: https://pkg.go.dev/github.com/mediocregopher/radix/v4#Dialer + RedisPipelineWindow time.Duration `envconfig:"REDIS_PIPELINE_WINDOW" default:"0"` + // RedisPipelineLimit is DEPRECATED and unused in radix v4. + // This setting has no effect. Radix v4 does not support explicit pipeline size limits. + // Write buffering is controlled solely by RedisPipelineWindow (WriteFlushInterval). + RedisPipelineLimit int `envconfig:"REDIS_PIPELINE_LIMIT" default:"0"` + // RedisClusterPipelineParallelism controls how many per-key pipeline groups + // can be executed concurrently in Redis Cluster mode. + // - 0: auto, bounded to REDIS_POOL_SIZE + // - 1: serial legacy behavior (default) + // - >1: bounded parallelism, capped to REDIS_POOL_SIZE + RedisClusterPipelineParallelism int `envconfig:"REDIS_CLUSTER_PIPELINE_PARALLELISM" default:"1"` + RedisPerSecond bool `envconfig:"REDIS_PERSECOND" default:"false"` + RedisPerSecondSocketType string `envconfig:"REDIS_PERSECOND_SOCKET_TYPE" default:"unix"` + RedisPerSecondType string `envconfig:"REDIS_PERSECOND_TYPE" default:"SINGLE"` + RedisPerSecondUrl string `envconfig:"REDIS_PERSECOND_URL" default:"/var/run/nutcracker/ratelimitpersecond.sock"` + RedisPerSecondPoolSize int `envconfig:"REDIS_PERSECOND_POOL_SIZE" default:"10"` + RedisPerSecondAuth string `envconfig:"REDIS_PERSECOND_AUTH" default:""` + RedisPerSecondTls bool `envconfig:"REDIS_PERSECOND_TLS" default:"false"` + // RedisSentinelAuth is the password for authenticating to Redis Sentinel nodes (not the Redis master/replica). + // This is separate from RedisAuth which is used for authenticating to the Redis master/replica nodes. + // If empty, no authentication will be attempted when connecting to Sentinel nodes. + RedisSentinelAuth string `envconfig:"REDIS_SENTINEL_AUTH" default:""` + // RedisPerSecondSentinelAuth is the password for authenticating to per-second Redis Sentinel nodes. + // This is separate from RedisPerSecondAuth which is used for authenticating to the Redis master/replica nodes. + // If empty, no authentication will be attempted when connecting to per-second Sentinel nodes. + RedisPerSecondSentinelAuth string `envconfig:"REDIS_PERSECOND_SENTINEL_AUTH" default:""` + // RedisPerSecondPipelineWindow sets the WriteFlushInterval for per-second redis connections. + // See comments of RedisPipelineWindow for details. + RedisPerSecondPipelineWindow time.Duration `envconfig:"REDIS_PERSECOND_PIPELINE_WINDOW" default:"0"` + // RedisPerSecondPipelineLimit is DEPRECATED and unused in radix v4. + // See comments of RedisPipelineLimit for details. + RedisPerSecondPipelineLimit int `envconfig:"REDIS_PERSECOND_PIPELINE_LIMIT" default:"0"` + // RedisPerSecondClusterPipelineParallelism controls per-key pipeline group + // concurrency for per-second Redis Cluster mode. + // See comments of RedisClusterPipelineParallelism for details. + RedisPerSecondClusterPipelineParallelism int `envconfig:"REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM" default:"1"` // Enable healthcheck to check Redis Connection. If there is no active connection, healthcheck failed. RedisHealthCheckActiveConnection bool `envconfig:"REDIS_HEALTH_CHECK_ACTIVE_CONNECTION" default:"false"` + // RedisTimeout sets the timeout for Redis connection and I/O operations. + RedisTimeout time.Duration `envconfig:"REDIS_TIMEOUT" default:"10s"` + // RedisPerSecondTimeout sets the timeout for per-second Redis connection and I/O operations. + RedisPerSecondTimeout time.Duration `envconfig:"REDIS_PERSECOND_TIMEOUT" default:"10s"` + + // RedisPoolOnEmptyBehavior controls what happens when Redis connection pool is empty. + // NOTE: In radix v4, the pool ALWAYS blocks when empty (WAIT behavior). + // Possible values: + // - "WAIT": Block until a connection is available (default, radix v4 behavior) + // - "CREATE": NOT SUPPORTED in radix v4 - will cause panic at startup + // - "ERROR": NOT SUPPORTED in radix v4 - will cause panic at startup + // For fail-fast behavior, use context timeouts when calling Redis operations. + RedisPoolOnEmptyBehavior string `envconfig:"REDIS_POOL_ON_EMPTY_BEHAVIOR" default:"WAIT"` + + // RedisPerSecondPoolOnEmptyBehavior controls pool-empty behavior for per-second Redis. + // See RedisPoolOnEmptyBehavior for possible values and details. + RedisPerSecondPoolOnEmptyBehavior string `envconfig:"REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR" default:"WAIT"` + + // RedisStartupInitialInterval is the initial backoff interval when retrying Redis connection at startup. + RedisStartupInitialInterval time.Duration `envconfig:"REDIS_STARTUP_INITIAL_INTERVAL" default:"1s"` + // RedisStartupMaxInterval is the maximum backoff interval between Redis connection retries at startup. + RedisStartupMaxInterval time.Duration `envconfig:"REDIS_STARTUP_MAX_INTERVAL" default:"30s"` + // RedisStartupMaxElapsedTime is the total time to keep retrying the Redis connection at startup. + // 0 means retry indefinitely until the connection succeeds. + RedisStartupMaxElapsedTime time.Duration `envconfig:"REDIS_STARTUP_MAX_ELAPSED_TIME" default:"0"` + // Memcache settings MemcacheHostPort []string `envconfig:"MEMCACHE_HOST_PORT" default:""` // MemcacheMaxIdleConns sets the maximum number of idle TCP connections per memcached node. @@ -132,13 +216,24 @@ type Settings struct { // number of connections to memcache kept idle in pool, if a connection is needed but none // are idle a new connection is opened, used and closed and can be left in a time-wait state // which can result in high CPU usage. - MemcacheMaxIdleConns int `envconfig:"MEMCACHE_MAX_IDLE_CONNS" default:"2"` - MemcacheSrv string `envconfig:"MEMCACHE_SRV" default:""` - MemcacheSrvRefresh time.Duration `envconfig:"MEMCACHE_SRV_REFRESH" default:"0"` + MemcacheMaxIdleConns int `envconfig:"MEMCACHE_MAX_IDLE_CONNS" default:"2"` + MemcacheSrv string `envconfig:"MEMCACHE_SRV" default:""` + MemcacheSrvRefresh time.Duration `envconfig:"MEMCACHE_SRV_REFRESH" default:"0"` + MemcacheTls bool `envconfig:"MEMCACHE_TLS" default:"false"` + MemcacheTlsConfig *tls.Config + MemcacheTlsClientCert string `envconfig:"MEMCACHE_TLS_CLIENT_CERT" default:""` + MemcacheTlsClientKey string `envconfig:"MEMCACHE_TLS_CLIENT_KEY" default:""` + MemcacheTlsCACert string `envconfig:"MEMCACHE_TLS_CACERT" default:""` + MemcacheTlsSkipHostnameVerification bool `envconfig:"MEMCACHE_TLS_SKIP_HOSTNAME_VERIFICATION" default:"false"` // Should the ratelimiting be running in Global shadow-mode, ie. never report a ratelimit status, unless a rate was provided from envoy as an override GlobalShadowMode bool `envconfig:"SHADOW_MODE" default:"false"` + // Should the ratelimiting be running in Global quota-mode, ie. set metadata but never report OVER_LIMIT status when quota limits are exceeded + GlobalQuotaMode bool `envconfig:"QUOTA_MODE" default:"false"` + + ResponseDynamicMetadata bool `envconfig:"RESPONSE_DYNAMIC_METADATA" default:"false"` + // Allow merging of multiple yaml files referencing the same domain MergeDomainConfigurations bool `envconfig:"MERGE_DOMAIN_CONFIG" default:"false"` @@ -163,6 +258,7 @@ func NewSettings() Settings { } // When we require TLS to connect to Redis, we check if we need to connect using the provided key-pair. RedisTlsConfig(s.RedisTls || s.RedisPerSecondTls)(&s) + MemcacheTlsConfig(s.MemcacheTls)(&s) GrpcServerTlsConfig()(&s) ConfigGrpcXdsServerTlsConfig()(&s) return s @@ -180,6 +276,15 @@ func RedisTlsConfig(redisTls bool) Option { } } +func MemcacheTlsConfig(memcacheTls bool) Option { + return func(s *Settings) { + s.MemcacheTlsConfig = &tls.Config{} + if memcacheTls { + s.MemcacheTlsConfig = utils.TlsConfigFromFiles(s.MemcacheTlsClientCert, s.MemcacheTlsClientKey, s.MemcacheTlsCACert, utils.ServerCA, s.MemcacheTlsSkipHostnameVerification) + } + } +} + func GrpcServerTlsConfig() Option { return func(s *Settings) { if s.GrpcServerUseTLS { diff --git a/src/settings/settings_test.go b/src/settings/settings_test.go index 0a391b78d..c6ab2fd0c 100644 --- a/src/settings/settings_test.go +++ b/src/settings/settings_test.go @@ -1,13 +1,145 @@ package settings import ( + "os" "testing" "github.com/stretchr/testify/assert" ) +const prometheusResponseTimeAsMillisecondsEnv = "PROMETHEUS_RESPONSE_TIME_AS_MILLISECONDS" + func TestSettingsTlsConfigUnmodified(t *testing.T) { settings := NewSettings() assert.NotNil(t, settings.RedisTlsConfig) assert.Nil(t, settings.RedisTlsConfig.RootCAs) } + +func TestPrometheusResponseTimeAsMillisecondsDefault(t *testing.T) { + os.Unsetenv(prometheusResponseTimeAsMillisecondsEnv) + + settings := NewSettings() + + assert.False(t, settings.PrometheusResponseTimeAsMilliseconds) +} + +func TestPrometheusResponseTimeAsMillisecondsEnabled(t *testing.T) { + os.Setenv(prometheusResponseTimeAsMillisecondsEnv, "true") + defer os.Unsetenv(prometheusResponseTimeAsMillisecondsEnv) + + settings := NewSettings() + + assert.True(t, settings.PrometheusResponseTimeAsMilliseconds) +} + +// Tests for RedisPoolOnEmptyBehavior +func TestRedisPoolOnEmptyBehavior_Default(t *testing.T) { + os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "WAIT", settings.RedisPoolOnEmptyBehavior) +} + +func TestRedisPoolOnEmptyBehavior_Error(t *testing.T) { + os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "ERROR") + defer os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "ERROR", settings.RedisPoolOnEmptyBehavior) +} + +func TestRedisPoolOnEmptyBehavior_Create(t *testing.T) { + os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "CREATE") + defer os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "CREATE", settings.RedisPoolOnEmptyBehavior) +} + +func TestRedisPoolOnEmptyBehavior_Wait(t *testing.T) { + os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "WAIT") + defer os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "WAIT", settings.RedisPoolOnEmptyBehavior) +} + +func TestRedisPoolOnEmptyBehavior_CaseInsensitive(t *testing.T) { + // Test that lowercase values work (processing is done in driver_impl.go) + os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "error") + defer os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + // Setting stores as-is, case conversion happens in driver_impl.go + assert.Equal(t, "error", settings.RedisPoolOnEmptyBehavior) +} + +// Tests for RedisPerSecondPoolOnEmptyBehavior +func TestRedisPerSecondPoolOnEmptyBehavior_Default(t *testing.T) { + os.Unsetenv("REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "WAIT", settings.RedisPerSecondPoolOnEmptyBehavior) +} + +func TestRedisPerSecondPoolOnEmptyBehavior_Error(t *testing.T) { + os.Setenv("REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR", "ERROR") + defer os.Unsetenv("REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + assert.Equal(t, "ERROR", settings.RedisPerSecondPoolOnEmptyBehavior) +} + +func TestRedisClusterPipelineParallelism_Default(t *testing.T) { + os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + os.Unsetenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 1, settings.RedisClusterPipelineParallelism) + assert.Equal(t, 1, settings.RedisPerSecondClusterPipelineParallelism) +} + +func TestRedisClusterPipelineParallelism_Configured(t *testing.T) { + os.Setenv("REDIS_CLUSTER_PIPELINE_PARALLELISM", "8") + os.Setenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM", "4") + defer os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + defer os.Unsetenv("REDIS_PERSECOND_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 8, settings.RedisClusterPipelineParallelism) + assert.Equal(t, 4, settings.RedisPerSecondClusterPipelineParallelism) +} + +func TestRedisClusterPipelineParallelism_Auto(t *testing.T) { + os.Setenv("REDIS_CLUSTER_PIPELINE_PARALLELISM", "0") + defer os.Unsetenv("REDIS_CLUSTER_PIPELINE_PARALLELISM") + + settings := NewSettings() + + assert.Equal(t, 0, settings.RedisClusterPipelineParallelism) +} + +// Test both pools can be configured independently +func TestRedisPoolOnEmptyBehavior_IndependentConfiguration(t *testing.T) { + os.Setenv("REDIS_POOL_ON_EMPTY_BEHAVIOR", "ERROR") + os.Setenv("REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR", "CREATE") + defer os.Unsetenv("REDIS_POOL_ON_EMPTY_BEHAVIOR") + defer os.Unsetenv("REDIS_PERSECOND_POOL_ON_EMPTY_BEHAVIOR") + + settings := NewSettings() + + // Main pool configured for fail-fast + assert.Equal(t, "ERROR", settings.RedisPoolOnEmptyBehavior) + + // Per-second pool configured differently + assert.Equal(t, "CREATE", settings.RedisPerSecondPoolOnEmptyBehavior) +} diff --git a/src/srv/srv_test.go b/src/srv/srv_test.go index 64a36682f..3e4d03255 100644 --- a/src/srv/srv_test.go +++ b/src/srv/srv_test.go @@ -8,7 +8,11 @@ import ( ) func mockAddrsLookup(service, proto, name string) (cname string, addrs []*net.SRV, err error) { - return "ignored", []*net.SRV{{"z", 1, 0, 0}, {"z", 0, 0, 0}, {"a", 9001, 0, 0}}, nil + return "ignored", []*net.SRV{ + {Target: "z", Port: 1, Priority: 0, Weight: 0}, + {Target: "z", Port: 0, Priority: 0, Weight: 0}, + {Target: "a", Port: 9001, Priority: 0, Weight: 0}, + }, nil } func TestLookupServerStringsFromSrvReturnsServersSorted(t *testing.T) { diff --git a/src/stats/manager.go b/src/stats/manager.go index ba83fd51d..43d72d59b 100644 --- a/src/stats/manager.go +++ b/src/stats/manager.go @@ -9,6 +9,9 @@ type Manager interface { // NewStats provides a RateLimitStats structure associated with a given descriptorKey. // Multiple calls with the same descriptorKey argument are guaranteed to be equivalent. NewStats(descriptorKey string) RateLimitStats + // Gets stats for a domain (when no descriptors are found) + // Multiple calls with the same domain argument are guaranteed to be equivalent. + NewDomainStats(domain string) DomainStats // Initializes a ShouldRateLimitStats structure. // Multiple calls to this method are idempotent. NewShouldRateLimitStats() ShouldRateLimitStats @@ -52,3 +55,9 @@ type RateLimitStats struct { WithinLimit gostats.Counter ShadowMode gostats.Counter } + +// Stats for a domain entry +type DomainStats struct { + Key string + NotFound gostats.Counter +} diff --git a/src/stats/manager_impl.go b/src/stats/manager_impl.go index fd6462492..e9b173b2d 100644 --- a/src/stats/manager_impl.go +++ b/src/stats/manager_impl.go @@ -8,6 +8,7 @@ import ( gostats "github.com/lyft/gostats" logger "github.com/goatapp/ratelimit/src/log" + "github.com/goatapp/ratelimit/src/settings" "github.com/goatapp/ratelimit/src/utils" ) @@ -59,6 +60,13 @@ func (this *ManagerImpl) NewStats(key string) RateLimitStats { return ret } +func (this *ManagerImpl) NewDomainStats(domain string) DomainStats { + ret := DomainStats{} + domain = utils.SanitizeStatName(domain) + ret.NotFound = this.rlStatsScope.NewCounter(domain + ".domain_not_found") + return ret +} + func (this *ManagerImpl) NewShouldRateLimitStats() ShouldRateLimitStats { ret := ShouldRateLimitStats{} ret.RedisError = this.shouldRateLimitScope.NewCounter("redis_error") diff --git a/src/stats/prom/default_mapper.yaml b/src/stats/prom/default_mapper.yaml new file mode 100644 index 000000000..e03706e37 --- /dev/null +++ b/src/stats/prom/default_mapper.yaml @@ -0,0 +1,98 @@ +# Requires statsd exporter >= v0.6.0 since it uses the "drop" action. +mappings: + - match: "ratelimit.service.rate_limit.*.*.near_limit" + name: "ratelimit_service_rate_limit_near_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.over_limit" + name: "ratelimit_service_rate_limit_over_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.total_hits" + name: "ratelimit_service_rate_limit_total_hits" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.within_limit" + name: "ratelimit_service_rate_limit_within_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + - match: "ratelimit.service.rate_limit.*.*.shadow_mode" + name: "ratelimit_service_rate_limit_shadow_mode" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + + - match: "ratelimit\\.service\\.rate_limit\\.([^\\.]*)\\.([^\\.]*)\\.([^\\.]*)(\\..*)?\\.near_limit" + match_type: regex + name: "ratelimit_service_rate_limit_near_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit\\.service\\.rate_limit\\.([^\\.]*)\\.([^\\.]*)\\.([^\\.]*)(\\..*)?\\.over_limit" + match_type: regex + name: "ratelimit_service_rate_limit_over_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit\\.service\\.rate_limit\\.([^\\.]*)\\.([^\\.]*)\\.([^\\.]*)(\\..*)?\\.total_hits" + match_type: regex + name: "ratelimit_service_rate_limit_total_hits" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit\\.service\\.rate_limit\\.([^\\.]*)\\.([^\\.]*)\\.([^\\.]*)(\\..*)?\\.within_limit" + match_type: regex + name: "ratelimit_service_rate_limit_within_limit" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + - match: "ratelimit\\.service\\.rate_limit\\.([^\\.]*)\\.([^\\.]*)\\.([^\\.]*)(\\..*)?\\.shadow_mode" + match_type: regex + name: "ratelimit_service_rate_limit_shadow_mode" + timer_type: "histogram" + labels: + domain: "$1" + key1: "$2" + key2: "$3" + + - match: "ratelimit.service.call.should_rate_limit.*" + name: "ratelimit_service_should_rate_limit_error" + match_metric_type: counter + labels: + err_type: "$1" + + - match: "ratelimit_server.*.total_requests" + name: "ratelimit_service_total_requests" + match_metric_type: counter + labels: + grpc_method: "$1" + - match: "ratelimit_server.*.response_time" + name: "ratelimit_service_response_time_seconds" + timer_type: histogram + scale: 0.001 + labels: + grpc_method: "$1" + + - match: "ratelimit.service.config_load_success" + name: "ratelimit_service_config_load_success" + match_metric_type: counter + - match: "ratelimit.service.config_load_error" + name: "ratelimit_service_config_load_error" + match_metric_type: counter diff --git a/src/stats/prom/prometheus_sink.go b/src/stats/prom/prometheus_sink.go new file mode 100644 index 000000000..30c32a311 --- /dev/null +++ b/src/stats/prom/prometheus_sink.go @@ -0,0 +1,183 @@ +package prom + +import ( + "context" + _ "embed" + "fmt" + "net/http" + "strings" + + "github.com/go-kit/log" + gostats "github.com/lyft/gostats" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/statsd_exporter/pkg/event" + "github.com/prometheus/statsd_exporter/pkg/exporter" + "github.com/prometheus/statsd_exporter/pkg/mapper" + + logger "github.com/goatapp/ratelimit/src/log" +) + +var ( + //go:embed default_mapper.yaml + defaultMapper string + _ gostats.Sink = &prometheusSink{} + + eventsActions = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "statsd_exporter_events_actions_total", + Help: "The total number of StatsD events by action.", + }, + []string{"action"}, + ) + eventsUnmapped = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "statsd_exporter_events_unmapped_total", + Help: "The total number of StatsD events no mapping was found for.", + }, + ) + metricsCount = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "statsd_exporter_metrics_total", + Help: "The total number of metrics.", + }, + []string{"type"}, + ) + conflictingEventStats = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "statsd_exporter_events_conflict_total", + Help: "The total number of StatsD events with conflicting names.", + }, + []string{"type"}, + ) + eventStats = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "statsd_exporter_events_total", + Help: "The total number of StatsD events seen.", + }, + []string{"type"}, + ) + errorEventStats = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "statsd_exporter_events_error_total", + Help: "The total number of StatsD events discarded due to errors.", + }, + []string{"reason"}, + ) +) + +type prometheusSink struct { + config struct { + addr string + path string + mapperYamlPath string + responseTimeAsMilliseconds bool + } + mapper *mapper.MetricMapper + events chan event.Events + exp *exporter.Exporter +} + +type prometheusSinkOption func(sink *prometheusSink) + +func WithAddr(addr string) prometheusSinkOption { + return func(sink *prometheusSink) { + sink.config.addr = addr + } +} + +func WithPath(path string) prometheusSinkOption { + return func(sink *prometheusSink) { + sink.config.path = path + } +} + +func WithMapperYamlPath(mapperYamlPath string) prometheusSinkOption { + return func(sink *prometheusSink) { + sink.config.mapperYamlPath = mapperYamlPath + } +} + +func WithResponseTimeAsMilliseconds(responseTimeAsMilliseconds bool) prometheusSinkOption { + return func(sink *prometheusSink) { + sink.config.responseTimeAsMilliseconds = responseTimeAsMilliseconds + } +} + +func (s *prometheusSink) mapperConfig() string { + if s.config.responseTimeAsMilliseconds { + return strings.Replace(defaultMapper, " scale: 0.001\n", "", 1) + } + + return defaultMapper +} + +// NewPrometheusSink returns a Sink that flushes stats to os.StdErr. +func NewPrometheusSink(opts ...prometheusSinkOption) gostats.Sink { + promRegistry := prometheus.DefaultRegisterer + sink := &prometheusSink{ + events: make(chan event.Events), + mapper: &mapper.MetricMapper{ + Registerer: promRegistry, + }, + } + for _, opt := range opts { + opt(sink) + } + if sink.config.addr == "" { + sink.config.addr = ":9090" + } + if sink.config.path == "" { + sink.config.path = "/metrics" + } + http.Handle(sink.config.path, promhttp.Handler()) + go func() { + logger.Info(context.Background(), fmt.Sprintf("Starting prometheus sink on %s%s", sink.config.addr, sink.config.path)) + _ = http.ListenAndServe(sink.config.addr, nil) + }() + if sink.config.mapperYamlPath != "" { + _ = sink.mapper.InitFromFile(sink.config.mapperYamlPath) + } else { + _ = sink.mapper.InitFromYAMLString(sink.mapperConfig()) + } + + sink.exp = exporter.NewExporter(promRegistry, + sink.mapper, log.NewNopLogger(), + eventsActions, eventsUnmapped, + errorEventStats, eventStats, + conflictingEventStats, metricsCount) + + go func() { + sink.exp.Listen(sink.events) + }() + + return sink +} + +func (s *prometheusSink) FlushCounter(name string, value uint64) { + logger.Debug(context.Background(), fmt.Sprintf("FlushCounter: %s %d", name, value)) + s.events <- event.Events{&event.CounterEvent{ + CMetricName: name, + CValue: float64(value), + CLabels: make(map[string]string), + }} +} + +func (s *prometheusSink) FlushGauge(name string, value uint64) { + logger.Debug(context.Background(), fmt.Sprintf("FlushGauge: %s %d", name, value)) + s.events <- event.Events{&event.GaugeEvent{ + GMetricName: name, + GValue: float64(value), + GLabels: make(map[string]string), + }} +} + +func (s *prometheusSink) FlushTimer(name string, value float64) { + logger.Debug(context.Background(), fmt.Sprintf("FlushTimer: %s %v", name, value)) + s.events <- event.Events{&event.ObserverEvent{ + OMetricName: name, + OValue: value, + OLabels: make(map[string]string), + }} +} diff --git a/src/stats/prom/prometheus_sink_test.go b/src/stats/prom/prometheus_sink_test.go new file mode 100644 index 000000000..d2d5e890a --- /dev/null +++ b/src/stats/prom/prometheus_sink_test.go @@ -0,0 +1,187 @@ +package prom + +import ( + "reflect" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" +) + +var s = NewPrometheusSink() + +func TestFlushCounter(t *testing.T) { + s.FlushCounter("ratelimit_server.ShouldRateLimit.total_requests", 1) + assert.Eventually(t, func() bool { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_total_requests"] + if !ok || len(m.Metric) != 1 { + return false + } + return toMap(m.Metric[0].Label)["grpc_method"] == "ShouldRateLimit" && + *m.Metric[0].Counter.Value == 1.0 + }, time.Second, time.Millisecond) +} + +func toMap(labels []*dto.LabelPair) map[string]string { + m := make(map[string]string) + for _, l := range labels { + m[*l.Name] = *l.Value + } + return m +} + +func TestFlushCounterWithDifferentLabels(t *testing.T) { + s.FlushCounter("ratelimit.service.rate_limit.domain1.key1_val1.over_limit", 1) + s.FlushCounter("ratelimit.service.rate_limit.domain1.key1_val1.key2_val2.over_limit", 2) + s.FlushCounter("ratelimit.service.rate_limit.domain1.key3_val3.key4_val4.over_limit", 1) + s.FlushCounter("ratelimit.service.rate_limit.domain1.key3_val3.key4_val4.key5_val5.over_limit", 2) + assert.Eventually(t, func() bool { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_rate_limit_over_limit"] + if !ok || len(m.Metric) != 3 { + return false + } + return *m.Metric[0].Counter.Value == 1.0 && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "domain": "domain1", + "key1": "key1_val1", + }) && + *m.Metric[1].Counter.Value == 2.0 && + reflect.DeepEqual(toMap(m.Metric[1].Label), map[string]string{ + "domain": "domain1", + "key1": "key1_val1", + "key2": "key2_val2", + }) && + *m.Metric[2].Counter.Value == 3.0 && + reflect.DeepEqual(toMap(m.Metric[2].Label), map[string]string{ + "domain": "domain1", + "key1": "key3_val3", + "key2": "key4_val4", + }) + }, time.Second, time.Millisecond) +} + +func TestFlushGauge(t *testing.T) { + s.FlushGauge("ratelimit.service.rate_limit.domain1.key1.test_gauge", 1) + metricFamilies, err := prometheus.DefaultGatherer.Gather() + assert.NoError(t, err) + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + _, ok := metrics["ratelimit_service_rate_limit_test_gauge"] + assert.False(t, ok) +} + +func TestFlushTimer(t *testing.T) { + s.FlushTimer("ratelimit.service.rate_limit.mongo_cps.database_users.total_hits", 1) + assert.Eventually(t, func() bool { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_rate_limit_total_hits"] + if !ok || len(m.Metric) != 1 { + return false + } + return *m.Metric[0].Histogram.SampleCount == uint64(1) && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "domain": "mongo_cps", + "key1": "database_users", + }) && + *m.Metric[0].Histogram.SampleSum == 1.0 + }, time.Second, time.Millisecond) +} + +func TestFlushResponseTimeConvertsMillisecondsToSeconds(t *testing.T) { + s.FlushTimer("ratelimit_server.ShouldRateLimit.response_time", 1000) + assert.Eventually(t, func() bool { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_response_time_seconds"] + if !ok || len(m.Metric) != 1 { + return false + } + + return *m.Metric[0].Histogram.SampleCount == uint64(1) && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "grpc_method": "ShouldRateLimit", + }) && + *m.Metric[0].Histogram.SampleSum == 1.0 + }, time.Second, time.Millisecond) +} + +func TestFlushResponseTimeCanUseLegacyMilliseconds(t *testing.T) { + oldRegisterer := prometheus.DefaultRegisterer + oldGatherer := prometheus.DefaultGatherer + reg := prometheus.NewRegistry() + prometheus.DefaultRegisterer = reg + prometheus.DefaultGatherer = reg + defer func() { + prometheus.DefaultRegisterer = oldRegisterer + prometheus.DefaultGatherer = oldGatherer + }() + + legacySink := NewPrometheusSink(WithAddr(":0"), WithPath("/metrics-legacy"), WithResponseTimeAsMilliseconds(true)) + legacySink.FlushTimer("ratelimit_server.ShouldRateLimit.response_time", 1000) + + assert.Eventually(t, func() bool { + metricFamilies, err := reg.Gather() + if err != nil { + return false + } + + metrics := make(map[string]*dto.MetricFamily) + for _, metricFamily := range metricFamilies { + metrics[*metricFamily.Name] = metricFamily + } + + m, ok := metrics["ratelimit_service_response_time_seconds"] + if !ok || len(m.Metric) != 1 { + return false + } + + return *m.Metric[0].Histogram.SampleCount == uint64(1) && + reflect.DeepEqual(toMap(m.Metric[0].Label), map[string]string{ + "grpc_method": "ShouldRateLimit", + }) && + *m.Metric[0].Histogram.SampleSum == 1000.0 + }, time.Second, time.Millisecond) +} diff --git a/src/trace/trace.go b/src/trace/trace.go index c32ddc78b..39d85f998 100644 --- a/src/trace/trace.go +++ b/src/trace/trace.go @@ -6,6 +6,7 @@ import ( "sync" "github.com/google/uuid" + "go.opentelemetry.io/contrib/propagators/b3" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" @@ -50,7 +51,7 @@ func InitProductionTraceProvider(protocol string, serviceName string, serviceNam ) if err != nil { - logger.Fatal(context.Background(), "", logger.WithError(err)) + logger.Fatal(context.Background(), fmt.Sprintf("resource error: %v", err)) } // trace if parent contains root span and is sampled // otherwise only trace according to sampling rate @@ -64,7 +65,7 @@ func InitProductionTraceProvider(protocol string, serviceName string, serviceNam sdktrace.WithResource(resource), ) otel.SetTracerProvider(tp) - otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, b3.New(), propagation.Baggage{})) logger.Info(context.Background(), fmt.Sprintf("TracerProvider initialized with following parameters: protocol: %s, serviceName: %s, serviceNamespace: %s, serviceInstanceId: %s, samplingRate: %f", protocol, serviceName, serviceNamespace, useServiceInstanceId, samplingRate)) return tp @@ -102,7 +103,7 @@ func GetTestSpanExporter() *tracetest.InMemoryExporter { sdktrace.WithSyncer(testSpanExporter), ) otel.SetTracerProvider(tp) - otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, b3.New(), propagation.Baggage{})) return testSpanExporter } diff --git a/src/utils/multi_closer.go b/src/utils/multi_closer.go new file mode 100644 index 000000000..fead3f6b1 --- /dev/null +++ b/src/utils/multi_closer.go @@ -0,0 +1,18 @@ +package utils + +import ( + "errors" + "io" +) + +type MultiCloser struct { + Closers []io.Closer +} + +func (m *MultiCloser) Close() error { + var e error + for _, closer := range m.Closers { + e = errors.Join(closer.Close()) + } + return e +} diff --git a/src/utils/time.go b/src/utils/time.go index ab6b84f8b..e7978cc6c 100644 --- a/src/utils/time.go +++ b/src/utils/time.go @@ -21,7 +21,7 @@ func NewTimeSourceImpl() TimeSource { } func (this *timeSourceImpl) UnixNow() int64 { - return time.Now().UnixMilli() + return time.Now().Unix() } // rand for jitter. diff --git a/src/utils/utilities.go b/src/utils/utilities.go index e877b9bfa..fb398b764 100644 --- a/src/utils/utilities.go +++ b/src/utils/utilities.go @@ -1,15 +1,16 @@ package utils import ( + "regexp" "strings" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" - "github.com/golang/protobuf/ptypes/duration" + "google.golang.org/protobuf/types/known/durationpb" ) // Interface for a time source. type TimeSource interface { - // @return the current unix time in milliseconds. + // @return the current unix time in seconds. UnixNow() int64 } @@ -26,22 +27,21 @@ func UnitToDivider(unit pb.RateLimitResponse_RateLimit_Unit) int64 { return 60 * 60 case pb.RateLimitResponse_RateLimit_DAY: return 60 * 60 * 24 + case pb.RateLimitResponse_RateLimit_WEEK: + return 60 * 60 * 24 * 7 + case pb.RateLimitResponse_RateLimit_MONTH: + return 60 * 60 * 24 * 30 + case pb.RateLimitResponse_RateLimit_YEAR: + return 60 * 60 * 24 * 365 } panic("should not get here") } -func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource) *duration.Duration { +func CalculateReset(unit *pb.RateLimitResponse_RateLimit_Unit, timeSource TimeSource) *durationpb.Duration { sec := UnitToDivider(*unit) - now := timeSource.UnixNow() / 1000 - return &duration.Duration{Seconds: sec - now%sec} -} - -func Max(a uint32, b uint32) uint32 { - if a > b { - return a - } - return b + now := timeSource.UnixNow() + return &durationpb.Duration{Seconds: sec - now%sec} } // Mask credentials from a redis connection string like @@ -62,8 +62,29 @@ func MaskCredentialsInUrl(url string) string { return strings.Join(urls, ",") } +var ipv4Regex = regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`) + // Remove invalid characters from the stat name. func SanitizeStatName(s string) string { r := strings.NewReplacer(":", "_", "|", "_") - return r.Replace(s) + return ipv4Regex.ReplaceAllStringFunc(r.Replace(s), func(ip string) string { + return strings.ReplaceAll(ip, ".", "_") + }) +} + +func GetHitsAddends(request *pb.RateLimitRequest) []uint64 { + hitsAddends := make([]uint64, len(request.Descriptors)) + + for i, descriptor := range request.Descriptors { + if descriptor.HitsAddend != nil { + // If the per descriptor hits_addend is set, use that. It allows to be zero. The zero value is + // means check only by no increment the hits. + hitsAddends[i] = descriptor.HitsAddend.Value + } else { + // If the per descriptor hits_addend is not set, use the request's hits_addend. If the value is + // zero (default value if not specified by the caller), use 1 for backward compatibility. + hitsAddends[i] = uint64(max(1, uint64(request.HitsAddend))) + } + } + return hitsAddends } diff --git a/test/common/common.go b/test/common/common.go index 1062a8995..9b56fb364 100644 --- a/test/common/common.go +++ b/test/common/common.go @@ -14,38 +14,47 @@ import ( "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/wrapperspb" pb_struct "github.com/envoyproxy/go-control-plane/envoy/extensions/common/ratelimit/v3" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" ) type TestStatSink struct { - sync.Mutex + mu sync.Mutex Record map[string]interface{} } +func NewTestStatSink() *TestStatSink { + return &TestStatSink{ + mu: sync.Mutex{}, + Record: make(map[string]interface{}), + } +} + func (s *TestStatSink) Clear() { - s.Lock() - s.Record = map[string]interface{}{} - s.Unlock() + s.mu.Lock() + s.Record = make(map[string]interface{}) + s.mu.Unlock() } func (s *TestStatSink) FlushCounter(name string, value uint64) { - s.Lock() + s.mu.Lock() s.Record[name] = value - s.Unlock() + s.mu.Unlock() } func (s *TestStatSink) FlushGauge(name string, value uint64) { - s.Lock() + s.mu.Lock() + fmt.Println("FlushGauge", name, value) s.Record[name] = value - s.Unlock() + s.mu.Unlock() } func (s *TestStatSink) FlushTimer(name string, value float64) { - s.Lock() + s.mu.Lock() s.Record[name] = value - s.Unlock() + s.mu.Unlock() } func NewRateLimitRequest(domain string, descriptors [][][2]string, hitsAddend uint32) *pb.RateLimitRequest { @@ -56,7 +65,8 @@ func NewRateLimitRequest(domain string, descriptors [][][2]string, hitsAddend ui for _, entry := range descriptor { newDescriptor.Entries = append( newDescriptor.Entries, - &pb_struct.RateLimitDescriptor_Entry{Key: entry[0], Value: entry[1]}) + &pb_struct.RateLimitDescriptor_Entry{Key: entry[0], Value: entry[1]}, + ) } request.Descriptors = append(request.Descriptors, newDescriptor) } @@ -64,6 +74,16 @@ func NewRateLimitRequest(domain string, descriptors [][][2]string, hitsAddend ui return request } +func NewRateLimitRequestWithPerDescriptorHitsAddend(domain string, descriptors [][][2]string, + hitsAddends []uint64, +) *pb.RateLimitRequest { + request := NewRateLimitRequest(domain, descriptors, 1) + for i, hitsAddend := range hitsAddends { + request.Descriptors[i].HitsAddend = &wrapperspb.UInt64Value{Value: hitsAddend} + } + return request +} + func AssertProtoEqual(assert *assert.Assertions, expected proto.Message, actual proto.Message) { assert.True(proto.Equal(expected, actual), fmt.Sprintf("These two protobuf messages are not equal:\nexpected: %v\nactual: %v", expected, actual)) @@ -86,7 +106,7 @@ func WaitForTcpPort(ctx context.Context, port int, timeout time.Duration) error // Wait up to 1s for the redis instance to start accepting connections. for { var d net.Dialer - conn, err := d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port)) + conn, err := d.DialContext(timeoutCtx, "tcp", "localhost:"+strconv.Itoa(port)) if err == nil { conn.Close() // TCP connections are working. All is well. diff --git a/test/config/basic_config.yaml b/test/config/basic_config.yaml index 1ce7c9afa..8992966fc 100644 --- a/test/config/basic_config.yaml +++ b/test/config/basic_config.yaml @@ -42,6 +42,7 @@ descriptors: - key: key4 rate_limit: + name: key4_rate_limit unit: day requests_per_unit: 1 diff --git a/test/config/config_test.go b/test/config/config_test.go index 971d09531..1d02e8939 100644 --- a/test/config/config_test.go +++ b/test/config/config_test.go @@ -12,6 +12,7 @@ import ( pb_type "github.com/envoyproxy/go-control-plane/envoy/type/v3" stats "github.com/lyft/gostats" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/goatapp/ratelimit/src/config" mockstats "github.com/goatapp/ratelimit/test/mocks/stats" @@ -32,42 +33,50 @@ func TestBasicConfig(t *testing.T) { rlConfig := config.NewRateLimitConfigImpl(loadFile("basic_config.yaml"), mockstats.NewMockStatManager(stats), false) rlConfig.Dump() assert.Equal(rlConfig.IsEmptyDomains(), false) + assert.EqualValues(0, stats.NewCounter("foo_domain.domain_not_found").Value()) assert.Nil(rlConfig.GetLimit(context.TODO(), "foo_domain", &pb_struct.RateLimitDescriptor{})) + assert.EqualValues(1, stats.NewCounter("foo_domain.domain_not_found").Value()) assert.Nil(rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{})) + assert.EqualValues(0, stats.NewCounter("test-domain.domain_not_found").Value()) rl := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "something"}}, - }) + }, + ) assert.Nil(rl) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}}, - }) + }, + ) assert.Nil(rl) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "value2"}, {Key: "subkey", Value: "subvalue"}}, - }) + }, + ) assert.Nil(rl) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key5", Value: "value5"}, {Key: "subkey5", Value: "subvalue"}}, - }) + }, + ) assert.Nil(rl) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "something"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -83,7 +92,8 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "subvalue1"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -91,19 +101,24 @@ func TestBasicConfig(t *testing.T) { assert.EqualValues(10, rl.Limit.RequestsPerUnit) assert.Equal(pb.RateLimitResponse_RateLimit_SECOND, rl.Limit.Unit) assert.EqualValues( - 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.total_hits").Value()) + 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.total_hits").Value(), + ) assert.EqualValues( - 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.over_limit").Value()) + 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.over_limit").Value(), + ) assert.EqualValues( - 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.near_limit").Value()) + 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.near_limit").Value(), + ) assert.EqualValues( - 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.within_limit").Value()) + 1, stats.NewCounter("test-domain.key1_value1.subkey1_subvalue1.within_limit").Value(), + ) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "something"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -119,7 +134,8 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "value2"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -135,19 +151,22 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "value3"}}, - }) + }, + ) assert.Nil(rl) rl = rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key3", Value: "foo"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() rl.Stats.WithinLimit.Inc() assert.EqualValues(1, rl.Limit.RequestsPerUnit) + assert.Empty(rl.Limit.Name, "No name provided in config") assert.Equal(pb.RateLimitResponse_RateLimit_HOUR, rl.Limit.Unit) assert.EqualValues(1, stats.NewCounter("test-domain.key3.total_hits").Value()) assert.EqualValues(1, stats.NewCounter("test-domain.key3.over_limit").Value()) @@ -158,12 +177,14 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key4", Value: "foo"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() rl.Stats.WithinLimit.Inc() assert.EqualValues(1, rl.Limit.RequestsPerUnit) + assert.EqualValues("key4_rate_limit", rl.Limit.Name, "Name provided in config") assert.Equal(pb.RateLimitResponse_RateLimit_DAY, rl.Limit.Unit) assert.EqualValues(1, stats.NewCounter("test-domain.key4.total_hits").Value()) assert.EqualValues(1, stats.NewCounter("test-domain.key4.over_limit").Value()) @@ -174,7 +195,8 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key6", Value: "foo"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.WithinLimit.Inc() assert.True(rl.Unlimited) @@ -187,7 +209,8 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key7", Value: "unspecified_value"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -205,7 +228,8 @@ func TestBasicConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key7", Value: "another_value"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -234,7 +258,8 @@ func TestDomainMerge(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}}, - }) + }, + ) assert.NotNil(rl) assert.EqualValues(10, rl.Limit.RequestsPerUnit) @@ -242,7 +267,8 @@ func TestDomainMerge(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "value2"}}, - }) + }, + ) assert.NotNil(rl) assert.EqualValues(20, rl.Limit.RequestsPerUnit) } @@ -265,7 +291,8 @@ func TestConfigLimitOverride(t *testing.T) { Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ RequestsPerUnit: 10, Unit: pb_type.RateLimitUnit_DAY, }, - }) + }, + ) assert.Equal("test-domain.key1_value1.subkey1_something", rl.FullKey) common.AssertProtoEqual(assert, &pb.RateLimitResponse_RateLimit{ RequestsPerUnit: 10, @@ -288,7 +315,8 @@ func TestConfigLimitOverride(t *testing.T) { Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ RequestsPerUnit: 42, Unit: pb_type.RateLimitUnit_HOUR, }, - }) + }, + ) assert.Equal("test-domain.key1_value1.subkey1_something", rl.FullKey) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() @@ -311,7 +339,8 @@ func TestConfigLimitOverride(t *testing.T) { Limit: &pb_struct.RateLimitDescriptor_RateLimitOverride{ RequestsPerUnit: 42, Unit: pb_type.RateLimitUnit_HOUR, }, - }) + }, + ) assert.Equal("test-domain.key1_value1.subkey1_something_else", rl.FullKey) common.AssertProtoEqual(assert, &pb.RateLimitResponse_RateLimit{ RequestsPerUnit: 42, @@ -343,9 +372,11 @@ func TestEmptyDomain(t *testing.T) { t, func() { config.NewRateLimitConfigImpl( - loadFile("empty_domain.yaml"), mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + loadFile("empty_domain.yaml"), mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "empty_domain.yaml: config file cannot have empty domain") + "empty_domain.yaml: config file cannot have empty domain", + ) } func TestDuplicateDomain(t *testing.T) { @@ -356,7 +387,8 @@ func TestDuplicateDomain(t *testing.T) { files = append(files, loadFile("duplicate_domain.yaml")...) config.NewRateLimitConfigImpl(files, mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) }, - "duplicate_domain.yaml: duplicate domain 'test-domain' in config file") + "duplicate_domain.yaml: duplicate domain 'test-domain' in config file", + ) } func TestEmptyKey(t *testing.T) { @@ -365,9 +397,11 @@ func TestEmptyKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("empty_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "empty_key.yaml: descriptor has empty key") + "empty_key.yaml: descriptor has empty key", + ) } func TestDuplicateKey(t *testing.T) { @@ -376,9 +410,11 @@ func TestDuplicateKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("duplicate_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "duplicate_key.yaml: duplicate descriptor composite key 'test-domain.key1_value1'") + "duplicate_key.yaml: duplicate descriptor composite key 'test-domain.key1_value1'", + ) } func TestDuplicateKeyDomainMerge(t *testing.T) { @@ -389,9 +425,11 @@ func TestDuplicateKeyDomainMerge(t *testing.T) { files = append(files, loadFile("merge_domain_key1.yaml")...) config.NewRateLimitConfigImpl( files, - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), true) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), true, + ) }, - "merge_domain_key1.yaml: duplicate descriptor composite key 'test-domain.key1_value1'") + "merge_domain_key1.yaml: duplicate descriptor composite key 'test-domain.key1_value1'", + ) } func TestBadLimitUnit(t *testing.T) { @@ -400,9 +438,11 @@ func TestBadLimitUnit(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("bad_limit_unit.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "bad_limit_unit.yaml: invalid rate limit unit 'foo'") + "bad_limit_unit.yaml: invalid rate limit unit 'foo'", + ) } func TestReplacesSelf(t *testing.T) { @@ -411,9 +451,11 @@ func TestReplacesSelf(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("replaces_self.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "replaces_self.yaml: replaces should not contain name of same descriptor") + "replaces_self.yaml: replaces should not contain name of same descriptor", + ) } func TestReplacesEmpty(t *testing.T) { @@ -422,9 +464,11 @@ func TestReplacesEmpty(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("replaces_empty.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "replaces_empty.yaml: should not have an empty replaces entry") + "replaces_empty.yaml: should not have an empty replaces entry", + ) } func TestBadYaml(t *testing.T) { @@ -433,9 +477,11 @@ func TestBadYaml(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("bad_yaml.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "bad_yaml.yaml: error loading config file: yaml: line 2: found unexpected end of stream") + "bad_yaml.yaml: error loading config file: yaml: line 2: found unexpected end of stream", + ) } func TestMisspelledKey(t *testing.T) { @@ -444,18 +490,22 @@ func TestMisspelledKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("misspelled_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "misspelled_key.yaml: config error, unknown key 'ratelimit'") + "misspelled_key.yaml: config error, unknown key 'ratelimit'", + ) expectConfigPanic( t, func() { config.NewRateLimitConfigImpl( loadFile("misspelled_key2.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "misspelled_key2.yaml: config error, unknown key 'requestsperunit'") + "misspelled_key2.yaml: config error, unknown key 'requestsperunit'", + ) } func TestNonStringKey(t *testing.T) { @@ -464,9 +514,11 @@ func TestNonStringKey(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("non_string_key.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "non_string_key.yaml: config error, key is not of type string: 0.25") + "non_string_key.yaml: config error, key is not of type string: 0.25", + ) } func TestNonMapList(t *testing.T) { @@ -475,9 +527,11 @@ func TestNonMapList(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("non_map_list.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "non_map_list.yaml: config error, yaml file contains list of type other than map: a") + "non_map_list.yaml: config error, yaml file contains list of type other than map: a", + ) } func TestUnlimitedWithRateLimitUnit(t *testing.T) { @@ -486,9 +540,11 @@ func TestUnlimitedWithRateLimitUnit(t *testing.T) { func() { config.NewRateLimitConfigImpl( loadFile("unlimited_with_unit.yaml"), - mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false) + mockstats.NewMockStatManager(stats.NewStore(stats.NewNullSink(), false)), false, + ) }, - "unlimited_with_unit.yaml: should not specify rate limit unit when unlimited") + "unlimited_with_unit.yaml: should not specify rate limit unit when unlimited", + ) } func TestShadowModeConfig(t *testing.T) { @@ -502,7 +558,8 @@ func TestShadowModeConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "something"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -519,7 +576,8 @@ func TestShadowModeConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "subvalue1"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -537,7 +595,8 @@ func TestShadowModeConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "something"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -554,7 +613,8 @@ func TestShadowModeConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "value2"}}, - }) + }, + ) rl.Stats.TotalHits.Inc() rl.Stats.OverLimit.Inc() rl.Stats.NearLimit.Inc() @@ -578,41 +638,56 @@ func TestWildcardConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "noVal", Value: "foo1"}}, - }) + }, + ) withoutVal2 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "noVal", Value: "foo2"}}, - }) + }, + ) assert.NotNil(withoutVal1) assert.Equal(withoutVal1, withoutVal2) + // Verify stats keys for no value descriptors + assert.Equal("test-domain.noVal", withoutVal1.Stats.Key, "No value descriptor should use just the key") + assert.Equal(withoutVal1.Stats.Key, withoutVal1.FullKey, "FullKey should match Stats.Key") + assert.Equal(withoutVal1.Stats.Key, withoutVal2.Stats.Key, "All no-value matches should have same stats key") // Matches multiple wildcard values and results are equal wildcard1 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, - }) + }, + ) wildcard2 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo2"}}, - }) + }, + ) wildcard3 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "nestedWild", Value: "val1"}, {Key: "wild", Value: "goo2"}}, - }) + }, + ) assert.NotNil(wildcard1) assert.Equal(wildcard1, wildcard2) + assert.Equal("test-domain.wild_foo*", wildcard1.Stats.Key, "Wildcard stats key should include the wildcard pattern with *") + assert.Equal("test-domain.wild_foo*", wildcard1.FullKey, "Wildcard FullKey should match Stats.Key") + assert.Equal("test-domain.wild_foo*", wildcard2.Stats.Key, "All wildcard matches should have same stats key with wildcard pattern") assert.NotNil(wildcard3) + assert.Equal("test-domain.nestedWild_val1.wild_goo*", wildcard3.Stats.Key, "Nested wildcard stats key should include the wildcard pattern with *") + assert.Equal("test-domain.nestedWild_val1.wild_goo*", wildcard3.FullKey, "Nested wildcard FullKey should match Stats.Key") // Doesn't match non-matching values noMatch := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "bar"}}, - }) + }, + ) assert.Nil(noMatch) // Non-wildcard values don't eager match @@ -620,14 +695,1658 @@ func TestWildcardConfig(t *testing.T) { context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "noWild", Value: "foo1"}}, - }) + }, + ) assert.Nil(eager) - // Wildcard in the middle of value is not supported. - midWildcard := rlConfig.GetLimit( + // Middle wildcard (single *): bar*b matches values with "bar" prefix and "b" suffix. + midWild1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "barab"}}, + }, + ) + midWild2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "bar123b"}}, + }, + ) + assert.NotNil(midWild1) + assert.NotNil(midWild2) + assert.Equal(midWild1, midWild2, "Different values matching the same middle wildcard should share the same rate limit") + assert.Equal("test-domain.midWild_bar*b", midWild1.Stats.Key, "Middle wildcard stats key should use the wildcard pattern") + assert.Equal("test-domain.midWild_bar*b", midWild1.FullKey, "Middle wildcard FullKey should use the wildcard pattern") + + // Middle wildcard: zero-length middle is allowed (* matches empty string). + midWildEmpty := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "barb"}}, + }, + ) + assert.NotNil(midWildEmpty, "* should match empty string so 'barb' matches 'bar*b'") + + // Middle wildcard: does not match values that don't satisfy prefix+suffix. + midWildNoMatch1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "bara"}}, + }, + ) + midWildNoMatch2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWild", Value: "xbarb"}}, + }, + ) + assert.Nil(midWildNoMatch1, "Value without required suffix should not match middle wildcard") + assert.Nil(midWildNoMatch2, "Value without required prefix should not match middle wildcard") + + // Multiple wildcards: foo*bar*baz matches values containing "foo"..."bar"..."baz" in order. + multiWild1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foo123bar456baz"}}, + }, + ) + multiWild2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foobarbaz"}}, + }, + ) + assert.NotNil(multiWild1) + assert.NotNil(multiWild2) + assert.Equal(multiWild1, multiWild2, "Different values matching the same multi-wildcard should share the same rate limit") + assert.Equal("test-domain.multiWild_foo*bar*baz", multiWild1.Stats.Key, "Multi-wildcard stats key should use the wildcard pattern") + assert.Equal("test-domain.multiWild_foo*bar*baz", multiWild1.FullKey, "Multi-wildcard FullKey should use the wildcard pattern") + + // Multiple wildcards: does not match when a required segment is missing or out of order. + multiWildNoMatch1 := rlConfig.GetLimit( context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{ - Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midWildcard", Value: "barab"}}, + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "foo123baz"}}, + }, + ) + multiWildNoMatch2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiWild", Value: "bar456baz"}}, + }, + ) + assert.Nil(multiWildNoMatch1, "Value missing middle segment 'bar' should not match") + assert.Nil(multiWildNoMatch2, "Value missing required prefix 'foo' should not match") +} + +func TestDetailedMetric(t *testing.T) { + assert := require.New(t) + stats := stats.NewStore(stats.NewNullSink(), false) + + // Descriptor config with a realistic nested setup, that is re-used across + // multiple test cases. + realisticExampleConfig := &config.YamlRoot{ + Domain: "nested", + Descriptors: []config.YamlDescriptor{ + { + Key: "key_1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 500, + Unit: "minute", + }, + DetailedMetric: true, + }, + { + Key: "key_1", + Value: "some-value-for-key-1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 500, + Unit: "minute", + }, + }, + { + Key: "key_2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5000, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key_3", + DetailedMetric: true, + }, + { + Key: "key_3", + Value: "requested-key3-value", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5, + Unit: "minute", + }, + DetailedMetric: true, + }, + }, + DetailedMetric: true, + }, + { + Key: "key_2", + Value: "specific-id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 50000, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key_3", + DetailedMetric: true, + }, + { + Key: "key_3", + Value: "requested-key3-value", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + DetailedMetric: true, + }, + }, + DetailedMetric: true, + }, + }, + } + + tests := []struct { + name string + config []config.RateLimitConfigToLoad + request *pb_struct.RateLimitDescriptor + expectedStatsKey string + }{ + { + name: "nested with no values but request only top-level key", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: &config.YamlRoot{ + Domain: "nested", + Descriptors: []config.YamlDescriptor{ + { + Key: "key-1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key-2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5, + Unit: "minute", + }, + }, + }, + DetailedMetric: true, + }, + }, + }, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key-1", + Value: "value-1", + }, + }, + }, + expectedStatsKey: "nested.key-1_value-1", + }, + { + name: "nested with no values but request only top-level key with no detailed metric", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: &config.YamlRoot{ + Domain: "nested", + Descriptors: []config.YamlDescriptor{ + { + Key: "key-1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key-2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5, + Unit: "minute", + }, + }, + }, + DetailedMetric: false, + }, + }, + }, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key-1", + Value: "value-1", + }, + }, + }, + expectedStatsKey: "nested.key-1", + }, + { + name: "nested with no values and request fully nested descriptors", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: &config.YamlRoot{ + Domain: "nested", + Descriptors: []config.YamlDescriptor{ + { + Key: "key-1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key-2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5, + Unit: "minute", + }, + DetailedMetric: true, + }, + }, + DetailedMetric: true, + }, + }, + }, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key-1", + Value: "value-1", + }, + { + Key: "key-2", + Value: "value-2", + }, + }, + }, + expectedStatsKey: "nested.key-1_value-1.key-2_value-2", + }, + { + name: "nested with no values and request fully nested descriptors with no detailed metric", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: &config.YamlRoot{ + Domain: "nested", + Descriptors: []config.YamlDescriptor{ + { + Key: "key-1", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + Descriptors: []config.YamlDescriptor{ + { + Key: "key-2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 5, + Unit: "minute", + }, + DetailedMetric: false, + }, + }, + DetailedMetric: false, + }, + }, + }, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key-1", + Value: "value-1", + }, + { + Key: "key-2", + Value: "value-2", + }, + }, + }, + expectedStatsKey: "nested.key-1.key-2", + }, + { + name: "test nested descriptors with simple request", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: realisticExampleConfig, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key_1", + Value: "value-for-key-1", + }, + }, + }, + expectedStatsKey: "nested.key_1_value-for-key-1", + }, + { + name: "test nested only second descriptor request not nested", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: realisticExampleConfig, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key_2", + Value: "key-2-value", + }, + }, + }, + expectedStatsKey: "nested.key_2_key-2-value", + }, + { + name: "test nested descriptors with nested request", + config: []config.RateLimitConfigToLoad{ + { + ConfigYaml: realisticExampleConfig, + }, + }, + request: &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + { + Key: "key_2", + Value: "key-2-value", + }, + { + Key: "key_3", + Value: "requested-key3-value", + }, + }, + }, + expectedStatsKey: "nested.key_2_key-2-value.key_3_requested-key3-value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rlConfig := config.NewRateLimitConfigImpl(tt.config, mockstats.NewMockStatManager(stats), true) + rlConfig.Dump() + + rl := rlConfig.GetLimit( + context.TODO(), "nested", + tt.request, + ) + assert.NotNil(rl) + assert.Equal(tt.expectedStatsKey, rl.Stats.Key) }) - assert.Nil(midWildcard) + } +} + +func TestValueToMetric_UsesRuntimeValuesInStats(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + rl := rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "draw"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "123"}, + }, + }, + ) + asrt.NotNil(rl) + + // Should include actual runtime values for keys that set value_to_metric: true + expectedKey := "domain.route_draw.http_method_GET.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Increment a couple of counters to ensure the key is actually used in stats + rl.Stats.TotalHits.Inc() + rl.Stats.WithinLimit.Inc() + + asrt.EqualValues(1, store.NewCounter(expectedKey+".total_hits").Value()) + asrt.EqualValues(1, store.NewCounter(expectedKey+".within_limit").Value()) } + +func TestValueToMetric_DefaultKeyIncludesValueAtThatLevel(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "d", + Descriptors: []config.YamlDescriptor{ + { + Key: "k1", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "k2", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 1, + Unit: "second", + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit( + context.TODO(), "d", + &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "k1", Value: "A"}, + {Key: "k2", Value: "foo"}, + }}, + ) + asrt.NotNil(rl) + asrt.Equal("d.k1_A.k2", rl.Stats.Key) +} + +func TestValueToMetric_MidLevelOnly(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "d", + Descriptors: []config.YamlDescriptor{ + { + Key: "k1", + Descriptors: []config.YamlDescriptor{ + { + Key: "k2", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "k3", + RateLimit: &config.YamlRateLimit{RequestsPerUnit: 1, Unit: "second"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit( + context.TODO(), "d", + &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "k1", Value: "X"}, + {Key: "k2", Value: "Y"}, + {Key: "k3", Value: "Z"}, + }}, + ) + asrt.NotNil(rl) + // k1 has no flag -> just key; k2 has flag -> include value + asrt.Equal("d.k1.k2_Y.k3", rl.Stats.Key) +} + +func TestValueToMetric_NoFlag_Unchanged(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "d", + Descriptors: []config.YamlDescriptor{ + { + Key: "k1", + Descriptors: []config.YamlDescriptor{ + { + Key: "k2", + RateLimit: &config.YamlRateLimit{RequestsPerUnit: 1, Unit: "second"}, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit( + context.TODO(), "d", + &pb_struct.RateLimitDescriptor{Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "k1", Value: "X"}, + {Key: "k2", Value: "Y"}, + }}, + ) + asrt.NotNil(rl) + // No flags anywhere -> same as old behavior when default matched at k1 + asrt.Equal("d.k1.k2", rl.Stats.Key) +} + +func TestValueToMetric_DoesNotOverrideDetailedMetric(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + DetailedMetric: true, + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + rl := rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "draw"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "123"}, + }, + }, + ) + asrt.NotNil(rl) + + // With detailed_metric at the leaf, the detailed metric key should be used, regardless of value_to_metric flags + expectedKey := "domain.route_draw.http_method_GET.subject_id_123" + asrt.Equal(expectedKey, rl.Stats.Key) + + rl.Stats.TotalHits.Inc() + asrt.EqualValues(1, store.NewCounter(expectedKey+".total_hits").Value()) +} + +func TestValueToMetric_WithConfiguredValues(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + Value: "GET", // Configured value in descriptor + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + { + Key: "http_method", + Value: "POST", // Another configured value + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 30, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + // Test GET path - should include runtime value for route, but use configured value for http_method + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "user123"}, + }, + }, + ) + asrt.NotNil(rl) + asrt.EqualValues(60, rl.Limit.RequestsPerUnit) + // route has value_to_metric=true, so includes runtime value; http_method has configured value, so uses that + expectedKey := "test-domain.route_api.http_method_GET.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test POST path - should include runtime value for route, but use configured value for http_method + rl = rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api"}, + {Key: "http_method", Value: "POST"}, + {Key: "subject_id", Value: "user456"}, + }, + }, + ) + asrt.NotNil(rl) + asrt.EqualValues(30, rl.Limit.RequestsPerUnit) + expectedKey = "test-domain.route_api.http_method_POST.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test that stats are actually created with the correct keys + rl.Stats.TotalHits.Inc() + asrt.EqualValues(1, store.NewCounter(expectedKey+".total_hits").Value()) +} + +func TestValueToMetric_WithWildcard(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "user", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "action", + Value: "read*", // Wildcard pattern + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "resource", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 100, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + // Test wildcard matching with value_to_metric - should include full runtime value + rl := rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "user", Value: "alice"}, + {Key: "action", Value: "readfile"}, // Matches "read*" wildcard + {Key: "resource", Value: "documents"}, + }, + }, + ) + asrt.NotNil(rl) + asrt.EqualValues(100, rl.Limit.RequestsPerUnit) + // Both user and action should include their full runtime values due to value_to_metric + expectedKey := "domain.user_alice.action_readfile.resource" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test another wildcard match + rl = rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "user", Value: "bob"}, + {Key: "action", Value: "readdata"}, // Also matches "read*" wildcard + {Key: "resource", Value: "database"}, + }, + }, + ) + asrt.NotNil(rl) + expectedKey = "domain.user_bob.action_readdata.resource" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test that stats are actually created with the correct keys + rl.Stats.TotalHits.Inc() + asrt.EqualValues(1, store.NewCounter(expectedKey+".total_hits").Value()) +} + +func TestValueToMetric_WithEmptyValue(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + // Test with empty value for route - should not include underscore and empty value + rl := rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: ""}, // Empty value + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "123"}, + }, + }, + ) + asrt.NotNil(rl) + + // Should not include underscore and empty value for route + expectedKey := "domain.route.http_method_GET.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test with empty value for http_method - should not include underscore and empty value + rl = rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "draw"}, + {Key: "http_method", Value: ""}, // Empty value + {Key: "subject_id", Value: "123"}, + }, + }, + ) + asrt.NotNil(rl) + + // Should not include underscore and empty value for http_method + expectedKey = "domain.route_draw.http_method.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test with empty value for both - should not include underscores and empty values + rl = rlConfig.GetLimit( + context.TODO(), "domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: ""}, // Empty value + {Key: "http_method", Value: ""}, // Empty value + {Key: "subject_id", Value: "123"}, + }, + }, + ) + asrt.NotNil(rl) + + // Should not include underscores and empty values + expectedKey = "domain.route.http_method.subject_id" + asrt.Equal(expectedKey, rl.Stats.Key) + + // Increment counters to ensure the keys are actually used in stats + rl.Stats.TotalHits.Inc() + rl.Stats.WithinLimit.Inc() + + asrt.EqualValues(1, store.NewCounter(expectedKey+".total_hits").Value()) + asrt.EqualValues(1, store.NewCounter(expectedKey+".within_limit").Value()) +} + +// TestValueToMetric_FullKeyMatchesStatsKey verifies that rateLimit.FullKey always matches +// rateLimit.Stats.Key. This is important for debugging and log/metric correlation. +// FullKey is used in debug logs, while Stats.Key is used for actual metrics. +func TestValueToMetric_FullKeyMatchesStatsKey(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + + // Test case 1: value_to_metric enabled - FullKey should match Stats.Key + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "user123"}, + }, + }, + ) + asrt.NotNil(rl) + asrt.Equal(rl.FullKey, rl.Stats.Key, "FullKey should match Stats.Key when value_to_metric is enabled") + expectedKey := "test-domain.route_api.http_method_GET.subject_id" + asrt.Equal(expectedKey, rl.FullKey) + asrt.Equal(expectedKey, rl.Stats.Key) + + // Test case 2: value_to_metric disabled - FullKey should match Stats.Key + cfgNoValueToMetric := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain-2", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig2 := config.NewRateLimitConfigImpl(cfgNoValueToMetric, mockstats.NewMockStatManager(store), false) + rl2 := rlConfig2.GetLimit( + context.TODO(), "test-domain-2", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "user123"}, + }, + }, + ) + asrt.NotNil(rl2) + asrt.Equal(rl2.FullKey, rl2.Stats.Key, "FullKey should match Stats.Key even when value_to_metric is disabled") + + // Test case 3: detailed_metric enabled - FullKey should match Stats.Key + cfgDetailedMetric := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain-3", + Descriptors: []config.YamlDescriptor{ + { + Key: "route", + Descriptors: []config.YamlDescriptor{ + { + Key: "http_method", + Descriptors: []config.YamlDescriptor{ + { + Key: "subject_id", + DetailedMetric: true, + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 60, + Unit: "minute", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig3 := config.NewRateLimitConfigImpl(cfgDetailedMetric, mockstats.NewMockStatManager(store), false) + rl3 := rlConfig3.GetLimit( + context.TODO(), "test-domain-3", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api"}, + {Key: "http_method", Value: "GET"}, + {Key: "subject_id", Value: "user123"}, + }, + }, + ) + asrt.NotNil(rl3) + asrt.Equal(rl3.FullKey, rl3.Stats.Key, "FullKey should match Stats.Key when detailed_metric is enabled") +} + +// TestShareThreshold tests config (ShareThresholdKeyPattern) and metrics (Stats.Key) +// Cache key generation is tested in base_limiter_test.go +func TestShareThreshold(t *testing.T) { + asrt := assert.New(t) + stats := stats.NewStore(stats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("share_threshold.yaml"), mockstats.NewMockStatManager(stats), false) + + // Test Case 1: Basic share_threshold functionality + t.Run("Basic share_threshold", func(t *testing.T) { + testValues := []string{"files/a.pdf", "files/b.csv", "files/c.txt"} + var rateLimits []*config.RateLimit + + for _, value := range testValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "files", Value: value}}, + }, + ) + asrt.NotNil(rl) + // Verify config: ShareThresholdKeyPattern is set correctly + asrt.Equal("files/*", rl.ShareThresholdKeyPattern[0]) + asrt.EqualValues(100, rl.Limit.RequestsPerUnit) + // Verify stats: All should have the same stats key with wildcard pattern + // share_threshold: stats key includes wildcard pattern with * + asrt.Equal("test-domain.files_files/*", rl.Stats.Key) + rateLimits = append(rateLimits, rl) + } + + // Verify all have same stats key + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All values should have same stats key") + asrt.Equal(rateLimits[i].Stats.Key, rateLimits[i].FullKey, "FullKey should match Stats.Key for all rate limits") + } + }) + + // Test Case 2: share_threshold with metrics (value_to_metric and detailed_metric) + // Tests that metrics correctly include wildcard prefix when share_threshold is enabled + t.Run("share_threshold with metrics", func(t *testing.T) { + // Test value_to_metric with share_threshold + // share_threshold takes priority: should use wildcard pattern with * + rl1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "route", Value: "api/v1"}, + {Key: "method", Value: "GET"}, + }, + }, + ) + asrt.NotNil(rl1) + asrt.Equal("api/*", rl1.ShareThresholdKeyPattern[0]) + asrt.Equal("test-domain.route_api/*.method", rl1.Stats.Key, "share_threshold takes priority over value_to_metric, should use wildcard pattern with *") + asrt.Equal(rl1.Stats.Key, rl1.FullKey, "FullKey should match Stats.Key") + + // Test detailed_metric with share_threshold + // share_threshold takes priority: should use wildcard pattern with * + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "service", Value: "svc/user"}, + {Key: "endpoint", Value: "get"}, + }, + }, + ) + asrt.NotNil(rl2) + asrt.Equal("svc/*", rl2.ShareThresholdKeyPattern[0]) + asrt.True(rl2.DetailedMetric) + asrt.Equal("test-domain.service_svc/*.endpoint_get", rl2.Stats.Key, "share_threshold takes priority over detailed_metric, should use wildcard pattern with *") + asrt.Equal(rl2.Stats.Key, rl2.FullKey, "FullKey should match Stats.Key") + }) + + // Test Case 3: Nested wildcards with share_threshold + t.Run("Nested wildcards", func(t *testing.T) { + // Nested share_threshold + rl1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "nested", Value: "parent"}, + {Key: "files", Value: "nested/file1.txt"}, + }, + }, + ) + asrt.NotNil(rl1) + asrt.Equal("nested/*", rl1.ShareThresholdKeyPattern[1]) + asrt.EqualValues(200, rl1.Limit.RequestsPerUnit) + // Verify stats key includes wildcard pattern for nested share_threshold + asrt.Equal("test-domain.nested_parent.files_nested/*", rl1.Stats.Key, "Nested share_threshold should include wildcard pattern with *") + asrt.Equal(rl1.Stats.Key, rl1.FullKey, "FullKey should match Stats.Key") + + // Multiple wildcards + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "files", Value: "top/file1.txt"}, + {Key: "files", Value: "nested/file1.txt"}, + }, + }, + ) + asrt.NotNil(rl2) + asrt.Equal("top/*", rl2.ShareThresholdKeyPattern[0]) + asrt.Equal("nested/*", rl2.ShareThresholdKeyPattern[1]) + // Verify stats key includes wildcard patterns for multiple share_threshold entries + asrt.Equal("test-domain.files_top/*.files_nested/*", rl2.Stats.Key, "Multiple share_threshold entries should include all wildcard patterns") + asrt.Equal(rl2.Stats.Key, rl2.FullKey, "FullKey should match Stats.Key") + + // Parent has share_threshold, child does not + rl3 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "path", Value: "path/foo"}, + {Key: "file", Value: "file/doc1"}, + {Key: "type", Value: "pdf"}, + }, + }, + ) + asrt.NotNil(rl3) + asrt.Equal("path/*", rl3.ShareThresholdKeyPattern[0]) + asrt.Equal("", rl3.ShareThresholdKeyPattern[1]) + // Verify stats key: parent has share_threshold (wildcard pattern), child is wildcard without share_threshold (preserves original behavior with *) + // Note: file has wildcard pattern file/* in config but share_threshold: false, so it preserves original wildcard behavior + asrt.Equal("test-domain.path_path/*.file_file/*.type", rl3.Stats.Key, "Parent with share_threshold uses wildcard pattern, child wildcard without share_threshold preserves original behavior") + asrt.Equal(rl3.Stats.Key, rl3.FullKey, "FullKey should match Stats.Key") + + // Both parent and child have share_threshold + rl4 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "user", Value: "user/alice"}, + {Key: "resource", Value: "res/file1"}, + {Key: "action", Value: "read"}, + }, + }, + ) + asrt.NotNil(rl4) + asrt.Equal("user/*", rl4.ShareThresholdKeyPattern[0]) + asrt.Equal("res/*", rl4.ShareThresholdKeyPattern[1]) + // Verify stats key includes wildcard patterns for both parent and child with share_threshold + // Note: action is just a key with value, no wildcard, so it doesn't include the value in stats key (no value_to_metric or detailed_metric) + asrt.Equal("test-domain.user_user/*.resource_res/*.action", rl4.Stats.Key, "Both parent and child with share_threshold should include wildcard patterns, action key without flags uses just the key") + asrt.Equal(rl4.Stats.Key, rl4.FullKey, "FullKey should match Stats.Key") + }) + + // Test Case 4: Explicit value takes precedence over wildcard + // Verify: file_test1 uses isolated threshold, file_test2/test23/test123 share threshold + t.Run("Explicit value precedence over wildcard", func(t *testing.T) { + // Test explicit match: file_test1 should use isolated threshold (50/min) + rlTest1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "file", Value: "test1"}, + }, + }, + ) + asrt.NotNil(rlTest1, "Should find rate limit for file=test1") + asrt.EqualValues(50, rlTest1.Limit.RequestsPerUnit, "test1 should have isolated limit of 50") + // ShareThresholdKeyPattern should be nil for explicit matches (lazy initialization) + asrt.Nil(rlTest1.ShareThresholdKeyPattern, "ShareThresholdKeyPattern should be nil for explicit matches (no share_threshold)") + // Verify Stats: test1 should have its own stats key + asrt.Equal("test-domain.file_test1", rlTest1.Stats.Key, "test1 should have its own stats key") + + // Test wildcard matches: file_test2, file_test23, file_test123 should share threshold (100/min) + testWildcardValues := []string{"test2", "test23", "test123"} + var rateLimits []*config.RateLimit + + for _, value := range testWildcardValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "file", Value: value}, + }, + }, + ) + asrt.NotNil(rl, "Should find rate limit for file=%s", value) + + // Verify RateLimitConfig: Should have share_threshold pattern + asrt.NotNil(rl.ShareThresholdKeyPattern, "ShareThresholdKeyPattern should be set for %s", value) + asrt.Equal(1, len(rl.ShareThresholdKeyPattern), "Should have one pattern") + asrt.Equal("test*", rl.ShareThresholdKeyPattern[0], "Pattern should be test*") + asrt.EqualValues(100, rl.Limit.RequestsPerUnit, "Should have shared limit of 100") + + // Verify Stats: All wildcard matches should have the same stats key with wildcard pattern + asrt.Equal("test-domain.file_test*", rl.Stats.Key, "Stats key should include wildcard pattern with * when share_threshold is true") + + rateLimits = append(rateLimits, rl) + } + + // Verify all wildcard matches have same stats key + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All wildcard values should have same stats key") + } + + // Verify stats counters are shared for wildcard matches + for _, rl := range rateLimits { + rl.Stats.TotalHits.Inc() + } + counterValue := stats.NewCounter("test-domain.file_test*.total_hits").Value() + asrt.GreaterOrEqual(counterValue, uint64(len(testWildcardValues)), "All wildcard values should increment the same counter") + + // test1 should have its own counter + rlTest1.Stats.TotalHits.Inc() + counterTest1 := stats.NewCounter("test-domain.file_test1.total_hits").Value() + asrt.GreaterOrEqual(counterTest1, uint64(1), "test1 should increment its own counter") + }) + + // Test Case 5: share_threshold with middle wildcard (single *) + t.Run("share_threshold with middle wildcard", func(t *testing.T) { + testValues := []string{"/api/v1", "/api-beta-v1", "/apiXYZv1"} + + var rateLimits []*config.RateLimit + for _, value := range testValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midpath", Value: value}}, + }, + ) + asrt.NotNil(rl, "Should match middle wildcard for value %s", value) + asrt.NotNil(rl.ShareThresholdKeyPattern) + asrt.Equal("/api*v1", rl.ShareThresholdKeyPattern[0], "ShareThresholdKeyPattern should hold the wildcard pattern") + asrt.EqualValues(100, rl.Limit.RequestsPerUnit) + asrt.Equal("test-domain.midpath_/api*v1", rl.Stats.Key, "Stats key should use middle wildcard pattern") + asrt.Equal(rl.Stats.Key, rl.FullKey) + rateLimits = append(rateLimits, rl) + } + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All matching values should share the same stats key") + } + + // Non-matching values should not match + noMatch := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midpath", Value: "/api/v2"}}, + }, + ) + asrt.Nil(noMatch, "Value not satisfying prefix+suffix should not match") + }) + + // Test Case 6: share_threshold with multi-wildcard (multiple *) + t.Run("share_threshold with multi-wildcard", func(t *testing.T) { + testValues := []string{"svcAepBrpc", "svcXXXepYYYrpc", "svceprpc"} + + var rateLimits []*config.RateLimit + for _, value := range testValues { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiroute", Value: value}}, + }, + ) + asrt.NotNil(rl, "Should match multi-wildcard for value %s", value) + asrt.NotNil(rl.ShareThresholdKeyPattern) + asrt.Equal("svc*ep*rpc", rl.ShareThresholdKeyPattern[0]) + asrt.EqualValues(50, rl.Limit.RequestsPerUnit) + asrt.Equal("test-domain.multiroute_svc*ep*rpc", rl.Stats.Key, "Stats key should use multi-wildcard pattern") + asrt.Equal(rl.Stats.Key, rl.FullKey) + rateLimits = append(rateLimits, rl) + } + for i := 1; i < len(rateLimits); i++ { + asrt.Equal(rateLimits[0].Stats.Key, rateLimits[i].Stats.Key, "All matching values should share the same stats key") + } + + // Non-matching: missing middle segment + noMatch := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "multiroute", Value: "svcABCrpc"}}, + }, + ) + asrt.Nil(noMatch, "Value missing required middle segment 'ep' should not match") + }) + + // Test Case 7: share_threshold with middle wildcard — share_threshold takes priority over value_to_metric + t.Run("share_threshold priority over value_to_metric with middle wildcard", func(t *testing.T) { + rl1 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midroute", Value: "/api/v2"}}, + }, + ) + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "midroute", Value: "/api-beta-v2"}}, + }, + ) + asrt.NotNil(rl1) + asrt.NotNil(rl2) + asrt.Equal("/api*v2", rl1.ShareThresholdKeyPattern[0]) + asrt.Equal("test-domain.midroute_/api*v2", rl1.Stats.Key, "share_threshold should take priority over value_to_metric") + asrt.Equal(rl1.Stats.Key, rl2.Stats.Key, "Both values should share the same stats key") + }) + + // Test Case 8: share_threshold with nested middle wildcards + t.Run("share_threshold with nested middle wildcards", func(t *testing.T) { + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "tenant", Value: "t1prod"}, + {Key: "resource", Value: "resAv2"}, + }, + }, + ) + asrt.NotNil(rl) + asrt.Equal("t*prod", rl.ShareThresholdKeyPattern[0]) + asrt.Equal("res*v2", rl.ShareThresholdKeyPattern[1]) + asrt.Equal("test-domain.tenant_t*prod.resource_res*v2", rl.Stats.Key, "Nested middle wildcards with share_threshold should both use wildcard patterns in stats key") + asrt.Equal(rl.Stats.Key, rl.FullKey) + + // Different matching values should share the same stats key + rl2 := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "tenant", Value: "t99prod"}, + {Key: "resource", Value: "resBBBv2"}, + }, + }, + ) + asrt.NotNil(rl2) + asrt.Equal(rl.Stats.Key, rl2.Stats.Key, "Different matching values should share the same stats key") + }) +} + +// TestWildcardStatsBehavior verifies that trailing wildcards (foo*) and middle/multi wildcards (foo*bar, foo*bar*baz) +// produce identical stats key behavior for all flag combinations (no flags, value_to_metric, share_threshold). +// Since all wildcard patterns now go through the same wildcardEntries+wildcardMatch path, each sub-test +// runs the same scenario for both trailing and middle patterns and asserts equal outcomes. +func TestWildcardStatsBehavior(t *testing.T) { + asrt := assert.New(t) + store := stats.NewStore(stats.NewNullSink(), false) + + t.Run("No flags - preserves original behavior with wildcard pattern", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "wild", + Value: "foo*", + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 20, + Unit: "minute", + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo*", rl.Stats.Key) + asrt.Equal(rl.Stats.Key, rl.FullKey) + }) + + t.Run("value_to_metric - uses runtime value", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "wild", + Value: "foo*", + ValueToMetric: true, + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 20, + Unit: "minute", + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo1", rl.Stats.Key) + }) + + t.Run("share_threshold - uses wildcard pattern with *", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "wild", + Value: "foo*", + ShareThreshold: true, + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 20, + Unit: "minute", + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo*", rl.Stats.Key) + asrt.Equal(rl.Stats.Key, rl.FullKey) + }) + + t.Run("share_threshold with value_to_metric - share_threshold takes priority", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "wild", + Value: "foo*", + ShareThreshold: true, + ValueToMetric: true, + RateLimit: &config.YamlRateLimit{ + RequestsPerUnit: 20, + Unit: "minute", + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo1"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo*", rl.Stats.Key) + asrt.Equal(rl.Stats.Key, rl.FullKey) + }) + + t.Run("value_to_metric in other descriptor - wildcard pattern preserved", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{ + { + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + { + Key: "wild", + Value: "foo*", + Descriptors: []config.YamlDescriptor{ + { + Key: "other", + ValueToMetric: true, + Descriptors: []config.YamlDescriptor{ + { + Key: "limit", + RateLimit: &config.YamlRateLimit{RequestsPerUnit: 20, Unit: "minute"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{ + {Key: "wild", Value: "foo1"}, + {Key: "other", Value: "bar"}, + {Key: "limit", Value: "X"}, + }, + }, + ) + asrt.NotNil(rl) + // Wildcard pattern should be preserved even though value_to_metric is enabled on "other" + asrt.Equal("test-domain.wild_foo*.other_bar.limit", rl.Stats.Key, "Should preserve wildcard pattern when value_to_metric is only on other descriptor") + asrt.Equal(rl.Stats.Key, rl.FullKey, "FullKey should match Stats.Key") + }) + + // The following sub-tests mirror the trailing-* cases above using middle/multi wildcards, + // explicitly asserting that the unified wildcardEntries+wildcardMatch path produces identical + // flag behavior regardless of wildcard position. + + t.Run("middle+trailing wildcard - no flags - preserves wildcard pattern in stats key", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{{ + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + {Key: "wild", Value: "foo*bar*", RateLimit: &config.YamlRateLimit{RequestsPerUnit: 20, Unit: "minute"}}, + }, + }, + }} + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl1 := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, + }) + rl2 := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "fooXbarY"}}, + }) + asrt.NotNil(rl1) + asrt.NotNil(rl2) + asrt.Equal("test-domain.wild_foo*bar*", rl1.Stats.Key, "Middle+trailing wildcard with no flags should preserve wildcard pattern — same as trailing-only") + asrt.Equal(rl1.Stats.Key, rl2.Stats.Key, "Different matching values should share the same stats key") + asrt.Equal(rl1.Stats.Key, rl1.FullKey) + }) + + t.Run("middle+trailing wildcard - value_to_metric - uses runtime value", func(t *testing.T) { + cfg := []config.RateLimitConfigToLoad{{ + Name: "inline", + ConfigYaml: &config.YamlRoot{ + Domain: "test-domain", + Descriptors: []config.YamlDescriptor{ + {Key: "wild", Value: "foo*bar*", ValueToMetric: true, RateLimit: &config.YamlRateLimit{RequestsPerUnit: 20, Unit: "minute"}}, + }, + }, + }} + rlConfig := config.NewRateLimitConfigImpl(cfg, mockstats.NewMockStatManager(store), false) + rl := rlConfig.GetLimit(context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "wild", Value: "foo123bar456"}}, + }) + asrt.NotNil(rl) + asrt.Equal("test-domain.wild_foo123bar456", rl.Stats.Key, "Middle+trailing wildcard with value_to_metric should use runtime value — same as trailing-only") + }) +} + +func TestMetadata(t *testing.T) { + assert := assert.New(t) + stats := stats.NewStore(stats.NewNullSink(), false) + rlConfig := config.NewRateLimitConfigImpl(loadFile("metadata.yaml"), mockstats.NewMockStatManager(stats), false) + rlConfig.Dump() + assert.Equal(rlConfig.IsEmptyDomains(), false) + assert.Nil(rlConfig.GetLimit(context.TODO(), "test-domain", &pb_struct.RateLimitDescriptor{})) + assert.EqualValues(0, stats.NewCounter("test-domain.domain_not_found").Value()) + + rl := rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key1", Value: "value1"}, {Key: "subkey1", Value: "something"}}, + }, + ) + rl.Stats.TotalHits.Inc() + rl.Stats.OverLimit.Inc() + rl.Stats.NearLimit.Inc() + rl.Stats.WithinLimit.Inc() + assert.EqualValues(5, rl.Limit.RequestsPerUnit) + assert.Equal(pb.RateLimitResponse_RateLimit_SECOND, rl.Limit.Unit) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.total_hits").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.over_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.near_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key1_value1.subkey1.within_limit").Value()) + + // Verify metadata for key1_value1.subkey1 + assert.NotNil(rl.Metadata) + nameVal, ok := rl.Metadata.GetFields()["name"] + assert.True(ok) + assert.Equal("service_1", nameVal.GetStringValue()) + + rl = rlConfig.GetLimit( + context.TODO(), "test-domain", + &pb_struct.RateLimitDescriptor{ + Entries: []*pb_struct.RateLimitDescriptor_Entry{{Key: "key2", Value: "something"}}, + }, + ) + rl.Stats.TotalHits.Inc() + rl.Stats.OverLimit.Inc() + rl.Stats.NearLimit.Inc() + rl.Stats.WithinLimit.Inc() + assert.EqualValues(20, rl.Limit.RequestsPerUnit) + assert.Equal(pb.RateLimitResponse_RateLimit_MINUTE, rl.Limit.Unit) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.total_hits").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.over_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.near_limit").Value()) + assert.EqualValues(1, stats.NewCounter("test-domain.key2.within_limit").Value()) + + assert.NotNil(rl.Metadata) + serviceVal, ok := rl.Metadata.GetFields()["service_with_quota"] + assert.True(ok) + assert.Equal("service_2", serviceVal.GetStructValue().GetFields()["name"].GetStringValue()) +} + +// share_threshold parity (middle+trailing wildcards produce the same shared-counter +// behaviour as trailing-only) is covered by TestShareThreshold Cases 5-7. diff --git a/test/config/metadata.yaml b/test/config/metadata.yaml new file mode 100644 index 000000000..cb1dadbc6 --- /dev/null +++ b/test/config/metadata.yaml @@ -0,0 +1,28 @@ +domain: test-domain +descriptors: + # Top level key/value with no default rate limit. + - key: key1 + value: value1 + descriptors: + # 2nd level key only with default rate limit. + - key: subkey1 + rate_limit: + unit: second + requests_per_unit: 5 + metadata: + name: service_1 + + # 2nd level key/value with limit. Specific override at 2nd level. + - key: subkey1 + value: subvalue1 + rate_limit: + unit: second + requests_per_unit: 10 + + - key: key2 + rate_limit: + unit: minute + requests_per_unit: 20 + metadata: + service_with_quota: + name: service_2 diff --git a/test/config/share_threshold.yaml b/test/config/share_threshold.yaml new file mode 100644 index 000000000..6ebce6a33 --- /dev/null +++ b/test/config/share_threshold.yaml @@ -0,0 +1,120 @@ +# Configuration for testing share_threshold feature +domain: test-domain +descriptors: + - key: files + value: files/* + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 100 + - key: nested + value: parent + descriptors: + - key: files + value: nested/* + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 200 + # Test case for nested descriptors with same key name at different levels + - key: files + value: top/* + share_threshold: true + descriptors: + - key: files + value: nested/* + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 300 + # Test case for share_threshold with value_to_metric + - key: route + value: api/* + share_threshold: true + value_to_metric: true + descriptors: + - key: method + rate_limit: + unit: minute + requests_per_unit: 60 + # Test case for share_threshold with detailed_metric + - key: service + value: svc/* + share_threshold: true + descriptors: + - key: endpoint + detailed_metric: true + rate_limit: + unit: minute + requests_per_unit: 80 + # Test case for nested descriptors with multiple wildcards - mixed share_threshold + - key: path + value: path/* + share_threshold: true + descriptors: + - key: file + value: file/* + share_threshold: false + descriptors: + - key: type + rate_limit: + unit: minute + requests_per_unit: 120 + # Test case for nested descriptors with multiple wildcards - both with share_threshold + - key: user + value: user/* + share_threshold: true + descriptors: + - key: resource + value: res/* + share_threshold: true + descriptors: + - key: action + rate_limit: + unit: minute + requests_per_unit: 150 + # Test case: explicit value takes precedence over wildcard with share_threshold + - key: file + value: test1 + rate_limit: + unit: minute + requests_per_unit: 50 + - key: file + value: test* + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 100 + # Test case: share_threshold with middle wildcard (single *) + - key: midpath + value: /api*v1 + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 100 + # Test case: share_threshold with multi-wildcard (multiple *) + - key: multiroute + value: svc*ep*rpc + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 50 + # Test case: share_threshold with middle wildcard + value_to_metric (share_threshold takes priority) + - key: midroute + value: /api*v2 + share_threshold: true + value_to_metric: true + rate_limit: + unit: minute + requests_per_unit: 80 + # Test case: share_threshold with middle wildcard nested + - key: tenant + value: t*prod + share_threshold: true + descriptors: + - key: resource + value: res*v2 + share_threshold: true + rate_limit: + unit: minute + requests_per_unit: 60 diff --git a/test/config/wildcard.yaml b/test/config/wildcard.yaml index e99bac663..3a1effcf3 100644 --- a/test/config/wildcard.yaml +++ b/test/config/wildcard.yaml @@ -20,6 +20,11 @@ descriptors: rate_limit: unit: minute requests_per_unit: 20 + - key: multiWild + value: foo*bar*baz + rate_limit: + unit: minute + requests_per_unit: 10 - key: nestedWild value: val1 descriptors: diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index f134b7593..e5f6eb11f 100644 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -4,6 +4,7 @@ package integration_test import ( "crypto/tls" + "crypto/x509" "fmt" "io" "math/rand" @@ -13,23 +14,27 @@ import ( "testing" "time" + "github.com/bradfitz/gomemcache/memcache" pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" - "github.com/golang/protobuf/ptypes/duration" "github.com/kelseyhightower/envconfig" "github.com/stretchr/testify/assert" "golang.org/x/net/context" "google.golang.org/grpc" "google.golang.org/grpc/credentials" - "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/durationpb" "github.com/goatapp/ratelimit/src/memcached" "github.com/goatapp/ratelimit/src/service_cmd/runner" "github.com/goatapp/ratelimit/src/settings" + "github.com/goatapp/ratelimit/src/stats" "github.com/goatapp/ratelimit/src/utils" "github.com/goatapp/ratelimit/test/common" ) -var projectDir = os.Getenv("PROJECT_DIR") +var ( + projectDir = os.Getenv("PROJECT_DIR") + statsPrefix = stats.GetStatsScope() +) func init() { os.Setenv("USE_STATSD", "false") @@ -60,14 +65,14 @@ func defaultSettings() settings.Settings { return s } -func newDescriptorStatus(status pb.RateLimitResponse_Code, requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Unit, limitRemaining uint32, durRemaining *duration.Duration) *pb.RateLimitResponse_DescriptorStatus { +func newDescriptorStatus(status pb.RateLimitResponse_Code, requestsPerUnit uint32, unit pb.RateLimitResponse_RateLimit_Unit, limitRemaining uint32, durRemaining *durationpb.Duration) *pb.RateLimitResponse_DescriptorStatus { limit := &pb.RateLimitResponse_RateLimit{RequestsPerUnit: requestsPerUnit, Unit: unit} return &pb.RateLimitResponse_DescriptorStatus{ Code: status, CurrentLimit: limit, LimitRemaining: limitRemaining, - DurationUntilReset: &duration.Duration{Seconds: durRemaining.GetSeconds()}, + DurationUntilReset: &durationpb.Duration{Seconds: durRemaining.GetSeconds()}, } } @@ -139,7 +144,8 @@ func TestBasicConfig_ExtraTags(t *testing.T) { _, err = c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("basic", [][][2]string{{{getCacheKey("key1", false), "foo"}}}, 1)) + common.NewRateLimitRequest("basic", [][][2]string{{{getCacheKey("key1", false), "foo"}}}, 1), + ) assert.NoError(err) // Manually flush the cache for local_cache stats @@ -148,13 +154,15 @@ func TestBasicConfig_ExtraTags(t *testing.T) { // store.NewCounter returns the existing counter. // This test looks for the extra tags requested. key1HitCounter := runner.GetStatsStore().NewCounterWithTags( - fmt.Sprintf("app.ratelimit.local.service.rate_limit.basic.%s.total_hits", getCacheKey("key1", false)), - extraTagsSettings.ExtraTags) + fmt.Sprintf(statsPrefix+".service.rate_limit.basic.%s.total_hits", getCacheKey("key1", false)), + extraTagsSettings.ExtraTags, + ) assert.Equal(1, int(key1HitCounter.Value())) configLoadStat := runner.GetStatsStore().NewCounterWithTags( - "app.ratelimit.local.service.config_load_success", - extraTagsSettings.ExtraTags) + statsPrefix+".service.config_load_success", + extraTagsSettings.ExtraTags, + ) assert.Equal(1, int(configLoadStat.Value())) // NOTE: This doesn't currently test that the extra tags are present for: @@ -186,7 +194,6 @@ func TestBasicAuthConfig(t *testing.T) { } func TestBasicAuthConfigWithRedisCluster(t *testing.T) { - t.Skipf("skipping due to flakiness") t.Run("WithoutPerSecondRedisAuth", testBasicConfigAuthWithRedisCluster(false, 0)) t.Run("WithPerSecondRedisAuth", testBasicConfigAuthWithRedisCluster(true, 0)) t.Run("WithoutPerSecondRedisAuthWithLocalCache", testBasicConfigAuthWithRedisCluster(false, 1000)) @@ -254,6 +261,10 @@ func TestConfigMemcacheWithMaxIdleConns(t *testing.T) { withDefaultMaxIdleConns := makeSimpleMemcacheSettings(singleNodePort, 0) assert.Equal(2, withDefaultMaxIdleConns.MemcacheMaxIdleConns) t.Run("MemcacheWithDefaultMaxIdleConns", testBasicConfig(withDefaultMaxIdleConns)) + + mc := memcache.New("localhost:6394") + assert.Nil(mc.FlushAll()) + withSpecifiedMaxIdleConns := makeSimpleMemcacheSettings(singleNodePort, 0) withSpecifiedMaxIdleConns.MemcacheMaxIdleConns = 100 t.Run("MemcacheWithSpecifiedMaxIdleConns", testBasicConfig(withSpecifiedMaxIdleConns)) @@ -296,6 +307,92 @@ func Test_mTLS(t *testing.T) { defer conn.Close() } +func TestReloadGRPCServerCerts(t *testing.T) { + common.WithMultiRedis(t, []common.RedisConfig{ + {Port: 6383}, + }, func() { + s := makeSimpleRedisSettings(6383, 6380, false, 0) + assert := assert.New(t) + // TLS setup initially used to configure the server + initialServerCAFile, initialServerCertFile, initialServerCertKey, err := mTLSSetup(utils.ServerCA) + assert.NoError(err) + // Second TLS setup that will replace the above during test + newServerCAFile, newServerCertFile, newServerCertKey, err := mTLSSetup(utils.ServerCA) + assert.NoError(err) + // Create CertPools and tls.Configs for both CAs + initialCaCert, err := os.ReadFile(initialServerCAFile) + assert.NoError(err) + initialCertPool := x509.NewCertPool() + initialCertPool.AppendCertsFromPEM(initialCaCert) + initialTlsConfig := &tls.Config{ + RootCAs: initialCertPool, + } + newCaCert, err := os.ReadFile(newServerCAFile) + assert.NoError(err) + newCertPool := x509.NewCertPool() + newCertPool.AppendCertsFromPEM(newCaCert) + newTlsConfig := &tls.Config{ + RootCAs: newCertPool, + } + connStr := fmt.Sprintf("localhost:%v", s.GrpcPort) + + // Set up ratelimit with the initial certificate + s.GrpcServerUseTLS = true + s.GrpcServerTlsCert = initialServerCertFile + s.GrpcServerTlsKey = initialServerCertKey + settings.GrpcServerTlsConfig()(&s) + runner := startTestRunner(t, s) + defer runner.Stop() + + // Ensure TLS validation works with the initial CA in cert pool + t.Run("WithInitialCert", func(t *testing.T) { + conn, err := tls.Dial("tcp", connStr, initialTlsConfig) + assert.NoError(err) + conn.Close() + }) + + // Ensure TLS validation fails with the new CA in cert pool + t.Run("WithNewCertFail", func(t *testing.T) { + conn, err := tls.Dial("tcp", connStr, newTlsConfig) + assert.Error(err) + if err == nil { + conn.Close() + } + }) + + // Replace the initial certificate with the new one + err = os.Rename(newServerCertFile, initialServerCertFile) + assert.NoError(err) + err = os.Rename(newServerCertKey, initialServerCertKey) + assert.NoError(err) + + // Ensure TLS validation works with the new CA in cert pool + t.Run("WithNewCertOK", func(t *testing.T) { + // If this takes longer than 10s, something is probably wrong + wait := 10 + for i := 0; i < wait; i++ { + // Ensure the new certificate is being used + conn, err := tls.Dial("tcp", connStr, newTlsConfig) + if err == nil { + conn.Close() + break + } + time.Sleep(1 * time.Second) + } + assert.NoError(err) + }) + + // Ensure TLS validation fails with the initial CA in cert pool + t.Run("WithInitialCertFail", func(t *testing.T) { + conn, err := tls.Dial("tcp", connStr, initialTlsConfig) + assert.Error(err) + if err == nil { + conn.Close() + } + }) + }) +} + func testBasicConfigAuthTLS(perSecond bool, local_cache_size int) func(*testing.T) { s := makeSimpleRedisSettings(16381, 16382, perSecond, local_cache_size) s.RedisTlsConfig = &tls.Config{} @@ -386,7 +483,9 @@ func configRedisCluster(s *settings.Settings) { s.RedisAuth = "password123" s.RedisPerSecondAuth = "password123" - s.RedisImplicitPipeline = true + // RedisPipelineLimit is deprecated in radix v4, use RedisPipelineWindow instead + s.RedisPerSecondPipelineWindow = 150 * time.Microsecond + s.RedisPipelineWindow = 150 * time.Microsecond } func testBasicConfigWithoutWatchRootWithRedisCluster(perSecond bool, local_cache_size int) func(*testing.T) { @@ -461,7 +560,6 @@ func getCacheKey(cacheKey string, enableLocalCache bool) string { func testBasicBaseConfig(s settings.Settings) func(*testing.T) { return func(t *testing.T) { - t.Skipf("skipping for now") enable_local_cache := s.LocalCacheSizeInBytes > 0 runner := startTestRunner(t, s) defer runner.Stop() @@ -474,62 +572,54 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { response, err := c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("foo", [][][2]string{{{getCacheKey("hello", enable_local_cache), "world"}}}, 1)) + common.NewRateLimitRequest("foo", [][][2]string{{{getCacheKey("hello", enable_local_cache), "world"}}}, 1), + ) common.AssertProtoEqual( assert, &pb.RateLimitResponse{ OverallCode: pb.RateLimitResponse_OK, Statuses: []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}, }, - response) + response, + ) assert.NoError(err) // Manually flush the cache for local_cache stats runner.GetStatsStore().Flush() - localCacheHitCounter := runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.hitCount") + localCacheHitCounter := runner.GetStatsStore().NewGauge("ratelimit.localcache.hitCount") assert.Equal(0, int(localCacheHitCounter.Value())) - localCacheMissCounter := runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.missCount") + localCacheMissCounter := runner.GetStatsStore().NewGauge("ratelimit.localcache.missCount") assert.Equal(0, int(localCacheMissCounter.Value())) response, err = c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("basic", [][][2]string{{{getCacheKey("key1", enable_local_cache), "foo"}}}, 1)) + common.NewRateLimitRequest("basic", [][][2]string{{{getCacheKey("key1", enable_local_cache), "foo"}}}, 1), + ) durRemaining := response.GetStatuses()[0].DurationUntilReset - expectedResponse_48 := &pb.RateLimitResponse{ - OverallCode: pb.RateLimitResponse_OK, - Statuses: []*pb.RateLimitResponse_DescriptorStatus{ - newDescriptorStatus(pb.RateLimitResponse_OK, 50, pb.RateLimitResponse_RateLimit_SECOND, 48, durRemaining), - }, - } - - expectedResponse_49 := &pb.RateLimitResponse{ - OverallCode: pb.RateLimitResponse_OK, - Statuses: []*pb.RateLimitResponse_DescriptorStatus{ - newDescriptorStatus(pb.RateLimitResponse_OK, 50, pb.RateLimitResponse_RateLimit_SECOND, 49, durRemaining), + common.AssertProtoEqual( + assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + newDescriptorStatus(pb.RateLimitResponse_OK, 50, pb.RateLimitResponse_RateLimit_SECOND, 49, durRemaining), + }, }, - } - - assert.Condition(func() bool { - if proto.Equal(expectedResponse_48, response) || proto.Equal(expectedResponse_49, response) { - return true - } - - return false - }, fmt.Sprintf("These protobuf messages are not equal:\nexpected_1: %v\n or expected_2: %v\n actual: %v", expectedResponse_48, expectedResponse_49, response)) + response, + ) assert.NoError(err) // store.NewCounter returns the existing counter. - key1HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.basic.%s.total_hits", getCacheKey("key1", enable_local_cache))) + key1HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.basic.%s.total_hits", getCacheKey("key1", enable_local_cache))) assert.Equal(1, int(key1HitCounter.Value())) // Manually flush the cache for local_cache stats runner.GetStatsStore().Flush() - localCacheHitCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.hitCount") + localCacheHitCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.hitCount") assert.Equal(0, int(localCacheHitCounter.Value())) - localCacheMissCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.missCount") + localCacheMissCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.missCount") if enable_local_cache { assert.Equal(1, int(localCacheMissCounter.Value())) } else { @@ -543,7 +633,9 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { response, err = c.ShouldRateLimit( context.Background(), common.NewRateLimitRequest( - "another", [][][2]string{{{getCacheKey("key2", enable_local_cache), strconv.Itoa(randomInt)}}}, 1)) + "another", [][][2]string{{{getCacheKey("key2", enable_local_cache), strconv.Itoa(randomInt)}}}, 1, + ), + ) status := pb.RateLimitResponse_OK limitRemaining := uint32(20 - (i + 1)) @@ -561,17 +653,18 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { newDescriptorStatus(status, 20, pb.RateLimitResponse_RateLimit_MINUTE, limitRemaining, durRemaining), }, }, - response) + response, + ) assert.NoError(err) - key2HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.total_hits", getCacheKey("key2", enable_local_cache))) + key2HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.total_hits", getCacheKey("key2", enable_local_cache))) assert.Equal(i+1, int(key2HitCounter.Value())) - key2OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit", getCacheKey("key2", enable_local_cache))) + key2OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit", getCacheKey("key2", enable_local_cache))) if i >= 20 { assert.Equal(i-19, int(key2OverlimitCounter.Value())) } else { assert.Equal(0, int(key2OverlimitCounter.Value())) } - key2LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key2", enable_local_cache))) + key2LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key2", enable_local_cache))) if enable_local_cache && i >= 20 { assert.Equal(i-20, int(key2LocalCacheOverLimitCounter.Value())) } else { @@ -580,14 +673,14 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { // Manually flush the cache for local_cache stats runner.GetStatsStore().Flush() - localCacheHitCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.hitCount") + localCacheHitCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.hitCount") if enable_local_cache && i >= 20 { assert.Equal(i-20, int(localCacheHitCounter.Value())) } else { assert.Equal(0, int(localCacheHitCounter.Value())) } - localCacheMissCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.missCount") + localCacheMissCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.missCount") if enable_local_cache { if i < 20 { assert.Equal(i+2, int(localCacheMissCounter.Value())) @@ -609,7 +702,9 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { [][][2]string{ {{getCacheKey("key2", enable_local_cache), strconv.Itoa(randomInt)}}, {{getCacheKey("key3", enable_local_cache), strconv.Itoa(randomInt)}}, - }, 1)) + }, 1, + ), + ) status := pb.RateLimitResponse_OK limitRemaining1 := uint32(20 - (i + 1)) @@ -634,29 +729,30 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { newDescriptorStatus(status, 10, pb.RateLimitResponse_RateLimit_HOUR, limitRemaining2, durRemaining2), }, }, - response) + response, + ) assert.NoError(err) - key2HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.total_hits", getCacheKey("key2", enable_local_cache))) + key2HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.total_hits", getCacheKey("key2", enable_local_cache))) assert.Equal(i+26, int(key2HitCounter.Value())) - key2OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit", getCacheKey("key2", enable_local_cache))) + key2OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit", getCacheKey("key2", enable_local_cache))) assert.Equal(5, int(key2OverlimitCounter.Value())) - key2LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key2", enable_local_cache))) + key2LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key2", enable_local_cache))) if enable_local_cache { assert.Equal(4, int(key2LocalCacheOverLimitCounter.Value())) } else { assert.Equal(0, int(key2LocalCacheOverLimitCounter.Value())) } - key3HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.total_hits", getCacheKey("key3", enable_local_cache))) + key3HitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.total_hits", getCacheKey("key3", enable_local_cache))) assert.Equal(i+1, int(key3HitCounter.Value())) - key3OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit", getCacheKey("key3", enable_local_cache))) + key3OverlimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit", getCacheKey("key3", enable_local_cache))) if i >= 10 { assert.Equal(i-9, int(key3OverlimitCounter.Value())) } else { assert.Equal(0, int(key3OverlimitCounter.Value())) } - key3LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf("app.ratelimit.local.service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key3", enable_local_cache))) + key3LocalCacheOverLimitCounter := runner.GetStatsStore().NewCounter(fmt.Sprintf(statsPrefix+".service.rate_limit.another.%s.over_limit_with_local_cache", getCacheKey("key3", enable_local_cache))) if enable_local_cache && i >= 10 { assert.Equal(i-10, int(key3LocalCacheOverLimitCounter.Value())) } else { @@ -665,7 +761,7 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { // Manually flush the cache for local_cache stats runner.GetStatsStore().Flush() - localCacheHitCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.hitCount") + localCacheHitCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.hitCount") if enable_local_cache { if i < 10 { assert.Equal(4, int(localCacheHitCounter.Value())) @@ -677,7 +773,7 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { assert.Equal(0, int(localCacheHitCounter.Value())) } - localCacheMissCounter = runner.GetStatsStore().NewGauge("app.ratelimit.local.localcache.missCount") + localCacheMissCounter = runner.GetStatsStore().NewGauge("ratelimit.localcache.missCount") if enable_local_cache { if i < 10 { // both key2 and key3 cache miss. @@ -694,13 +790,15 @@ func testBasicBaseConfig(s settings.Settings) func(*testing.T) { // Test DurationUntilReset by hitting same key twice resp1, err := c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("another", [][][2]string{{{getCacheKey("key4", enable_local_cache), "durTest"}}}, 1)) + common.NewRateLimitRequest("another", [][][2]string{{{getCacheKey("key4", enable_local_cache), "durTest"}}}, 1), + ) time.Sleep(2 * time.Second) // Wait to allow duration to tick down resp2, err := c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("another", [][][2]string{{{getCacheKey("key4", enable_local_cache), "durTest"}}}, 1)) + common.NewRateLimitRequest("another", [][][2]string{{{getCacheKey("key4", enable_local_cache), "durTest"}}}, 1), + ) assert.Less(resp2.GetStatuses()[0].DurationUntilReset.GetSeconds(), resp1.GetStatuses()[0].DurationUntilReset.GetSeconds()) } @@ -723,7 +821,8 @@ func startTestRunner(t *testing.T, s settings.Settings) *runner.Runner { }() // HACK: Wait for the server to come up. Make a hook that we can wait on. - common.WaitForTcpPort(context.Background(), s.GrpcPort, 1*time.Second) + // Increased timeout from 1s to 10s to allow for Redis cluster connection initialization + common.WaitForTcpPort(context.Background(), s.GrpcPort, 10*time.Second) return &runner } @@ -742,18 +841,20 @@ func testConfigReload(s settings.Settings, reloadConfFunc, restoreConfFunc func( response, err := c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("reload", [][][2]string{{{getCacheKey("block", enable_local_cache), "foo"}}}, 1)) + common.NewRateLimitRequest("reload", [][][2]string{{{getCacheKey("block", enable_local_cache), "foo"}}}, 1), + ) common.AssertProtoEqual( assert, &pb.RateLimitResponse{ OverallCode: pb.RateLimitResponse_OK, Statuses: []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK}}, }, - response) + response, + ) assert.NoError(err) runner.GetStatsStore().Flush() - loadCountBefore := runner.GetStatsStore().NewCounter("app.ratelimit.local.service.config_load_success").Value() + loadCountBefore := runner.GetStatsStore().NewCounter(statsPrefix + ".service.config_load_success").Value() reloadConfFunc() loadCountAfter, reloaded := waitForConfigReload(runner, loadCountBefore) @@ -763,7 +864,8 @@ func testConfigReload(s settings.Settings, reloadConfFunc, restoreConfFunc func( response, err = c.ShouldRateLimit( context.Background(), - common.NewRateLimitRequest("reload", [][][2]string{{{getCacheKey("key1", enable_local_cache), "foo"}}}, 1)) + common.NewRateLimitRequest("reload", [][][2]string{{{getCacheKey("key1", enable_local_cache), "foo"}}}, 1), + ) durRemaining := response.GetStatuses()[0].DurationUntilReset common.AssertProtoEqual( @@ -774,7 +876,8 @@ func testConfigReload(s settings.Settings, reloadConfFunc, restoreConfFunc func( newDescriptorStatus(pb.RateLimitResponse_OK, 50, pb.RateLimitResponse_RateLimit_SECOND, 49, durRemaining), }, }, - response) + response, + ) assert.NoError(err) restoreConfFunc() @@ -825,7 +928,7 @@ func waitForConfigReload(runner *runner.Runner, loadCountBefore uint64) (uint64, for i := 0; i < wait; i++ { time.Sleep(1 * time.Second) runner.GetStatsStore().Flush() - loadCountAfter = runner.GetStatsStore().NewCounter("app.ratelimit.local.service.config_load_success").Value() + loadCountAfter = runner.GetStatsStore().NewCounter(statsPrefix + ".service.config_load_success").Value() // Check that successful loads count has increased before continuing. if loadCountAfter > loadCountBefore { @@ -835,3 +938,87 @@ func waitForConfigReload(runner *runner.Runner, loadCountBefore uint64) (uint64, } return loadCountAfter, reloaded } + +func TestShareThreshold(t *testing.T) { + common.WithMultiRedis(t, []common.RedisConfig{ + {Port: 6383}, + {Port: 6380}, + }, func() { + t.Run("WithoutPerSecondRedis", testShareThreshold(makeSimpleRedisSettings(6383, 6380, false, 0))) + }) +} + +func testShareThreshold(s settings.Settings) func(*testing.T) { + return func(t *testing.T) { + runner := startTestRunner(t, s) + defer runner.Stop() + + assert := assert.New(t) + conn, err := grpc.Dial(fmt.Sprintf("localhost:%v", s.GrpcPort), grpc.WithInsecure()) + assert.NoError(err) + defer conn.Close() + c := pb.NewRateLimitServiceClient(conn) + + // Use the domain from the config file + domain := "share-threshold-test" + + // Test Case 1: share_threshold: true - different values matching files/* should share the same threshold + // Make 10 requests with files/a.pdf - can be OK or OVER_LIMIT + for i := 0; i < 10; i++ { + response, err := c.ShouldRateLimit( + context.Background(), + common.NewRateLimitRequest(domain, [][][2]string{{{"files", "files/a.pdf"}}}, 1), + ) + assert.NoError(err) + // Each request can be OK or OVER_LIMIT (depending on when limit is reached) + assert.True(response.OverallCode == pb.RateLimitResponse_OK || response.OverallCode == pb.RateLimitResponse_OVER_LIMIT, + "Request %d should be OK or OVER_LIMIT, got: %v", i+1, response.OverallCode) + } + + // Now make a request with files/b.csv - must be OVER_LIMIT because it shares the threshold + response, err := c.ShouldRateLimit( + context.Background(), + common.NewRateLimitRequest(domain, [][][2]string{{{"files", "files/b.csv"}}}, 1), + ) + assert.NoError(err) + durRemaining := response.GetStatuses()[0].DurationUntilReset + common.AssertProtoEqual( + assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + newDescriptorStatus(pb.RateLimitResponse_OVER_LIMIT, 10, pb.RateLimitResponse_RateLimit_HOUR, 0, durRemaining), + }, + }, + response, + ) + + // Test Case 2: share_threshold: false - different values should have isolated thresholds + // Use random values with prefix files_no_share to ensure uniqueness (based on timestamp) + // Each value should have its own isolated threshold, so all 10 requests should be OK + baseTimestamp := time.Now().UnixNano() + r := rand.New(rand.NewSource(baseTimestamp)) + for i := 0; i < 10; i++ { + // Generate unique value using timestamp and random number to avoid collisions + uniqueValue := fmt.Sprintf("files_no_share/%d-%d", baseTimestamp, r.Int63()) + response, err := c.ShouldRateLimit( + context.Background(), + common.NewRateLimitRequest(domain, [][][2]string{{{"files_no_share", uniqueValue}}}, 1), + ) + assert.NoError(err) + // Each value has its own isolated threshold, so each request should have remaining = 9 (10 - 1) + expectedRemaining := uint32(9) + durRemaining := response.GetStatuses()[0].DurationUntilReset + common.AssertProtoEqual( + assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + newDescriptorStatus(pb.RateLimitResponse_OK, 10, pb.RateLimitResponse_RateLimit_HOUR, expectedRemaining, durRemaining), + }, + }, + response, + ) + } + } +} diff --git a/test/integration/runtime/current/ratelimit/config/share_threshold.yaml b/test/integration/runtime/current/ratelimit/config/share_threshold.yaml new file mode 100644 index 000000000..33b55dc4f --- /dev/null +++ b/test/integration/runtime/current/ratelimit/config/share_threshold.yaml @@ -0,0 +1,15 @@ +domain: share-threshold-test +descriptors: + # Test case 1: share_threshold: true - files/* should share threshold + - key: files + value: files/* + share_threshold: true + rate_limit: + unit: hour + requests_per_unit: 10 + + - key: files_no_share + value: files_no_share/* + rate_limit: + unit: hour + requests_per_unit: 10 diff --git a/test/limiter/base_limiter_test.go b/test/limiter/base_limiter_test.go index 90d8117e7..4490a789a 100644 --- a/test/limiter/base_limiter_test.go +++ b/test/limiter/base_limiter_test.go @@ -29,9 +29,9 @@ func TestGenerateCacheKeys(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, rand.New(jitterSource), 3600, nil, 0.8, "", sm) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal(uint64(0), limits[0].Stats.TotalHits.Value()) - cacheKeys := baseRateLimit.GenerateCacheKeys(request, limits, 1) + cacheKeys := baseRateLimit.GenerateCacheKeys(request, limits, []uint64{1}) assert.Equal(1, len(cacheKeys)) assert.Equal("domain_key_value", cacheKeys[0].Key) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) @@ -47,14 +47,94 @@ func TestGenerateCacheKeysPrefix(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, rand.New(jitterSource), 3600, nil, 0.8, "prefix:", sm) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal(uint64(0), limits[0].Stats.TotalHits.Value()) - cacheKeys := baseRateLimit.GenerateCacheKeys(request, limits, 1) + cacheKeys := baseRateLimit.GenerateCacheKeys(request, limits, []uint64{1}) assert.Equal(1, len(cacheKeys)) assert.Equal("prefix:domain_key_value", cacheKeys[0].Key) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) } +func TestGenerateCacheKeysWithShareThreshold(t *testing.T) { + assert := assert.New(t) + controller := gomock.NewController(t) + defer controller.Finish() + timeSource := mock_utils.NewMockTimeSource(controller) + jitterSource := mock_utils.NewMockJitterRandSource(controller) + statsStore := stats.NewStore(stats.NewNullSink(), false) + sm := mockstats.NewMockStatManager(statsStore) + baseRateLimit := limiter.NewBaseRateLimit(timeSource, rand.New(jitterSource), 3600, nil, 0.8, "", sm) + + // Test 1: Simple case - different values with same wildcard prefix generate same cache key + limit := config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("files_files/*"), false, false, false, "", nil, false) + limit.ShareThresholdKeyPattern = []string{"files/*"} // Entry at index 0 + + request1 := common.NewRateLimitRequest("domain", [][][2]string{{{"files", "files/a.pdf"}}}, 1) + limits1 := []*config.RateLimit{limit} + cacheKeys1 := baseRateLimit.GenerateCacheKeys(request1, limits1, []uint64{1}) + assert.Equal(1, len(cacheKeys1)) + assert.Equal("domain_files_files/*", cacheKeys1[0].Key) + + request2 := common.NewRateLimitRequest("domain", [][][2]string{{{"files", "files/b.csv"}}}, 1) + limits2 := []*config.RateLimit{limit} + cacheKeys2 := baseRateLimit.GenerateCacheKeys(request2, limits2, []uint64{1}) + assert.Equal(1, len(cacheKeys2)) + // Should generate the same cache key as the first request + assert.Equal("domain_files_files/*", cacheKeys2[0].Key) + assert.Equal(cacheKeys1[0].Key, cacheKeys2[0].Key) + + // Test 2: Multiple different values all generate the same cache key + testValues := []string{"files/c.txt", "files/d.json", "files/e.xml", "files/subdir/f.txt"} + for _, value := range testValues { + request := common.NewRateLimitRequest("domain", [][][2]string{{{"files", value}}}, 1) + cacheKeys := baseRateLimit.GenerateCacheKeys(request, limits1, []uint64{1}) + assert.Equal(1, len(cacheKeys)) + assert.Equal("domain_files_files/*", cacheKeys[0].Key, "Value %s should generate same cache key", value) + } + + // Test 3: Nested descriptors with share_threshold at second level + limitNested := config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("parent_files_nested/*"), false, false, false, "", nil, false) + limitNested.ShareThresholdKeyPattern = []string{"", "nested/*"} // First entry no share_threshold, second entry has it + + request3a := common.NewRateLimitRequest("domain", [][][2]string{ + {{"parent", "value1"}, {"files", "nested/file1.txt"}}, + }, 1) + limits3a := []*config.RateLimit{limitNested} + cacheKeys3a := baseRateLimit.GenerateCacheKeys(request3a, limits3a, []uint64{1}) + assert.Equal(1, len(cacheKeys3a)) + assert.Equal("domain_parent_value1_files_nested/*", cacheKeys3a[0].Key) + + request3b := common.NewRateLimitRequest("domain", [][][2]string{ + {{"parent", "value1"}, {"files", "nested/file2.csv"}}, + }, 1) + cacheKeys3b := baseRateLimit.GenerateCacheKeys(request3b, limits3a, []uint64{1}) + assert.Equal(1, len(cacheKeys3b)) + // Should generate the same cache key despite different file values + assert.Equal("domain_parent_value1_files_nested/*", cacheKeys3b[0].Key) + assert.Equal(cacheKeys3a[0].Key, cacheKeys3b[0].Key) + + // Test 4: Multiple entries with share_threshold at different positions + limitMulti := config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("files_top/*_files_nested/*"), false, false, false, "", nil, false) + limitMulti.ShareThresholdKeyPattern = []string{"top/*", "nested/*"} // Both entries have share_threshold + + request4a := common.NewRateLimitRequest("domain", [][][2]string{ + {{"files", "top/file1.txt"}, {"files", "nested/sub1.txt"}}, + }, 1) + limits4a := []*config.RateLimit{limitMulti} + cacheKeys4a := baseRateLimit.GenerateCacheKeys(request4a, limits4a, []uint64{1}) + assert.Equal(1, len(cacheKeys4a)) + assert.Equal("domain_files_top/*_files_nested/*", cacheKeys4a[0].Key) + + request4b := common.NewRateLimitRequest("domain", [][][2]string{ + {{"files", "top/file2.pdf"}, {"files", "nested/sub2.csv"}}, + }, 1) + cacheKeys4b := baseRateLimit.GenerateCacheKeys(request4b, limits4a, []uint64{1}) + assert.Equal(1, len(cacheKeys4b)) + // Should generate the same cache key despite different values + assert.Equal("domain_files_top/*_files_nested/*", cacheKeys4b[0].Key) + assert.Equal(cacheKeys4a[0].Key, cacheKeys4b[0].Key) +} + func TestOverLimitWithLocalCache(t *testing.T) { assert := assert.New(t) controller := gomock.NewController(t) @@ -101,7 +181,7 @@ func TestGetResponseStatusOverLimitWithLocalCache(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, nil, 0.8, "", sm) - limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 6, 4, 5) // As `isOverLimitWithLocalCache` is passed as `true`, immediate response is returned with no checks of the limits. responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, true, 2) @@ -124,7 +204,7 @@ func TestGetResponseStatusOverLimitWithLocalCacheShadowMode(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, nil, 0.8, "", sm) // This limit is in ShadowMode - limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 6, 4, 5) // As `isOverLimitWithLocalCache` is passed as `true`, immediate response is returned with no checks of the limits. responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, true, 2) @@ -148,7 +228,7 @@ func TestGetResponseStatusOverLimit(t *testing.T) { localCache := freecache.NewCache(100) sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, localCache, 0.8, "", sm) - limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 7, 4, 5) responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, false, 1) assert.Equal(pb.RateLimitResponse_OVER_LIMIT, responseStatus.GetCode()) @@ -174,7 +254,7 @@ func TestGetResponseStatusOverLimitShadowMode(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, localCache, 0.8, "", sm) // Key is in shadow_mode: true - limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 7, 4, 5) responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, false, 1) assert.Equal(pb.RateLimitResponse_OK, responseStatus.GetCode()) @@ -196,7 +276,7 @@ func TestGetResponseStatusBelowLimit(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, nil, 0.8, "", sm) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 6, 9, 10) responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, false, 1) assert.Equal(pb.RateLimitResponse_OK, responseStatus.GetCode()) @@ -217,7 +297,7 @@ func TestGetResponseStatusBelowLimitShadowMode(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) sm := mockstats.NewMockStatManager(statsStore) baseRateLimit := limiter.NewBaseRateLimit(timeSource, nil, 3600, nil, 0.8, "", sm) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, true, false, "", nil, false)} limitInfo := limiter.NewRateLimitInfo(limits[0], 2, 6, 9, 10) responseStatus := baseRateLimit.GetResponseDescriptorStatus(context.Background(), "key", limitInfo, false, 1) assert.Equal(pb.RateLimitResponse_OK, responseStatus.GetCode()) diff --git a/test/memcached/cache_impl_test.go b/test/memcached/cache_impl_test.go index e8b3952ee..5d375165d 100644 --- a/test/memcached/cache_impl_test.go +++ b/test/memcached/cache_impl_test.go @@ -46,24 +46,25 @@ func TestMemcached(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) cache := memcached.NewRateLimitCacheImpl(client, timeSource, nil, 0, nil, sm, 0.8, "") - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key_value"}).Return( getMultiResult(map[string]int{"domain_key_value": 4}), nil, ) client.EXPECT().Increment("domain_key_value", uint64(1)).Return(uint64(5), nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key2_value2_subkey2_subvalue2"}).Return( getMultiResult(map[string]int{"domain_key2_value2_subkey2_subvalue2": 10}), nil, ) @@ -74,23 +75,25 @@ func TestMemcached(t *testing.T) { [][][2]string{ {{"key2", "value2"}}, {{"key2", "value2"}, {"subkey2", "subvalue2"}}, - }, 1) + }, 1, + ) limits = []*config.RateLimit{ nil, - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, sm.NewStats("key2_value2_subkey2_subvalue2"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, sm.NewStats("key2_value2_subkey2_subvalue2"), false, false, false, "", nil, false), } assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[1].Stats.TotalHits.Value()) assert.Equal(uint64(1), limits[1].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[1].Stats.NearLimit.Value()) assert.Equal(uint64(0), limits[1].Stats.WithinLimit.Value()) - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{ "domain_key3_value3", "domain_key3_value3_subkey3_subvalue3", @@ -102,32 +105,34 @@ func TestMemcached(t *testing.T) { nil, ) client.EXPECT().Increment("domain_key3_value3", uint64(1)).Return(uint64(11), nil) - client.EXPECT().Increment("domain_key3_value3_subkey3_subvalue3", uint64(1)).Return(uint64(13), nil) + client.EXPECT().Increment("domain_key3_value3_subkey3_subvalue3", uint64(2)).Return(uint64(13), nil) - request = common.NewRateLimitRequest( + request = common.NewRateLimitRequestWithPerDescriptorHitsAddend( "domain", [][][2]string{ {{"key3", "value3"}}, {{"key3", "value3"}, {"subkey3", "subvalue3"}}, - }, 1) + }, []uint64{1, 2}, + ) limits = []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key3_value3"), false, false, "", nil, false), - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_DAY, sm.NewStats("key3_value3_subkey3_subvalue3"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key3_value3"), false, false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_DAY, sm.NewStats("key3_value3_subkey3_subvalue3"), false, false, false, "", nil, false), } assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) + assert.Equal(uint64(2), limits[1].Stats.TotalHits.Value()) + assert.Equal(uint64(2), limits[1].Stats.OverLimit.Value()) + assert.Equal(uint64(0), limits[1].Stats.NearLimit.Value()) + assert.Equal(uint64(0), limits[1].Stats.WithinLimit.Value()) cache.Flush() } @@ -143,36 +148,38 @@ func TestMemcachedGetError(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) cache := memcached.NewRateLimitCacheImpl(client, timeSource, nil, 0, nil, sm, 0.8, "") - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key_value"}).Return( nil, memcache.ErrNoServers, ) client.EXPECT().Increment("domain_key_value", uint64(1)).Return(uint64(5), nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 9, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) // No error, but the key is missing - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key_value1"}).Return( nil, nil, ) client.EXPECT().Increment("domain_key_value1", uint64(1)).Return(uint64(5), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value1"}}}, 1) - limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value1"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value1"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 9, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) @@ -183,7 +190,8 @@ func TestMemcachedGetError(t *testing.T) { func testLocalCacheStats(localCacheStats stats.StatGenerator, statsStore stats.Store, sink *common.TestStatSink, expectedHitCount int, expectedMissCount int, expectedLookUpCount int, expectedExpiredCount int, - expectedEntryCount int) func(*testing.T) { + expectedEntryCount int, +) func(*testing.T) { return func(t *testing.T) { localCacheStats.GenerateStats() statsStore.Flush() @@ -232,7 +240,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { localCacheStats := limiter.NewLocalCacheStats(localCache, statsStore.Scope("localcache")) // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 10}), nil, ) @@ -241,14 +249,15 @@ func TestOverLimitWithLocalCache(t *testing.T) { request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, "", nil, false), + config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, false, "", nil, false), } assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) @@ -259,7 +268,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { testLocalCacheStats(localCacheStats, statsStore, sink, 0, 1, 1, 0, 0) // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 12}), nil, ) @@ -269,7 +278,8 @@ func TestOverLimitWithLocalCache(t *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) @@ -280,7 +290,7 @@ func TestOverLimitWithLocalCache(t *testing.T) { testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 2, 0, 0) // Test Over limit stats - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 15}), nil, ) @@ -290,7 +300,8 @@ func TestOverLimitWithLocalCache(t *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) @@ -301,14 +312,15 @@ func TestOverLimitWithLocalCache(t *testing.T) { testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 3, 0, 1) // Test Over limit stats with local cache - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Times(0) client.EXPECT().Increment("domain_key4_value4", uint64(1)).Times(0) assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(4), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.OverLimitWithLocalCache.Value()) @@ -333,7 +345,7 @@ func TestNearLimit(t *testing.T) { cache := memcached.NewRateLimitCacheImpl(client, timeSource, nil, 0, nil, sm, 0.8, "") // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 10}), nil, ) @@ -342,21 +354,22 @@ func TestNearLimit(t *testing.T) { request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, "", nil, false), + config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, false, "", nil, false), } assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 12}), nil, ) @@ -366,7 +379,8 @@ func TestNearLimit(t *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) @@ -374,7 +388,7 @@ func TestNearLimit(t *testing.T) { // Test Near Limit Stats. We went OVER_LIMIT, but the near_limit counter only increases // when we are near limit, not after we have passed the limit. - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1000000)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key4_value4"}).Return( getMultiResult(map[string]int{"domain_key4_value4": 15}), nil, ) @@ -384,7 +398,8 @@ func TestNearLimit(t *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, }, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) @@ -392,108 +407,114 @@ func TestNearLimit(t *testing.T) { // Now test hitsAddend that is greater than 1 // All of it under limit, under near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key5_value5"}).Return( getMultiResult(map[string]int{"domain_key5_value5": 2}), nil, ) client.EXPECT().Increment("domain_key5_value5", uint64(3)).Return(uint64(5), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key5", "value5"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key5_value5"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key5_value5"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 15, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(3), limits[0].Stats.WithinLimit.Value()) // All of it under limit, some over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key6_value6"}).Return( getMultiResult(map[string]int{"domain_key6_value6": 5}), nil, ) client.EXPECT().Increment("domain_key6_value6", uint64(2)).Return(uint64(7), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key6", "value6"}}}, 2) - limits = []*config.RateLimit{config.NewRateLimit(8, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key6_value6"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(8, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key6_value6"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) // All of it under limit, all of it over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key7_value7"}).Return( getMultiResult(map[string]int{"domain_key7_value7": 16}), nil, ) client.EXPECT().Increment("domain_key7_value7", uint64(3)).Return(uint64(19), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key7", "value7"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key7_value7"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key7_value7"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(3), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(3), limits[0].Stats.WithinLimit.Value()) // Some of it over limit, all of it over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key8_value8"}).Return( getMultiResult(map[string]int{"domain_key8_value8": 19}), nil, ) client.EXPECT().Increment("domain_key8_value8", uint64(3)).Return(uint64(22), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key8", "value8"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key8_value8"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key8_value8"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) // Some of it in all three places - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key9_value9"}).Return( getMultiResult(map[string]int{"domain_key9_value9": 15}), nil, ) client.EXPECT().Increment("domain_key9_value9", uint64(7)).Return(uint64(22), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key9", "value9"}}}, 7) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key9_value9"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key9_value9"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(7), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(4), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) // all of it over limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(2) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key10_value10"}).Return( getMultiResult(map[string]int{"domain_key10_value10": 27}), nil, ) client.EXPECT().Increment("domain_key10_value10", uint64(3)).Return(uint64(30), nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key10", "value10"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key10_value10"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key10_value10"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(3), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) @@ -514,14 +535,15 @@ func TestMemcacheWithJitter(t *testing.T) { sm := mockstats.NewMockStatManager(statsStore) cache := memcached.NewRateLimitCacheImpl(client, timeSource, rand.New(jitterSource), 3600, nil, sm, 0.8, "") - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() jitterSource.EXPECT().Int63().Return(int64(100)) // Key is not found in memcache client.EXPECT().GetMulti([]string{"domain_key_value"}).Return(nil, nil) // First increment attempt will fail client.EXPECT().Increment("domain_key_value", uint64(1)).Return( - uint64(0), memcache.ErrCacheMiss) + uint64(0), memcache.ErrCacheMiss, + ) // Add succeeds client.EXPECT().Add( &memcache.Item{ @@ -533,11 +555,12 @@ func TestMemcacheWithJitter(t *testing.T) { ).Return(nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 9, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) @@ -558,11 +581,12 @@ func TestMemcacheAdd(t *testing.T) { cache := memcached.NewRateLimitCacheImpl(client, timeSource, nil, 0, nil, sm, 0.8, "") // Test a race condition with the initial add - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key_value"}).Return(nil, nil) client.EXPECT().Increment("domain_key_value", uint64(1)).Return( - uint64(0), memcache.ErrCacheMiss) + uint64(0), memcache.ErrCacheMiss, + ) // Add fails, must have been a race condition client.EXPECT().Add( &memcache.Item{ @@ -573,24 +597,27 @@ func TestMemcacheAdd(t *testing.T) { ).Return(memcache.ErrNotStored) // Should work the second time, since some other client added the key. client.EXPECT().Increment("domain_key_value", uint64(1)).Return( - uint64(2), nil) + uint64(2), nil, + ) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 9, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) // A rate limit with 1-minute window - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key2_value2"}).Return(nil, nil) client.EXPECT().Increment("domain_key2_value2", uint64(1)).Return( - uint64(0), memcache.ErrCacheMiss) + uint64(0), memcache.ErrCacheMiss, + ) client.EXPECT().Add( &memcache.Item{ Key: "domain_key2_value2", @@ -600,11 +627,12 @@ func TestMemcacheAdd(t *testing.T) { ).Return(nil) request = common.NewRateLimitRequest("domain", [][][2]string{{{"key2", "value2"}}}, 1) - limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, sm.NewStats("key2_value2"), false, false, "", nil, false)} + limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, sm.NewStats("key2_value2"), false, false, false, "", nil, false)} assert.Equal( []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 9, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) + cache.DoLimit(context.Background(), request, limits), + ) assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) @@ -666,14 +694,14 @@ func TestMemcachedTracer(t *testing.T) { cache := memcached.NewRateLimitCacheImpl(client, timeSource, nil, 0, nil, sm, 0.8, "") - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) + timeSource.EXPECT().UnixNow().Return(int64(1234)).AnyTimes() client.EXPECT().GetMulti([]string{"domain_key_value"}).Return( getMultiResult(map[string]int{"domain_key_value": 4}), nil, ) client.EXPECT().Increment("domain_key_value", uint64(1)).Return(uint64(5), nil) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} cache.DoLimit(context.Background(), request, limits) diff --git a/test/mocks/redis/redis.go b/test/mocks/redis/redis.go index 4728516b4..c656706c9 100644 --- a/test/mocks/redis/redis.go +++ b/test/mocks/redis/redis.go @@ -9,36 +9,35 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - radix "github.com/mediocregopher/radix/v4" redis "github.com/goatapp/ratelimit/src/redis" ) -// MockClient is a mock of Client interface. +// MockClient is a mock of Client interface type MockClient struct { ctrl *gomock.Controller recorder *MockClientMockRecorder } -// MockClientMockRecorder is the mock recorder for MockClient. +// MockClientMockRecorder is the mock recorder for MockClient type MockClientMockRecorder struct { mock *MockClient } -// NewMockClient creates a new mock instance. +// NewMockClient creates a new mock instance func NewMockClient(ctrl *gomock.Controller) *MockClient { mock := &MockClient{ctrl: ctrl} mock.recorder = &MockClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use. +// EXPECT returns an object that allows the caller to indicate expected use func (m *MockClient) EXPECT() *MockClientMockRecorder { return m.recorder } -// Close mocks base method. +// Close mocks base method func (m *MockClient) Close() error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Close") @@ -46,13 +45,13 @@ func (m *MockClient) Close() error { return ret0 } -// Close indicates an expected call of Close. +// Close indicates an expected call of Close func (mr *MockClientMockRecorder) Close() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockClient)(nil).Close)) } -// DoCmd mocks base method. +// DoCmd mocks base method func (m *MockClient) DoCmd(arg0 context.Context, arg1 interface{}, arg2 string, arg3 ...interface{}) error { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1, arg2} @@ -64,28 +63,14 @@ func (m *MockClient) DoCmd(arg0 context.Context, arg1 interface{}, arg2 string, return ret0 } -// DoCmd indicates an expected call of DoCmd. +// DoCmd indicates an expected call of DoCmd func (mr *MockClientMockRecorder) DoCmd(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoCmd", reflect.TypeOf((*MockClient)(nil).DoCmd), varargs...) } -// ImplicitPipeliningEnabled mocks base method. -func (m *MockClient) ImplicitPipeliningEnabled() bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ImplicitPipeliningEnabled") - ret0, _ := ret[0].(bool) - return ret0 -} - -// ImplicitPipeliningEnabled indicates an expected call of ImplicitPipeliningEnabled. -func (mr *MockClientMockRecorder) ImplicitPipeliningEnabled() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImplicitPipeliningEnabled", reflect.TypeOf((*MockClient)(nil).ImplicitPipeliningEnabled)) -} - -// NumActiveConns mocks base method. +// NumActiveConns mocks base method func (m *MockClient) NumActiveConns() int { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NumActiveConns") @@ -93,13 +78,13 @@ func (m *MockClient) NumActiveConns() int { return ret0 } -// NumActiveConns indicates an expected call of NumActiveConns. +// NumActiveConns indicates an expected call of NumActiveConns func (mr *MockClientMockRecorder) NumActiveConns() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NumActiveConns", reflect.TypeOf((*MockClient)(nil).NumActiveConns)) } -// PipeAppend mocks base method. +// PipeAppend mocks base method func (m *MockClient) PipeAppend(arg0 redis.Pipeline, arg1 interface{}, arg2 string, arg3 ...interface{}) redis.Pipeline { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1, arg2} @@ -111,14 +96,33 @@ func (m *MockClient) PipeAppend(arg0 redis.Pipeline, arg1 interface{}, arg2 stri return ret0 } -// PipeAppend indicates an expected call of PipeAppend. +// PipeAppend indicates an expected call of PipeAppend func (mr *MockClientMockRecorder) PipeAppend(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeAppend", reflect.TypeOf((*MockClient)(nil).PipeAppend), varargs...) } -// PipeDo mocks base method. +// PipeScriptAppend mocks base method +func (m *MockClient) PipeScriptAppend(arg0 redis.Pipeline, arg1 interface{}, arg2 radix.EvalScript, arg3 []string, arg4 ...string) redis.Pipeline { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1, arg2, arg3} + for _, a := range arg4 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "PipeScriptAppend", varargs...) + ret0, _ := ret[0].(redis.Pipeline) + return ret0 +} + +// PipeScriptAppend indicates an expected call of PipeScriptAppend +func (mr *MockClientMockRecorder) PipeScriptAppend(arg0, arg1, arg2, arg3 interface{}, arg4 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1, arg2, arg3}, arg4...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeScriptAppend", reflect.TypeOf((*MockClient)(nil).PipeScriptAppend), varargs...) +} + +// PipeDo mocks base method func (m *MockClient) PipeDo(arg0 context.Context, arg1 redis.Pipeline) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PipeDo", arg0, arg1) @@ -126,27 +130,22 @@ func (m *MockClient) PipeDo(arg0 context.Context, arg1 redis.Pipeline) error { return ret0 } -// PipeDo indicates an expected call of PipeDo. +// PipeDo indicates an expected call of PipeDo func (mr *MockClientMockRecorder) PipeDo(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeDo", reflect.TypeOf((*MockClient)(nil).PipeDo), arg0, arg1) } -// PipeScriptAppend mocks base method. -func (m *MockClient) PipeScriptAppend(arg0 redis.Pipeline, arg1 interface{}, arg2 radix.EvalScript, arg3 ...string) redis.Pipeline { +// ImplicitPipeliningEnabled mocks base method +func (m *MockClient) ImplicitPipeliningEnabled() bool { m.ctrl.T.Helper() - varargs := []interface{}{arg0, arg1, arg2} - for _, a := range arg3 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "PipeScriptAppend", varargs...) - ret0, _ := ret[0].(redis.Pipeline) + ret := m.ctrl.Call(m, "ImplicitPipeliningEnabled") + ret0, _ := ret[0].(bool) return ret0 } -// PipeScriptAppend indicates an expected call of PipeScriptAppend. -func (mr *MockClientMockRecorder) PipeScriptAppend(arg0, arg1, arg2 interface{}, arg3 ...interface{}) *gomock.Call { +// ImplicitPipeliningEnabled indicates an expected call of ImplicitPipeliningEnabled +func (mr *MockClientMockRecorder) ImplicitPipeliningEnabled() *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{arg0, arg1, arg2}, arg3...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipeScriptAppend", reflect.TypeOf((*MockClient)(nil).PipeScriptAppend), varargs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImplicitPipeliningEnabled", reflect.TypeOf((*MockClient)(nil).ImplicitPipeliningEnabled)) } diff --git a/test/mocks/stats/manager.go b/test/mocks/stats/manager.go index 39746a9a3..2dc9e32d4 100644 --- a/test/mocks/stats/manager.go +++ b/test/mocks/stats/manager.go @@ -7,6 +7,7 @@ import ( gostats "github.com/lyft/gostats" logger "github.com/goatapp/ratelimit/src/log" + "github.com/goatapp/ratelimit/src/stats" "github.com/goatapp/ratelimit/src/utils" ) @@ -38,7 +39,7 @@ func (m *MockStatManager) NewServiceStats() stats.ServiceStats { func (m *MockStatManager) NewStats(key string) stats.RateLimitStats { ret := stats.RateLimitStats{} - logger.Debug(context.Background(), fmt.Sprintf("outputting test gostats %s", key)) + logger.Debug(context.Background(), fmt.Sprintf("outputing test gostats %s", key)) ret.Key = key key = utils.SanitizeStatName(key) ret.TotalHits = m.store.NewCounter(key + ".total_hits") @@ -51,6 +52,15 @@ func (m *MockStatManager) NewStats(key string) stats.RateLimitStats { return ret } +func (m *MockStatManager) NewDomainStats(key string) stats.DomainStats { + ret := stats.DomainStats{} + logger.Debug(context.Background(), fmt.Sprintf("outputing test domain stats %s", key)) + ret.Key = key + ret.NotFound = m.store.NewCounter(key + ".domain_not_found") + + return ret +} + func NewMockStatManager(store gostats.Store) stats.Manager { return &MockStatManager{store: store} } diff --git a/test/provider/xds_grpc_sotw_provider_test.go b/test/provider/xds_grpc_sotw_provider_test.go index 2b2f5bbdf..2f8d75995 100644 --- a/test/provider/xds_grpc_sotw_provider_test.go +++ b/test/provider/xds_grpc_sotw_provider_test.go @@ -27,7 +27,8 @@ const ( ) func TestXdsProvider(t *testing.T) { - intSnapshot, _ := cache.NewSnapshot("1", + intSnapshot, _ := cache.NewSnapshot( + "1", map[resource.Type][]types.Resource{ resource.RateLimitConfigType: { &rls_config.RateLimitConfig{ @@ -86,7 +87,7 @@ func testInitialXdsConfig(snapVersion *int, setSnapshotFunc common.SetSnapshotFu config, err := configEvent.GetConfig() assert.Nil(err) - assert.Equal("foo.k1_v1: unit=MINUTE requests_per_unit=3, shadow_mode: false\n", config.Dump()) + assert.Equal("foo.k1_v1: unit=MINUTE requests_per_unit=3, shadow_mode: false, quota_mode: false\n", config.Dump()) } } @@ -95,7 +96,8 @@ func testNewXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.SetSnapshot return func(t *testing.T) { assert := assert.New(t) - snapshot, _ := cache.NewSnapshot(fmt.Sprint(*snapVersion), + snapshot, _ := cache.NewSnapshot( + fmt.Sprint(*snapVersion), map[resource.Type][]types.Resource{ resource.RateLimitConfigType: { &rls_config.RateLimitConfig{ @@ -122,7 +124,7 @@ func testNewXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.SetSnapshot config, err := configEvent.GetConfig() assert.Nil(err) - assert.Equal("foo.k2_v2: unit=MINUTE requests_per_unit=5, shadow_mode: false\n", config.Dump()) + assert.Equal("foo.k2_v2: unit=MINUTE requests_per_unit=5, shadow_mode: false, quota_mode: false\n", config.Dump()) } } @@ -131,7 +133,8 @@ func testMultiDomainXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Set return func(t *testing.T) { assert := assert.New(t) - snapshot, _ := cache.NewSnapshot(fmt.Sprint(*snapVersion), + snapshot, _ := cache.NewSnapshot( + fmt.Sprint(*snapVersion), map[resource.Type][]types.Resource{ resource.RateLimitConfigType: { &rls_config.RateLimitConfig{ @@ -173,8 +176,8 @@ func testMultiDomainXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Set config, err := configEvent.GetConfig() assert.Nil(err) assert.ElementsMatch([]string{ - "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false", - "bar.k1_v1: unit=MINUTE requests_per_unit=100, shadow_mode: false", + "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false, quota_mode: false", + "bar.k1_v1: unit=MINUTE requests_per_unit=100, shadow_mode: false, quota_mode: false", }, strings.Split(strings.TrimSuffix(config.Dump(), "\n"), "\n")) } } @@ -184,7 +187,8 @@ func testDeeperLimitsXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Se return func(t *testing.T) { assert := assert.New(t) - snapshot, _ := cache.NewSnapshot(fmt.Sprint(*snapVersion), + snapshot, _ := cache.NewSnapshot( + fmt.Sprint(*snapVersion), map[resource.Type][]types.Resource{ resource.RateLimitConfigType: { &rls_config.RateLimitConfig{ @@ -236,6 +240,7 @@ func testDeeperLimitsXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Se RequestsPerUnit: 15, }, ShadowMode: true, + QuotaMode: true, }, }, }, @@ -266,13 +271,13 @@ func testDeeperLimitsXdsConfigUpdate(snapVersion *int, setSnapshotFunc common.Se config, err := configEvent.GetConfig() assert.Nil(err) assert.ElementsMatch([]string{ - "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false", - "foo.k1_v1.k2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false", - "foo.k1_v1.k2_v2: unit=HOUR requests_per_unit=15, shadow_mode: false", - "foo.j1_v2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false", - "foo.j1_v2.j2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false", - "foo.j1_v2.j2_v2: unit=DAY requests_per_unit=15, shadow_mode: true", - "bar.k1_v1: unit=MINUTE requests_per_unit=100, shadow_mode: false", + "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false, quota_mode: false", + "foo.k1_v1.k2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false, quota_mode: false", + "foo.k1_v1.k2_v2: unit=HOUR requests_per_unit=15, shadow_mode: false, quota_mode: false", + "foo.j1_v2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false, quota_mode: false", + "foo.j1_v2.j2: unit=UNKNOWN requests_per_unit=0, shadow_mode: false, quota_mode: false", + "foo.j1_v2.j2_v2: unit=DAY requests_per_unit=15, shadow_mode: true, quota_mode: true", + "bar.k1_v1: unit=MINUTE requests_per_unit=100, shadow_mode: false, quota_mode: false", }, strings.Split(strings.TrimSuffix(config.Dump(), "\n"), "\n")) } } @@ -281,7 +286,8 @@ func testSameDomainMultipleXdsConfigUpdate(setSnapshotFunc common.SetSnapshotFun return func(t *testing.T) { assert := assert.New(t) - snapshot, _ := cache.NewSnapshot("3", + snapshot, _ := cache.NewSnapshot( + "3", map[resource.Type][]types.Resource{ resource.RateLimitConfigType: { &rls_config.RateLimitConfig{ @@ -323,8 +329,8 @@ func testSameDomainMultipleXdsConfigUpdate(setSnapshotFunc common.SetSnapshotFun config, err := configEvent.GetConfig() assert.Nil(err) assert.ElementsMatch([]string{ - "foo.k1_v2: unit=MINUTE requests_per_unit=100, shadow_mode: false", - "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false", + "foo.k1_v2: unit=MINUTE requests_per_unit=100, shadow_mode: false, quota_mode: false", + "foo.k1_v1: unit=MINUTE requests_per_unit=10, shadow_mode: false, quota_mode: false", }, strings.Split(strings.TrimSuffix(config.Dump(), "\n"), "\n")) } } diff --git a/test/redis/bench_test.go b/test/redis/bench_test.go index 6b75bf3f3..197cf0f17 100644 --- a/test/redis/bench_test.go +++ b/test/redis/bench_test.go @@ -40,16 +40,16 @@ func BenchmarkParallelDoLimit(b *testing.B) { }) } - mkDoLimitBench := func(implicitPipeline bool) func(*testing.B) { + mkDoLimitBench := func(pipelineWindow time.Duration, pipelineLimit int) func(*testing.B) { return func(b *testing.B) { statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", "127.0.0.1:6379", poolSize, implicitPipeline, nil, false, nil) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", "127.0.0.1:6379", poolSize, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) defer client.Close() cache := redis.NewFixedRateLimitCacheImpl(client, nil, utils.NewTimeSourceImpl(), rand.New(utils.NewLockedSource(time.Now().Unix())), 10, nil, 0.8, "", sm, true) request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(1000000000, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(1000000000, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, false, "", nil, false)} // wait for the pool to fill up for { @@ -68,6 +68,30 @@ func BenchmarkParallelDoLimit(b *testing.B) { } } - b.Run("no pipeline", mkDoLimitBench(false)) - b.Run("pipeline enabled", mkDoLimitBench(true)) + b.Run("no pipeline", mkDoLimitBench(0, 0)) + + b.Run("pipeline 35us 1", mkDoLimitBench(35*time.Microsecond, 1)) + b.Run("pipeline 75us 1", mkDoLimitBench(75*time.Microsecond, 1)) + b.Run("pipeline 150us 1", mkDoLimitBench(150*time.Microsecond, 1)) + b.Run("pipeline 300us 1", mkDoLimitBench(300*time.Microsecond, 1)) + + b.Run("pipeline 35us 2", mkDoLimitBench(35*time.Microsecond, 2)) + b.Run("pipeline 75us 2", mkDoLimitBench(75*time.Microsecond, 2)) + b.Run("pipeline 150us 2", mkDoLimitBench(150*time.Microsecond, 2)) + b.Run("pipeline 300us 2", mkDoLimitBench(300*time.Microsecond, 2)) + + b.Run("pipeline 35us 4", mkDoLimitBench(35*time.Microsecond, 4)) + b.Run("pipeline 75us 4", mkDoLimitBench(75*time.Microsecond, 4)) + b.Run("pipeline 150us 4", mkDoLimitBench(150*time.Microsecond, 4)) + b.Run("pipeline 300us 4", mkDoLimitBench(300*time.Microsecond, 4)) + + b.Run("pipeline 35us 8", mkDoLimitBench(35*time.Microsecond, 8)) + b.Run("pipeline 75us 8", mkDoLimitBench(75*time.Microsecond, 8)) + b.Run("pipeline 150us 8", mkDoLimitBench(150*time.Microsecond, 8)) + b.Run("pipeline 300us 8", mkDoLimitBench(300*time.Microsecond, 8)) + + b.Run("pipeline 35us 16", mkDoLimitBench(35*time.Microsecond, 16)) + b.Run("pipeline 75us 16", mkDoLimitBench(75*time.Microsecond, 16)) + b.Run("pipeline 150us 16", mkDoLimitBench(150*time.Microsecond, 16)) + b.Run("pipeline 300us 16", mkDoLimitBench(300*time.Microsecond, 16)) } diff --git a/test/redis/driver_impl_test.go b/test/redis/driver_impl_test.go index 061663941..4aa20a32e 100644 --- a/test/redis/driver_impl_test.go +++ b/test/redis/driver_impl_test.go @@ -3,7 +3,9 @@ package redis_test import ( "context" "fmt" + "strings" "testing" + "time" "github.com/alicebob/miniredis/v2" stats "github.com/lyft/gostats" @@ -32,13 +34,14 @@ func expectPanicError(t *testing.T, f assert.PanicTestFunc) (result error) { return } -func testNewClientImpl(t *testing.T, implicitPipeline bool) func(t *testing.T) { +func testNewClientImpl(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) func(t *testing.T) { return func(t *testing.T) { redisAuth := "123" statsStore := stats.NewStore(stats.NewNullSink(), false) + // Use a short maxElapsedTime so failing connection tests don't hang in retry loops. mkRedisClient := func(auth, addr string) redis.Client { - return redis.NewClientImpl(context.Background(), statsStore, false, auth, "tcp", "single", addr, 1, implicitPipeline, nil, false, nil) + return redis.NewClientImpl(context.Background(), statsStore, false, auth, "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 100*time.Millisecond) } t.Run("connection refused", func(t *testing.T) { @@ -65,9 +68,8 @@ func testNewClientImpl(t *testing.T, implicitPipeline bool) func(t *testing.T) { redisSrv.RequireAuth(redisAuth) - assert.PanicsWithError(t, "response returned from Conn: NOAUTH Authentication required.", func() { - mkRedisClient("", redisSrv.Addr()) - }) + panicErr := expectPanicError(t, func() { mkRedisClient("", redisSrv.Addr()) }) + assert.Contains(t, panicErr.Error(), "NOAUTH") }) t.Run("auth pass", func(t *testing.T) { @@ -102,36 +104,22 @@ func testNewClientImpl(t *testing.T, implicitPipeline bool) func(t *testing.T) { redisSrv.RequireUserAuth(user, pass) redisAuth := fmt.Sprintf("%s:invalid-password", user) - assert.PanicsWithError(t, "response returned from Conn: WRONGPASS invalid username-password pair", func() { - mkRedisClient(redisAuth, redisSrv.Addr()) - }) - }) - - t.Run("ImplicitPipeliningEnabled() return expected value", func(t *testing.T) { - redisSrv := mustNewRedisServer() - defer redisSrv.Close() - - client := mkRedisClient("", redisSrv.Addr()) - - if implicitPipeline { - assert.True(t, client.ImplicitPipeliningEnabled()) - } else { - assert.False(t, client.ImplicitPipeliningEnabled()) - } + panicErr := expectPanicError(t, func() { mkRedisClient(redisAuth, redisSrv.Addr()) }) + assert.Contains(t, panicErr.Error(), "WRONGPASS") }) } } func TestNewClientImpl(t *testing.T) { - t.Run("ImplicitPipeliningEnabled", testNewClientImpl(t, true)) - t.Run("ImplicitPipeliningDisabled", testNewClientImpl(t, false)) + t.Run("WithPipelineWindow", testNewClientImpl(t, 2*time.Millisecond, 2)) + t.Run("WithoutPipelineWindow", testNewClientImpl(t, 0, 0)) } func TestDoCmd(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) mkRedisClient := func(addr string) redis.Client { - return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, false, nil, false, nil) + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) } t.Run("SETGET ok", func(t *testing.T) { @@ -171,12 +159,12 @@ func TestDoCmd(t *testing.T) { }) } -func testPipeDo(t *testing.T, implicitPipeline bool) func(t *testing.T) { +func testPipeDo(t *testing.T, pipelineWindow time.Duration, pipelineLimit int) func(t *testing.T) { return func(t *testing.T) { statsStore := stats.NewStore(stats.NewNullSink(), false) mkRedisClient := func(addr string) redis.Client { - return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, implicitPipeline, nil, false, nil) + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, pipelineWindow, pipelineLimit, nil, false, nil, 10*time.Second, "", "", time.Second, 30*time.Second, 0) } t.Run("SETGET ok", func(t *testing.T) { @@ -219,7 +207,13 @@ func testPipeDo(t *testing.T, implicitPipeline bool) func(t *testing.T) { expectErrContainEOF := func(t *testing.T, err error) { assert.NotNil(t, err) - assert.Contains(t, err.Error(), "EOF") + // radix v4 wraps errors with "response returned from Conn:" + // and may return different connection errors (EOF, connection reset, etc) + errMsg := err.Error() + hasConnectionError := strings.Contains(errMsg, "EOF") || + strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "broken pipe") + assert.True(t, hasConnectionError, "expected connection error, got: %s", errMsg) } expectErrContainEOF(t, client.PipeDo(context.Background(), client.PipeAppend(redis.Pipeline{}, nil, "GET", "foo"))) @@ -228,6 +222,279 @@ func testPipeDo(t *testing.T, implicitPipeline bool) func(t *testing.T) { } func TestPipeDo(t *testing.T) { - t.Run("ImplicitPipeliningEnabled", testPipeDo(t, true)) - t.Run("ImplicitPipeliningDisabled", testPipeDo(t, false)) + t.Run("WithPipelineWindow", testPipeDo(t, 10*time.Millisecond, 2)) + t.Run("WithoutPipelineWindow", testPipeDo(t, 0, 0)) +} + +// Tests for pool on-empty behavior +func TestPoolOnEmptyBehavior(t *testing.T) { + statsStore := stats.NewStore(stats.NewNullSink(), false) + + // Helper to create client with specific on-empty behavior + mkRedisClientWithBehavior := func(addr, behavior string) redis.Client { + return redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", addr, 1, 0, 0, nil, false, nil, 10*time.Second, behavior, "", time.Second, 30*time.Second, 0) + } + + t.Run("default behavior (empty string)", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + var client redis.Client + assert.NotPanics(t, func() { + client = mkRedisClientWithBehavior(redisSrv.Addr(), "") + }) + assert.NotNil(t, client) + + // Verify client works + var res string + assert.Nil(t, client.DoCmd(context.Background(), nil, "SET", "foo", "bar")) + assert.Nil(t, client.DoCmd(context.Background(), &res, "GET", "foo")) + assert.Equal(t, "bar", res) + }) + + t.Run("ERROR behavior should panic", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // radix v4 does not support ERROR behavior - should panic at startup + panicErr := expectPanicError(t, func() { + mkRedisClientWithBehavior(redisSrv.Addr(), "ERROR") + }) + assert.Contains(t, panicErr.Error(), "REDIS_POOL_ON_EMPTY_BEHAVIOR=ERROR is not supported in radix v4") + }) + + t.Run("CREATE behavior should panic", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // radix v4 does not support CREATE behavior - should panic at startup + panicErr := expectPanicError(t, func() { + mkRedisClientWithBehavior(redisSrv.Addr(), "CREATE") + }) + assert.Contains(t, panicErr.Error(), "REDIS_POOL_ON_EMPTY_BEHAVIOR=CREATE is not supported in radix v4") + }) + + t.Run("WAIT behavior", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + var client redis.Client + assert.NotPanics(t, func() { + client = mkRedisClientWithBehavior(redisSrv.Addr(), "WAIT") + }) + assert.NotNil(t, client) + + // Verify client works + var res string + assert.Nil(t, client.DoCmd(context.Background(), nil, "SET", "test5", "value5")) + assert.Nil(t, client.DoCmd(context.Background(), &res, "GET", "test5")) + assert.Equal(t, "value5", res) + }) + + t.Run("case insensitive behavior - lowercase 'error' panics", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // Test that lowercase 'error' is treated same as 'ERROR' (case insensitive) + panicErr := expectPanicError(t, func() { + mkRedisClientWithBehavior(redisSrv.Addr(), "error") + }) + assert.Contains(t, panicErr.Error(), "REDIS_POOL_ON_EMPTY_BEHAVIOR=ERROR is not supported in radix v4") + }) + + t.Run("case insensitive behavior - lowercase 'create' panics", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // Test that lowercase 'create' is treated same as 'CREATE' (case insensitive) + panicErr := expectPanicError(t, func() { + mkRedisClientWithBehavior(redisSrv.Addr(), "create") + }) + assert.Contains(t, panicErr.Error(), "REDIS_POOL_ON_EMPTY_BEHAVIOR=CREATE is not supported in radix v4") + }) + + t.Run("case insensitive behavior - lowercase 'wait' works", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // Test that lowercase 'wait' is treated same as 'WAIT' (case insensitive) + var client redis.Client + assert.NotPanics(t, func() { + client = mkRedisClientWithBehavior(redisSrv.Addr(), "wait") + }) + assert.NotNil(t, client) + + // Verify client works + var res string + assert.Nil(t, client.DoCmd(context.Background(), nil, "SET", "test6", "value6")) + assert.Nil(t, client.DoCmd(context.Background(), &res, "GET", "test6")) + assert.Equal(t, "value6", res) + }) + + t.Run("unknown behavior falls back to default", func(t *testing.T) { + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + // Unknown behavior should not panic, just log warning and use default + var client redis.Client + assert.NotPanics(t, func() { + client = mkRedisClientWithBehavior(redisSrv.Addr(), "UNKNOWN_BEHAVIOR") + }) + assert.NotNil(t, client) + + // Verify client works + var res string + assert.Nil(t, client.DoCmd(context.Background(), nil, "SET", "test7", "value7")) + assert.Nil(t, client.DoCmd(context.Background(), &res, "GET", "test7")) + assert.Equal(t, "value7", res) + }) +} + +func TestNewClientImplSentinel(t *testing.T) { + statsStore := stats.NewStore(stats.NewNullSink(), false) + + mkSentinelClient := func(auth, sentinelAuth, url string, useTls bool, timeout time.Duration) redis.Client { + // Pass nil for tlsConfig - we can't test TLS without a real TLS server, + // but we can verify the code path is executed (logs will show TLS is enabled) + // Use a short maxElapsedTime so failing connection tests don't hang in retry loops. + return redis.NewClientImpl(context.Background(), statsStore, useTls, auth, "tcp", "sentinel", url, 1, 0, 0, nil, false, nil, timeout, "", sentinelAuth, time.Second, 30*time.Second, 100*time.Millisecond) + } + + t.Run("invalid url format - missing sentinel addresses", func(t *testing.T) { + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", "mymaster", false, 10*time.Second) + }) + assert.Contains(t, panicErr.Error(), "Expected master name and a list of urls for the sentinels") + }) + + t.Run("invalid url format - only master name", func(t *testing.T) { + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", "mymaster,", false, 10*time.Second) + }) + // Empty sentinel address causes "missing address" error from radix + assert.True(t, + containsAny(panicErr.Error(), []string{"Expected master name", "missing address"}), + "Expected format validation error, got: %s", panicErr.Error()) + }) + + t.Run("connection refused - sentinel not available", func(t *testing.T) { + // Use a port that's unlikely to have a sentinel running + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", url, false, 1*time.Second) + }) + // Should fail with connection error or timeout + assert.NotNil(t, panicErr) + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect"}), + "Expected connection error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel auth password only", func(t *testing.T) { + // This will fail to connect, but we're testing that sentinelAuth parameter is accepted + // The log output will show "enabling authentication to redis sentinel" which confirms the code path + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "sentinel-password", url, false, 1*time.Second) + }) + // Should fail with connection error, not auth error (since we can't connect) + assert.NotNil(t, panicErr) + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect"}), + "Expected connection error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel auth user:password", func(t *testing.T) { + // This will fail to connect, but we're testing that sentinelAuth parameter with user:password format is accepted + // The log output will show "enabling authentication to redis sentinel on ... with user sentinel-user" + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "sentinel-user:sentinel-pass", url, false, 1*time.Second) + }) + // Should fail with connection error, not auth error (since we can't connect) + assert.NotNil(t, panicErr) + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect"}), + "Expected connection error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel with timeout", func(t *testing.T) { + // Test that timeout parameter is used + url := "mymaster,localhost:12345" + start := time.Now() + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", url, false, 500*time.Millisecond) + }) + duration := time.Since(start) + assert.NotNil(t, panicErr) + // Timeout should be respected (with some tolerance) + assert.True(t, duration < 2*time.Second, "Timeout should be respected, took %v", duration) + }) + + t.Run("sentinel with multiple addresses", func(t *testing.T) { + // Test that multiple sentinel addresses are accepted in URL format + url := "mymaster,localhost:12345,localhost:12346,localhost:12347" + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", url, false, 1*time.Second) + }) + // Should fail with connection error, not format error + assert.NotNil(t, panicErr) + assert.NotContains(t, panicErr.Error(), "Expected master name") + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect"}), + "Expected connection error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel with redis auth but no sentinel auth", func(t *testing.T) { + // Test that redis auth and sentinel auth are separate + // redisAuth is for master/replica, sentinelAuth is for sentinel nodes + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("redis-password", "", url, false, 1*time.Second) + }) + // Should fail with connection error (can't test auth without real sentinel) + assert.NotNil(t, panicErr) + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect"}), + "Expected connection error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel with TLS enabled", func(t *testing.T) { + // Test that TLS configuration is accepted (will fail to connect without real TLS server) + // The log output will show "enabling TLS to redis sentinel" which confirms the code path + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("", "", url, true, 1*time.Second) + }) + // Should fail with connection/TLS error (can't test TLS without real TLS server) + assert.NotNil(t, panicErr) + // Error could be connection refused, TLS handshake failure, or timeout + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect", "tls", "handshake"}), + "Expected connection/TLS error, got: %s", panicErr.Error()) + }) + + t.Run("sentinel with TLS and sentinel auth", func(t *testing.T) { + // Test that both TLS and sentinel auth can be configured together + // The log output will show both TLS and auth messages + url := "mymaster,localhost:12345" + panicErr := expectPanicError(t, func() { + mkSentinelClient("redis-password", "sentinel-password", url, true, 1*time.Second) + }) + // Should fail with connection/TLS error (can't test without real servers) + assert.NotNil(t, panicErr) + assert.True(t, + containsAny(panicErr.Error(), []string{"connection refused", "timeout", "no such host", "connect", "tls", "handshake"}), + "Expected connection/TLS error, got: %s", panicErr.Error()) + }) +} + +// Helper function to check if error message contains any of the given strings +func containsAny(s string, substrs []string) bool { + for _, substr := range substrs { + if strings.Contains(strings.ToLower(s), strings.ToLower(substr)) { + return true + } + } + return false } diff --git a/test/redis/fixed_cache_impl_test.go b/test/redis/fixed_cache_impl_test.go index d55a0edc4..19ebea468 100644 --- a/test/redis/fixed_cache_impl_test.go +++ b/test/redis/fixed_cache_impl_test.go @@ -7,670 +7,245 @@ import ( "github.com/goatapp/ratelimit/test/mocks/stats" - "github.com/coocood/freecache" - "github.com/mediocregopher/radix/v4" - pb "github.com/envoyproxy/go-control-plane/envoy/service/ratelimit/v3" gostats "github.com/lyft/gostats" "github.com/goatapp/ratelimit/src/config" - "github.com/goatapp/ratelimit/src/limiter" "github.com/goatapp/ratelimit/src/redis" "github.com/goatapp/ratelimit/src/trace" "github.com/goatapp/ratelimit/src/utils" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/goatapp/ratelimit/test/common" - mock_redis "github.com/goatapp/ratelimit/test/mocks/redis" mock_utils "github.com/goatapp/ratelimit/test/mocks/utils" + + "github.com/golang/mock/gomock" ) var testSpanExporter = trace.GetTestSpanExporter() -func TestRedis(t *testing.T) { - t.Run("WithoutPerSecondRedis", testRedis(false)) - t.Run("WithPerSecondRedis", testRedis(true)) -} +func TestRedisTokenBucketBasic(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() -func pipeAppend(pipeline redis.Pipeline, rcv interface{}, cmd string, args ...interface{}) redis.Pipeline { - return append(pipeline, radix.FlatCmd(rcv, cmd, args...)) -} + statsStore := gostats.NewStore(gostats.NewNullSink(), false) + sm := stats.NewMockStatManager(statsStore) + timeSource := mock_utils.NewMockTimeSource(controller) -func pipeScriptAppend(pipeline redis.Pipeline, rcv interface{}, script radix.EvalScript, args ...interface{}) redis.Pipeline { - return append(pipeline, script.FlatCmd(rcv, nil, args)) -} + redisSrv := mustNewRedisServer() + defer redisSrv.Close() -func testRedis(usePerSecondRedis bool) func(*testing.T) { - return func(t *testing.T) { - assert := assert.New(t) - controller := gomock.NewController(t) - defer controller.Finish() - statsStore := gostats.NewStore(gostats.NewNullSink(), false) - sm := stats.NewMockStatManager(statsStore) - - client := mock_redis.NewMockClient(controller) - perSecondClient := mock_redis.NewMockClient(controller) - timeSource := mock_utils.NewMockTimeSource(controller) - var cache limiter.RateLimitCache - if usePerSecondRedis { - cache = redis.NewFixedRateLimitCacheImpl(client, perSecondClient, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - } else { - cache = redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - } - - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - var clientUsed *mock_redis.MockClient - if usePerSecondRedis { - clientUsed = perSecondClient - } else { - clientUsed = client - } - - clientUsed.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key_value", "domain_key_value:expires", "10", "10", "775", "1", "1234").SetArg(1, []int64{5, 1, 1}).DoAndReturn(pipeScriptAppend) - clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - - clientUsed = client - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - clientUsed.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key2_value2_subkey2_subvalue2", "domain_key2_value2_subkey2_subvalue2:expires", "10", "10", "60000", "1", "1234").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest( - "domain", - [][][2]string{ - {{"key2", "value2"}}, - {{"key2", "value2"}, {"subkey2", "subvalue2"}}, - }, 1) - limits = []*config.RateLimit{ - nil, - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, sm.NewStats("key2_value2_subkey2_subvalue2"), false, false, "", nil, false), - } - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[1].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[1].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[1].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[1].Stats.WithinLimit.Value()) - - clientUsed = client - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(6) - clientUsed.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key3_value3", "domain_key3_value3:expires", - "10", "10", "3600000", "1", "1000000").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - clientUsed.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key3_value3_subkey3_subvalue3", "domain_key3_value3_subkey3_subvalue3:expires", - "10", "10", "86400000", "1", "1000000").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - clientUsed.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest( - "domain", - [][][2]string{ - {{"key3", "value3"}}, - {{"key3", "value3"}, {"subkey3", "subvalue3"}}, - }, 1) - limits = []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key3_value3"), false, false, "", nil, false), - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_DAY, sm.NewStats("key3_value3_subkey3_subvalue3"), false, false, "", nil, false), - } - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) - } -} + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) -func testLocalCacheStats(localCacheStats gostats.StatGenerator, statsStore gostats.Store, sink *common.TestStatSink, - expectedHitCount int, expectedMissCount int, expectedLookUpCount int, expectedExpiredCount int, - expectedEntryCount int) func(*testing.T) { - return func(t *testing.T) { - localCacheStats.GenerateStats() - statsStore.Flush() - - // Check whether all local_cache related stats are available. - _, ok := sink.Record["averageAccessTime"] - assert.Equal(t, true, ok) - hitCount, ok := sink.Record["hitCount"] - assert.Equal(t, true, ok) - missCount, ok := sink.Record["missCount"] - assert.Equal(t, true, ok) - lookupCount, ok := sink.Record["lookupCount"] - assert.Equal(t, true, ok) - _, ok = sink.Record["overwriteCount"] - assert.Equal(t, true, ok) - _, ok = sink.Record["evacuateCount"] - assert.Equal(t, true, ok) - expiredCount, ok := sink.Record["expiredCount"] - assert.Equal(t, true, ok) - entryCount, ok := sink.Record["entryCount"] - assert.Equal(t, true, ok) - - // Check the correctness of hitCount, missCount, lookupCount, expiredCount and entryCount - assert.Equal(t, expectedHitCount, hitCount.(int)) - assert.Equal(t, expectedMissCount, missCount.(int)) - assert.Equal(t, expectedLookUpCount, lookupCount.(int)) - assert.Equal(t, expectedExpiredCount, expiredCount.(int)) - assert.Equal(t, expectedEntryCount, entryCount.(int)) - - sink.Clear() - } + // Use a realistic timestamp (June 2026) so PXAT doesn't expire immediately in miniredis + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + + request := common.NewRateLimitRequest("basic_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("basic_key_value"), false, false, false, "", nil, false)} + + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, limits[0].Limit, response[0].CurrentLimit) + assert.Equal(t, uint64(1), limits[0].Stats.TotalHits.Value()) + assert.Equal(t, uint64(1), limits[0].Stats.WithinLimit.Value()) } -func TestOverLimitWithLocalCache(t *testing.T) { - assert := assert.New(t) +func TestRedisTokenBucketOverLimit(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() - client := mock_redis.NewMockClient(controller) - timeSource := mock_utils.NewMockTimeSource(controller) - localCache := freecache.NewCache(100) statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, localCache, 0.8, "", sm, false) - sink := &common.TestStatSink{} - localCacheStats := limiter.NewLocalCacheStats(localCache, statsStore.Scope("localcache")) + timeSource := mock_utils.NewMockTimeSource(controller) - // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(5) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{4, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) + redisSrv := mustNewRedisServer() + defer redisSrv.Close() - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, "", nil, false), - } + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + + request := common.NewRateLimitRequest("overlimit_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{config.NewRateLimit(2, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("overlimit_key_value"), false, false, false, "", nil, false)} - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 1, 1, 0, 0) - - // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{2, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 2, 0, 0) - - // Test Over limit stats - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 3, 0, 1) - - // Test Over limit stats with local cache - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "1003600", "1000000", "1").Times(0) - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(4), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 1, 3, 4, 0, 1) + // First two requests should pass + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + + // Third request should be over limit + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OVER_LIMIT, response[0].Code) } -func TestNearLimit(t *testing.T) { - assert := assert.New(t) +func TestRedisTokenBucketNilLimit(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() - client := mock_redis.NewMockClient(controller) - timeSource := mock_utils.NewMockTimeSource(controller) statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) + timeSource := mock_utils.NewMockTimeSource(controller) - // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{4, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) + redisSrv := mustNewRedisServer() + defer redisSrv.Close() - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, "", nil, false), - } + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + + request := common.NewRateLimitRequest("nil_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{nil} - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - - // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{2, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Test Near Limit Stats. We went OVER_LIMIT, but the near_limit counter only increases - // when we are near limit, not after we have passed the limit. - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Now test hitsAddend that is greater than 1 - // All of it under limit, under near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key5_value5", "domain_key5_value5:expires", "20", "20", "775", "3", "1234").SetArg(1, []int64{17, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key5", "value5"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key5_value5"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 15, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(3), limits[0].Stats.WithinLimit.Value()) - - // All of it under limit, some over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key6_value6", "domain_key6_value6:expires", "8", "8", "775", "2", "1234").SetArg(1, []int64{2, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key6", "value6"}}}, 2) - limits = []*config.RateLimit{config.NewRateLimit(8, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key6_value6"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // All of it under limit, all of it over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key7_value7", "domain_key7_value7:expires", "20", "20", "775", "3", "1234").SetArg(1, []int64{3, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key7", "value7"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key7_value7"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(3), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(3), limits[0].Stats.WithinLimit.Value()) - - // Some of it over limit, all of it over near limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key8_value8", "domain_key8_value8:expires", "20", "20", "775", "3", "1234").SetArg(1, []int64{1, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key8", "value8"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key8_value8"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) - - // Some of it in all three places - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key9_value9", "domain_key9_value9:expires", "20", "20", "775", "7", "1234").SetArg(1, []int64{5, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key9", "value9"}}}, 7) - limits = []*config.RateLimit{config.NewRateLimit(20, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key9_value9"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(7), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(4), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) - - // all of it over limit - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(3) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key10_value10", "domain_key10_value10:expires", "10", "10", "775", "3", "1234").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - request = common.NewRateLimitRequest("domain", [][][2]string{{{"key10", "value10"}}}, 3) - limits = []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key10_value10"), false, false, "", nil, false)} - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}}, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(3), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.WithinLimit.Value()) + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Nil(t, response[0].CurrentLimit) } -func TestOverLimitWithLocalCacheShadowRule(t *testing.T) { - assert := assert.New(t) +func TestRedisTokenBucketDurationReset(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() - client := mock_redis.NewMockClient(controller) - timeSource := mock_utils.NewMockTimeSource(controller) - localCache := freecache.NewCache(100) statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, localCache, 0.8, "", sm, false) - sink := &common.TestStatSink{} - localCacheStats := limiter.NewLocalCacheStats(localCache, statsStore.Scope("localcache")) + timeSource := mock_utils.NewMockTimeSource(controller) - // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{4, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) + redisSrv := mustNewRedisServer() + defer redisSrv.Close() - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}}, 1) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, true, "", nil, false), - } + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + + request := common.NewRateLimitRequest("reset_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("reset_key_value"), false, false, false, "", nil, false)} - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 1, 1, 0, 0) - - // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{2, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 2, 0, 0) - - // Test Over limit stats - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{0, 1, 0}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) - - // The result should be OK since limit is in ShadowMode - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.ShadowMode.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 3, 0, 1) - - // Test Over limit stats with local cache - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(4) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "775", "1", "1000000").Times(0) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), - "EXPIRE", "domain_key4_value4", int64(3600)).Times(0) - - // The result should be OK since limit is in ShadowMode - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - - // Even if you hit the local cache, other metrics should increase normally. - assert.Equal(uint64(4), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(2), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(2), limits[0].Stats.ShadowMode.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 1, 3, 4, 0, 1) + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, utils.CalculateReset(&limits[0].Limit.Unit, timeSource), response[0].DurationUntilReset) } -func TestRedisTracer(t *testing.T) { - assert := assert.New(t) +func TestRedisTokenBucketShadowMode(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() - testSpanExporter.Reset() - statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) + timeSource := mock_utils.NewMockTimeSource(controller) - client := mock_redis.NewMockClient(controller) + redisSrv := mustNewRedisServer() + defer redisSrv.Close() - timeSource := mock_utils.NewMockTimeSource(controller) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) - timeSource.EXPECT().UnixNow().Return(int64(1234)).MaxTimes(4) + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key_value", "domain_key_value:expires", "10", "10", "775", "1", "1234").SetArg(1, []int64{5, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil) + request := common.NewRateLimitRequest("shadow_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{config.NewRateLimit(1, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("shadow_key_value"), false, true, false, "", nil, false)} - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key", "value"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("key_value"), false, false, "", nil, false)} - cache.DoLimit(context.Background(), request, limits) + // First request takes the only token + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) - spanStubs := testSpanExporter.GetSpans() - assert.NotNil(spanStubs) - assert.Len(spanStubs, 1) - assert.Equal(spanStubs[0].Name, "Redis Pipeline Execution") + // Second request would be over limit, but shadow mode returns OK + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) } -func TestOverLimitWithStopCacheKeyIncrementWhenOverlimitConfig(t *testing.T) { - assert := assert.New(t) +func TestRedisTokenBucketLimitRemaining(t *testing.T) { controller := gomock.NewController(t) defer controller.Finish() - client := mock_redis.NewMockClient(controller) + statsStore := gostats.NewStore(gostats.NewNullSink(), false) + sm := stats.NewMockStatManager(statsStore) timeSource := mock_utils.NewMockTimeSource(controller) - localCache := freecache.NewCache(100) + + redisSrv := mustNewRedisServer() + defer redisSrv.Close() + + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, false) + + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + + request := common.NewRateLimitRequest("remaining_domain", [][][2]string{{{"key", "value"}}}, 1) + limits := []*config.RateLimit{config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("remaining_key_value"), false, false, false, "", nil, false)} + + // 5 tokens, consume 1 at a time, check LimitRemaining decreases + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, uint32(4), response[0].LimitRemaining) + + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, uint32(3), response[0].LimitRemaining) + + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, uint32(2), response[0].LimitRemaining) + + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, uint32(1), response[0].LimitRemaining) + + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, uint32(0), response[0].LimitRemaining) + + // 6th request should be over limit + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OVER_LIMIT, response[0].Code) + assert.Equal(t, uint32(0), response[0].LimitRemaining) +} + +func TestRedisTokenBucketStopIncrementAllDescriptors(t *testing.T) { + controller := gomock.NewController(t) + defer controller.Finish() + statsStore := gostats.NewStore(gostats.NewNullSink(), false) sm := stats.NewMockStatManager(statsStore) - cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, localCache, 0.8, "", sm, true) - sink := &common.TestStatSink{} - localCacheStats := limiter.NewLocalCacheStats(localCache, statsStore.Scope("localcache")) + timeSource := mock_utils.NewMockTimeSource(controller) + + redisSrv := mustNewRedisServer() + defer redisSrv.Close() - // Test Near Limit Stats. Under Near Limit Ratio - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(7) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key4_value4").SetArg(1, uint32(11)).DoAndReturn(pipeAppend) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key5_value5").SetArg(1, uint32(11)).DoAndReturn(pipeAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{4, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key5_value5", "domain_key5_value5:expires", "14", "14", "3600000", "1", "1000000").SetArg(1, []int64{3, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) + client := redis.NewClientImpl(context.Background(), statsStore, false, "", "tcp", "single", redisSrv.Addr(), 1, 0, 0, nil, false, nil, 0, "", "", 0, 0, 0) + cache := redis.NewFixedRateLimitCacheImpl(client, nil, timeSource, rand.New(rand.NewSource(1)), 0, nil, 0.8, "", sm, true) - request := common.NewRateLimitRequest("domain", [][][2]string{{{"key4", "value4"}}, {{"key5", "value5"}}}, 1) + timeSource.EXPECT().UnixNow().Return(int64(1798825388)).AnyTimes() + // Two descriptors: one with limit 2, one with limit 10 + // When key1 goes over limit, ALL descriptors stop consuming tokens + request := common.NewRateLimitRequest("multi_domain", [][][2]string{{{"key1", "val1"}}, {{"key2", "val2"}}}, 1) limits := []*config.RateLimit{ - config.NewRateLimit(15, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key4_value4"), false, false, "", nil, false), - config.NewRateLimit(14, pb.RateLimitResponse_RateLimit_HOUR, sm.NewStats("key5_value5"), false, false, "", nil, false), + config.NewRateLimit(2, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("multi_key1_val1"), false, false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_SECOND, sm.NewStats("multi_key2_val2"), false, false, false, "", nil, false), } - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 4, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 3, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(1), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(0), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[0].Stats.WithinLimit.Value()) - assert.Equal(uint64(1), limits[1].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(0), limits[1].Stats.NearLimit.Value()) - assert.Equal(uint64(1), limits[1].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 1, 1, 0, 0) - - // Test Near Limit Stats. At Near Limit Ratio, still OK - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(7) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key4_value4").SetArg(1, uint32(13)).DoAndReturn(pipeAppend) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key5_value5").SetArg(1, uint32(13)).DoAndReturn(pipeAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "1", "1000000").SetArg(1, []int64{2, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key5_value5", "domain_key5_value5:expires", "14", "14", "3600000", "1", "1000000").SetArg(1, []int64{1, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 2, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(2), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[0].Stats.WithinLimit.Value()) - assert.Equal(uint64(2), limits[1].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(1), limits[1].Stats.NearLimit.Value()) - assert.Equal(uint64(2), limits[1].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 2, 0, 0) - - // Test one key is reaching to the Overlimit threshold - timeSource.EXPECT().UnixNow().Return(int64(1000000)).MaxTimes(7) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key4_value4").SetArg(1, uint32(0)).DoAndReturn(pipeAppend) - client.EXPECT().PipeAppend(gomock.Any(), gomock.Any(), "GET", "domain_key5_value5").SetArg(1, uint32(1)).DoAndReturn(pipeAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key4_value4", "domain_key4_value4:expires", "15", "15", "3600000", "0", "1000000").SetArg(1, []int64{1, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeScriptAppend(gomock.Any(), gomock.Any(), gomock.Any(), "domain_key5_value5", "domain_key5_value5:expires", "14", "14", "3600000", "1", "1000000").SetArg(1, []int64{0, 1, 1}).DoAndReturn(pipeScriptAppend) - client.EXPECT().PipeDo(gomock.Any(), gomock.Any()).Return(nil).Times(2) - - assert.Equal( - []*pb.RateLimitResponse_DescriptorStatus{ - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 1, DurationUntilReset: utils.CalculateReset(&limits[0].Limit.Unit, timeSource)}, - {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0, DurationUntilReset: utils.CalculateReset(&limits[1].Limit.Unit, timeSource)}, - }, - cache.DoLimit(context.Background(), request, limits)) - assert.Equal(uint64(3), limits[0].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[0].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(2), limits[0].Stats.NearLimit.Value()) - assert.Equal(uint64(3), limits[0].Stats.WithinLimit.Value()) - assert.Equal(uint64(3), limits[1].Stats.TotalHits.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimit.Value()) - assert.Equal(uint64(0), limits[1].Stats.OverLimitWithLocalCache.Value()) - assert.Equal(uint64(2), limits[1].Stats.NearLimit.Value()) - assert.Equal(uint64(3), limits[1].Stats.WithinLimit.Value()) - - // Check the local cache stats. - testLocalCacheStats(localCacheStats, statsStore, sink, 0, 2, 3, 0, 1) + // First request: both pass + response := cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, pb.RateLimitResponse_OK, response[1].Code) + assert.Equal(t, uint32(1), response[0].LimitRemaining) + assert.Equal(t, uint32(9), response[1].LimitRemaining) + + // Second request: both pass (key1 uses its last token) + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OK, response[0].Code) + assert.Equal(t, pb.RateLimitResponse_OK, response[1].Code) + assert.Equal(t, uint32(0), response[0].LimitRemaining) + assert.Equal(t, uint32(8), response[1].LimitRemaining) + + // Third request: key1 is over limit, key2 stops consuming too (all descriptors freeze) + response = cache.DoLimit(context.Background(), request, limits) + assert.Equal(t, pb.RateLimitResponse_OVER_LIMIT, response[0].Code) + assert.Equal(t, pb.RateLimitResponse_OK, response[1].Code) + assert.Equal(t, uint32(8), response[1].LimitRemaining) } diff --git a/test/server/health_test.go b/test/server/health_test.go index db40d6171..aface4035 100644 --- a/test/server/health_test.go +++ b/test/server/health_test.go @@ -18,117 +18,81 @@ import ( func TestHealthCheck(t *testing.T) { defer signal.Reset(syscall.SIGTERM) - recorder := httptest.NewRecorder() - hc := server.NewHealthChecker(health.NewServer(), "ratelimit", false) - r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) + checkHTTP := func(wantCode int) { + recorder := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) + hc.ServeHTTP(recorder, r) + if recorder.Code != wantCode { + t.Errorf("expected code %d actual %d", wantCode, recorder.Code) + } } - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + // Redis starts unhealthy until the connection is confirmed. + checkHTTP(500) - err := hc.Fail(server.RedisHealthComponentName) + err := hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } + checkHTTP(200) - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) + err = hc.Fail(server.RedisHealthComponentName) + if err != nil { + t.Errorf("Expected no errors for updating redis health status") } + checkHTTP(500) err = hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + checkHTTP(200) } func TestHealthyWithAtLeastOneConfigLoaded(t *testing.T) { defer signal.Reset(syscall.SIGTERM) - recorder := httptest.NewRecorder() - hc := server.NewHealthChecker(health.NewServer(), "ratelimit", true) - r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) + checkHTTP := func(wantCode int) { + recorder := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) + hc.ServeHTTP(recorder, r) + if recorder.Code != wantCode { + t.Errorf("expected code %d actual %d", wantCode, recorder.Code) + } } + // Both Redis and config start unhealthy. + checkHTTP(500) + err := hc.Ok(server.ConfigHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating config health status") } + // Config is ready but Redis still unhealthy. + checkHTTP(500) - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) + err = hc.Ok(server.RedisHealthComponentName) + if err != nil { + t.Errorf("Expected no errors for updating redis health status") } + // Both ready now. + checkHTTP(200) err = hc.Fail(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 500 { - t.Errorf("expected code 500 actual %d", recorder.Code) - } + checkHTTP(500) err = hc.Ok(server.RedisHealthComponentName) if err != nil { t.Errorf("Expected no errors for updating redis health status") } - - recorder = httptest.NewRecorder() - - r, _ = http.NewRequest("GET", "http://1.2.3.4/healthcheck", nil) - hc.ServeHTTP(recorder, r) - - if recorder.Code != 200 { - t.Errorf("expected code 200 actual %d", recorder.Code) - } - - if recorder.Body.String() != "OK" { - t.Errorf("expected body 'OK', got '%s'", recorder.Body.String()) - } + checkHTTP(200) } func TestGrpcHealthCheck(t *testing.T) { @@ -142,9 +106,10 @@ func TestGrpcHealthCheck(t *testing.T) { Service: "ratelimit", } + // Redis starts unhealthy until the connection is confirmed. res, _ := grpcHealthServer.Check(context.Background(), req) - if healthpb.HealthCheckResponse_SERVING != res.Status { - t.Errorf("expected status SERVING actual %v", res.Status) + if healthpb.HealthCheckResponse_NOT_SERVING != res.Status { + t.Errorf("expected status NOT_SERVING actual %v", res.Status) } err := hc.Ok(server.RedisHealthComponentName) diff --git a/test/server/server_impl_test.go b/test/server/server_impl_test.go index 2e400b872..203353ddb 100644 --- a/test/server/server_impl_test.go +++ b/test/server/server_impl_test.go @@ -26,8 +26,8 @@ func assertHttpResponse(t *testing.T, requestBody string, expectedStatusCode int, expectedContentType string, - expectedResponseBody string) { - + expectedResponseBody string, +) { t.Helper() assert := assert.New(t) diff --git a/test/service/ratelimit_test.go b/test/service/ratelimit_test.go index 6eb9d227b..c12d3b1cb 100644 --- a/test/service/ratelimit_test.go +++ b/test/service/ratelimit_test.go @@ -22,6 +22,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" "github.com/goatapp/ratelimit/src/trace" @@ -96,6 +98,8 @@ func commonSetup(t *testing.T) rateLimitServiceTestSuite { ret.statStore = gostats.NewStore(gostats.NewNullSink(), false) ret.statsManager = mock_stats.NewMockStatManager(ret.statStore) ret.health = server.NewHealthChecker(health.NewServer(), "ratelimit", false) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = ret.health.Ok(server.RedisHealthComponentName) return ret } @@ -129,7 +133,8 @@ func TestService(test *testing.T) { request := common.NewRateLimitRequest("test-domain", [][][2]string{{{"hello", "world"}}}, 1) t.config.EXPECT().GetLimit(context.Background(), "test-domain", request.Descriptors[0]).Return(nil) t.cache.EXPECT().DoLimit(context.Background(), request, []*config.RateLimit{nil}).Return( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}) + []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}, + ) response, err := service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( @@ -138,7 +143,8 @@ func TestService(test *testing.T) { OverallCode: pb.RateLimitResponse_OK, Statuses: []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}, }, - response) + response, + ) t.assert.Nil(err) // Force a config reload - config event from config provider. @@ -151,9 +157,10 @@ func TestService(test *testing.T) { // Different request. request = common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "key_name", nil, false), nil, } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) @@ -162,7 +169,8 @@ func TestService(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err = service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( t.assert, @@ -173,7 +181,8 @@ func TestService(test *testing.T) { {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, }, }, - response) + response, + ) t.assert.Nil(err) // Config load failure. @@ -187,7 +196,7 @@ func TestService(test *testing.T) { // Config should still be valid. Also make sure order does not affect results. limits = []*config.RateLimit{ nil, - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) @@ -195,7 +204,8 @@ func TestService(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, - }) + }, + ) response, err = service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( t.assert, @@ -206,7 +216,8 @@ func TestService(test *testing.T) { {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, }, }, - response) + response, + ) t.assert.Nil(err) t.assert.EqualValues(2, t.statStore.NewCounter("config_load_success").Value()) @@ -237,11 +248,12 @@ func TestServiceGlobalShadowMode(test *testing.T) { // Make a request. request := common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) // Global Shadow mode limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), nil, } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) @@ -250,7 +262,8 @@ func TestServiceGlobalShadowMode(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) // OK overall code even if limit response was OVER_LIMIT @@ -263,7 +276,8 @@ func TestServiceGlobalShadowMode(test *testing.T) { {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, }, }, - response) + response, + ) t.assert.Nil(err) t.assert.EqualValues(1, t.statStore.NewCounter("global_shadow_mode").Value()) @@ -279,10 +293,11 @@ func TestRuleShadowMode(test *testing.T) { service := t.setupBasicService() request := common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, "", nil, false), - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, false, "", nil, false), } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) @@ -290,9 +305,10 @@ func TestRuleShadowMode(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) - t.assert.Equal( + t.assert.True(proto.Equal( &pb.RateLimitResponse{ OverallCode: pb.RateLimitResponse_OK, Statuses: []*pb.RateLimitResponse_DescriptorStatus{ @@ -300,7 +316,8 @@ func TestRuleShadowMode(test *testing.T) { {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, }, }, - response) + response, + )) t.assert.Nil(err) t.assert.EqualValues(0, t.statStore.NewCounter("global_shadow_mode").Value()) @@ -312,10 +329,11 @@ func TestMixedRuleShadowMode(test *testing.T) { service := t.setupBasicService() request := common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, "", nil, false), - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, true, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[1]).Return(limits[1]) @@ -329,9 +347,10 @@ func TestMixedRuleShadowMode(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: testResults[0], CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: testResults[1], CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) - t.assert.Equal( + t.assert.True(proto.Equal( &pb.RateLimitResponse{ OverallCode: pb.RateLimitResponse_OVER_LIMIT, Statuses: []*pb.RateLimitResponse_DescriptorStatus{ @@ -339,7 +358,8 @@ func TestMixedRuleShadowMode(test *testing.T) { {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: nil, LimitRemaining: 0}, }, }, - response) + response, + )) t.assert.Nil(err) t.assert.EqualValues(0, t.statStore.NewCounter("global_shadow_mode").Value()) @@ -372,9 +392,10 @@ func TestServiceWithCustomRatelimitHeaders(test *testing.T) { // Make request request := common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), nil, } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) @@ -383,7 +404,8 @@ func TestServiceWithCustomRatelimitHeaders(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( @@ -400,7 +422,8 @@ func TestServiceWithCustomRatelimitHeaders(test *testing.T) { {Key: "A-Ratelimit-Reset", Value: "58"}, }, }, - response) + response, + ) t.assert.Nil(err) } @@ -425,9 +448,10 @@ func TestServiceWithDefaultRatelimitHeaders(test *testing.T) { // Make request request := common.NewRateLimitRequest( - "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1) + "different-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), nil, } t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) @@ -436,7 +460,8 @@ func TestServiceWithDefaultRatelimitHeaders(test *testing.T) { []*pb.RateLimitResponse_DescriptorStatus{ {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}, - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( @@ -453,7 +478,8 @@ func TestServiceWithDefaultRatelimitHeaders(test *testing.T) { {Key: "RateLimit-Reset", Value: "58"}, }, }, - response) + response, + ) t.assert.Nil(err) } @@ -487,12 +513,13 @@ func TestCacheError(test *testing.T) { service := t.setupBasicService() request := common.NewRateLimitRequest("different-domain", [][][2]string{{{"foo", "bar"}}}, 1) - limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, "", nil, false)} + limits := []*config.RateLimit{config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false)} t.config.EXPECT().GetLimit(context.Background(), "different-domain", request.Descriptors[0]).Return(limits[0]) t.cache.EXPECT().DoLimit(context.Background(), request, limits).Do( func(context.Context, *pb.RateLimitRequest, []*config.RateLimit) { panic(redis.RedisError("cache error")) - }) + }, + ) response, err := service.ShouldRateLimit(context.Background(), request) t.assert.Nil(response) @@ -527,11 +554,12 @@ func TestUnlimited(test *testing.T) { service := t.setupBasicService() request := common.NewRateLimitRequest( - "some-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}, {{"baz", "qux"}}}, 1) + "some-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}, {{"baz", "qux"}}}, 1, + ) limits := []*config.RateLimit{ - config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("foo_bar"), false, false, "", nil, false), + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("foo_bar"), false, false, false, "", nil, false), nil, - config.NewRateLimit(55, pb.RateLimitResponse_RateLimit_SECOND, t.statsManager.NewStats("baz_qux"), true, false, "", nil, false), + config.NewRateLimit(55, pb.RateLimitResponse_RateLimit_SECOND, t.statsManager.NewStats("baz_qux"), true, false, false, "", nil, false), } t.config.EXPECT().GetLimit(context.Background(), "some-domain", request.Descriptors[0]).Return(limits[0]) t.config.EXPECT().GetLimit(context.Background(), "some-domain", request.Descriptors[1]).Return(limits[1]) @@ -557,7 +585,8 @@ func TestUnlimited(test *testing.T) { {Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: math.MaxUint32}, }, }, - response) + response, + ) t.assert.Nil(err) } @@ -570,7 +599,8 @@ func TestServiceTracer(test *testing.T) { request := common.NewRateLimitRequest("test-domain", [][][2]string{{{"hello", "world"}}}, 1) t.config.EXPECT().GetLimit(context.Background(), "test-domain", request.Descriptors[0]).Return(nil) t.cache.EXPECT().DoLimit(context.Background(), request, []*config.RateLimit{nil}).Return( - []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}) + []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}, + ) response, err := service.ShouldRateLimit(context.Background(), request) common.AssertProtoEqual( @@ -579,7 +609,8 @@ func TestServiceTracer(test *testing.T) { OverallCode: pb.RateLimitResponse_OK, Statuses: []*pb.RateLimitResponse_DescriptorStatus{{Code: pb.RateLimitResponse_OK, CurrentLimit: nil, LimitRemaining: 0}}, }, - response) + response, + ) t.assert.Nil(err) spanStubs := testSpanExporter.GetSpans() @@ -596,6 +627,8 @@ func TestServiceHealthStatus(test *testing.T) { healthyWithAtLeastOneConfigLoaded := false grpcHealthServer := health.NewServer() hc := server.NewHealthChecker(grpcHealthServer, "ratelimit", healthyWithAtLeastOneConfigLoaded) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = hc.Ok(server.RedisHealthComponentName) healthpb.RegisterHealthServer(grpc.NewServer(), grpcHealthServer) // Set up the service @@ -622,6 +655,8 @@ func TestServiceHealthStatusAtLeastOneConfigLoaded(test *testing.T) { healthyWithAtLeastOneConfigLoaded := true grpcHealthServer := health.NewServer() hc := server.NewHealthChecker(grpcHealthServer, "ratelimit", healthyWithAtLeastOneConfigLoaded) + // Tests use a mocked cache, so simulate a successful Redis connection. + _ = hc.Ok(server.RedisHealthComponentName) healthpb.RegisterHealthServer(grpc.NewServer(), grpcHealthServer) // Set up the service @@ -665,3 +700,653 @@ func TestServiceHealthStatusAtLeastOneConfigLoaded(test *testing.T) { test.Errorf("expected status NOT_SERVING actual %v", res.Status) } } + +func TestServiceGlobalQuotaMode(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + // No global quota_mode, this should be picked-up from environment variables during re-load of config + service := t.setupBasicService() + + // Force a config reload. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"foo", "bar"}}, {{"hello", "world"}}}, 1, + ) + + // Global Quota mode + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, false, "", nil, false), + } + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // OVER_LIMIT overall code since all quota limits were OVER_LIMIT + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestMetadataReturnedForPassedDescriptors(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + os.Unsetenv("RESPONSE_DYNAMIC_METADATA") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, false, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), + } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_2")}} + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) + + // Verify response includes metadata about quota violations + t.assert.Nil(err) + t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) + t.assert.NotNil(response.DynamicMetadata) + + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Since descriptor 1 has passed and 2 had failed, metadata from the first descriptors should be returned + t.assert.Equal("service_1", nameVal.GetStringValue()) +} + +func TestMetadataReturnedForAllPassedDescriptors(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + os.Unsetenv("RESPONSE_DYNAMIC_METADATA") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, true, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), + } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"some_other_name": structpb.NewStringValue("service_2")}} + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 6}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) + + // Verify response includes metadata about quota violations + t.assert.Nil(err) + t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) + t.assert.NotNil(response.DynamicMetadata) + + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Both descriptors have passed metadata should contain values from both descriptors + t.assert.Equal("service_1", nameVal.GetStringValue()) + nameVal, ok = fields["some_other_name"] + t.assert.True(ok) + t.assert.Equal("service_2", nameVal.GetStringValue()) +} + +func TestOverlappingMetadataReturnsTheFirstValue(test *testing.T) { + os.Setenv("QUOTA_MODE", "true") + os.Setenv("RESPONSE_DYNAMIC_METADATA", "true") + defer func() { + os.Unsetenv("QUOTA_MODE") + os.Unsetenv("RESPONSE_DYNAMIC_METADATA") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + // Make a request. + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + limits := []*config.RateLimit{ + config.NewRateLimit(10, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key"), false, false, true, "", nil, false), + config.NewRateLimit(5, pb.RateLimitResponse_RateLimit_MINUTE, t.statsManager.NewStats("key2"), false, false, true, "", nil, false), + } + limits[0].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_1")}} + limits[1].Metadata = &structpb.Struct{Fields: map[string]*structpb.Value{"name": structpb.NewStringValue("service_2")}} + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 5}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 6}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + test.Logf("DynamicMetadata: %+v", response.DynamicMetadata) + + // Verify response includes metadata about quota violations + t.assert.Nil(err) + t.assert.Equal(pb.RateLimitResponse_OK, response.OverallCode) + t.assert.NotNil(response.DynamicMetadata) + + // Verify metadata for passed limits + passedMetadataVal, ok := response.DynamicMetadata.GetFields()["metadata"] + t.assert.True(ok) + passedMetadata := passedMetadataVal.GetStructValue() + t.assert.NotNil(passedMetadata) + + fields := passedMetadata.GetFields() + nameVal, ok := fields["name"] + t.assert.True(ok) + // Metadata from the first descriptor takes precendence + t.assert.Equal("service_1", nameVal.GetStringValue()) +} + +func TestServicePerDescriptorQuotaMode(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + + // No Global Quota mode + service := t.setupBasicService() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + // Create limits with one having quota mode enabled per-descriptor + limits := []*config.RateLimit{ + // Regular limit - should reject when exceeded + { + FullKey: "regular_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: false, + ShadowMode: false, + }, + // Quota mode limit - should not reject when exceeded + { + FullKey: "quota_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Regular limit should cause OVER_LIMIT overall, even though quota mode is under the limit + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestServiceMixedPerDescriptorModes(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + + // No Global Quota mode + service := t.setupBasicService() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + // Create limits with one having quota mode enabled per-descriptor + // In this configuration the limits will be evaluated as rate limits. + limits := []*config.RateLimit{ + // Regular limit + { + FullKey: "regular_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: false, + ShadowMode: false, + }, + // Quota mode limit + { + FullKey: "quota_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Overall result is OVER_LIMIT, since all quota limits were exceeded + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestServiceMixedPerDescriptorModesUnderLimit(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + + // No Global Quota mode + service := t.setupBasicService() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + // Create limits with one having quota mode enabled per-descriptor + // In this configuration the limits will be evaluated as rate limits. + limits := []*config.RateLimit{ + // Regular limit + { + FullKey: "regular_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: false, + ShadowMode: false, + }, + // Quota mode limit + { + FullKey: "quota_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Overall result is OVER_LIMIT, since all quota limits were exceeded + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestServiceQuotaModeOnlyAllOverTheLimit(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"quota1", "limit"}}, {{"quota2", "limit"}}}, 1, + ) + + // Both limits are in quota mode + limits := []*config.RateLimit{ + { + FullKey: "quota_limit_1", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + { + FullKey: "quota_limit_2", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Since quota limits were exceeded overall result in OVER_LIMIT + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OVER_LIMIT, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestServiceQuotaModeOnlySomeOverTheLimit(test *testing.T) { + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"quota1", "limit"}}, {{"quota2", "limit"}}}, 1, + ) + + // Both limits are in quota mode + limits := []*config.RateLimit{ + { + FullKey: "quota_limit_1", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + { + FullKey: "quota_limit_2", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Since only some quota limits were exceeded overall result is OK + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OK, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) +} + +func TestServiceQuotaModeWithShadowMode(test *testing.T) { + os.Setenv("SHADOW_MODE", "true") + defer func() { + os.Unsetenv("SHADOW_MODE") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + // Mix of regular and quota mode limits with global shadow mode + limits := []*config.RateLimit{ + { + FullKey: "regular_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + { + FullKey: "quota_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Global shadow mode should override everything and result in OK + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) + + // Verify global shadow mode counter is incremented + t.assert.EqualValues(1, t.statStore.NewCounter("global_shadow_mode").Value()) +} + +func TestServiceMixedModeWithShadowMode(test *testing.T) { + os.Setenv("SHADOW_MODE", "true") + defer func() { + os.Unsetenv("SHADOW_MODE") + }() + + t := commonSetup(test) + defer t.controller.Finish() + + service := t.setupBasicService() + + // Force a config reload to pick up environment variables. + barrier := newBarrier() + t.configUpdateEvent.EXPECT().GetConfig().DoAndReturn(func() (config.RateLimitConfig, any) { + barrier.signal() + return t.config, nil + }) + t.configUpdateEventChan <- t.configUpdateEvent + barrier.wait() + + request := common.NewRateLimitRequest( + "quota-domain", [][][2]string{{{"regular", "limit"}}, {{"quota", "limit"}}}, 1, + ) + + // Mix of regular and quota mode limits with global shadow mode + limits := []*config.RateLimit{ + { + FullKey: "regular_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 5, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: false, + ShadowMode: false, + }, + { + FullKey: "quota_limit", + Limit: &pb.RateLimitResponse_RateLimit{RequestsPerUnit: 3, Unit: pb.RateLimitResponse_RateLimit_MINUTE}, + QuotaMode: true, + ShadowMode: false, + }, + } + + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[0]).Return(limits[0]) + t.config.EXPECT().GetLimit(context.Background(), "quota-domain", request.Descriptors[1]).Return(limits[1]) + t.cache.EXPECT().DoLimit(context.Background(), request, limits).Return( + []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + ) + response, err := service.ShouldRateLimit(context.Background(), request) + + // Global shadow mode should override everything and result in OK + common.AssertProtoEqual( + t.assert, + &pb.RateLimitResponse{ + OverallCode: pb.RateLimitResponse_OK, + Statuses: []*pb.RateLimitResponse_DescriptorStatus{ + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[0].Limit, LimitRemaining: 0}, + {Code: pb.RateLimitResponse_OVER_LIMIT, CurrentLimit: limits[1].Limit, LimitRemaining: 0}, + }, + }, + response, + ) + t.assert.Nil(err) + + // Verify global shadow mode counter is incremented + t.assert.EqualValues(1, t.statStore.NewCounter("global_shadow_mode").Value()) +}