Docs

Improve Rate Limiting Accuracy Across Gateway Instances

Configure limit-count-advanced sync_interval and window_type, then run deterministic checks for the staleness window delayed Redis synchronization introduces and how fixed and sliding windows differ.

Applies to:API7 Gateway 3.10.x

Roles:Platform engineer

Goal

Create two limit-count-advanced (opens in Plugin Hub docs) routes that share a quota across gateway instances through Redis — one window_type: fixed, one window_type: sliding — and run deterministic checks against your own instances that show:

  1. A brief staleness window where delayed synchronization lets more requests through than the configured count, and that it clears at the next synchronization.
  2. The documented behavioral difference between the two window types: a fixed window resets fully at each boundary, while a sliding window counts recent requests within the last time_window regardless of when that window began.

These checks confirm the mechanics documented in the plugin's own configuration reference. They do not measure an error percentage for your deployment — the size of the staleness window depends on your instance count, traffic rate, and sync_interval, which only you can measure in your own environment. limit-count-advanced is an API7 Enterprise plugin, available since v3.2.16.4; sync_interval is documented as Enterprise-only.

This guide is written against limit-count-advanced. From 3.10.1 every feature it demonstrates — sliding windows, rules, sync_interval, and the redis / redis-sentinel policies — is available directly in limit-count (opens in Plugin Hub docs), which is the plugin to reach for in new configuration. limit-count-advanced is retained as a compatibility wrapper, so the procedure below still works unchanged. See Configure Rate Limiting.

Before you begin

Set the variables used in this guide:

export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default"   # only the group the quickstart creates; otherwise copy the gateway group's UUID from Gateway Groups in the Dashboard
read -rp "Gateway group name for ADC (if used): " API7_GATEWAY_GROUP_NAME && export API7_GATEWAY_GROUP_NAME
export GATEWAY_URL="http://127.0.0.1:9080"
read -rp "First direct gateway-instance URL: " GATEWAY_URL_A && export GATEWAY_URL_A
read -rp "Second direct gateway-instance URL: " GATEWAY_URL_B && export GATEWAY_URL_B
read -rp "Redis host reachable from every gateway instance: " REDIS_HOST && export REDIS_HOST
read -rp "Redis port: " REDIS_PORT && export REDIS_PORT
read -rp 'Redis password reference ($env:// or $secret://): ' REDIS_PASSWORD_REF && export REDIS_PASSWORD_REF
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo
  • Confirm that the control plane and selected gateway group are ready:

    curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" -H "X-API-KEY: ${API7_ADMIN_KEY}"
    curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/gateway_groups/${API7_GATEWAY_GROUP_ID}/instances" -H "X-API-KEY: ${API7_ADMIN_KEY}" | grep -o '"status": *"Healthy"' | wc -l

    Expected: 200, then 2 or more. The staleness check in this guide requires two distinct data plane instances; the window-reset checks work with one.

  • GATEWAY_URL_A and GATEWAY_URL_B reach two different gateway instances directly (not through a load balancer that could route both requests to the same instance).

  • Every gateway instance can reach the same Redis database at ${REDIS_HOST}:${REDIS_PORT} with the credential resolved from REDIS_PASSWORD_REF. Configure the referenced environment variable or secret provider on every data plane before applying the routes. Use an isolated test database or key namespace; this guide creates counters with a short time_window but does not administer Redis.

  • The gateway can resolve and reach httpbin.org:443. This is a public demonstration service, so its availability and behavior are outside API7 Gateway's control.

  • curl, jq, grep, tr, and GNU envsubst are available.

  • For ADC, confirm label-selector support and reserve the working files:

    adc --version
    adc sync --help | grep -- '--label-selector'
    for file in rate-accuracy-adc.yaml.tmpl rate-accuracy-adc.yaml rate-accuracy-cleanup.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done

    ADC resolves --gateway-group by display name; the compatibility Admin API uses the group's UUID. Enter the display name of the group identified by API7_GATEWAY_GROUP_ID.

Check the fixed Admin API identifiers before using PUT:

curl -ksS --connect-timeout 3 --max-time 10 -w '\nservice: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/services/rate-accuracy-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
for id in rate-accuracy-fixed-id rate-accuracy-sliding-id; do
  curl -ksS --connect-timeout 3 --max-time 10 -w "\nroute ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done

Expected: every resource is absent. A 200 response containing a resource means the ID is in use; stop or change every occurrence because PUT replaces that ID. Admin URL IDs deliberately differ from names. For ADC, continue only if its diff creates exactly this guide's labeled resources.

Steps

Complete steps 1 and 2 for the Admin API, or skip them and complete step 3 for ADC. Do not apply both paths.

Step 1: Create the upstream service

curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/rate-accuracy-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
  -d '{"name":"rate-accuracy-guide-service","labels":{"guide":"rate-limiting-accuracy"},"upstream":{"name":"default","type":"roundrobin","scheme":"https","pass_host":"node","nodes":[{"host":"httpbin.org","port":443,"weight":100}]}}'

Step 2: Create fixed and sliding routes sharing a Redis quota

Both routes use count: 1, time_window: 5, and sync_interval: 1 — a sync interval well inside the documented [0.1, time_window) range — so a one-second gap between two requests is enough to observe delayed synchronization, and a two-second wait is enough to observe the next one. sync_interval (opens in Plugin Hub docs) defaults to -1, which synchronizes counter data to Redis on every request instead of on a timer; this guide sets an explicit positive value specifically to make the staleness window in the next section observable, not because -1 is unsafe for production use.

jq -n --arg host "${REDIS_HOST}" --argjson port "${REDIS_PORT}" --arg password "${REDIS_PASSWORD_REF}" \
  '{name:"rate-accuracy-fixed",labels:{guide:"rate-limiting-accuracy"},paths:["/rate-accuracy/fixed"],methods:["GET"],service_id:"rate-accuracy-guide-service-id",plugins:{"proxy-rewrite":{uri:"/get"},"limit-count-advanced":{window_type:"fixed",count:1,time_window:5,key_type:"var",key:"http_x_rate_key",policy:"redis",redis_host:$host,redis_port:$port,redis_password:$password,redis_database:0,sync_interval:1,allow_degradation:false,rejected_code:429,show_limit_quota_header:true}}}' \
  | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/rate-accuracy-fixed-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
      -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-

jq -n --arg host "${REDIS_HOST}" --argjson port "${REDIS_PORT}" --arg password "${REDIS_PASSWORD_REF}" \
  '{name:"rate-accuracy-sliding",labels:{guide:"rate-limiting-accuracy"},paths:["/rate-accuracy/sliding"],methods:["GET"],service_id:"rate-accuracy-guide-service-id",plugins:{"proxy-rewrite":{uri:"/get"},"limit-count-advanced":{window_type:"sliding",count:1,time_window:5,key_type:"var",key:"http_x_rate_key",policy:"redis",redis_host:$host,redis_port:$port,redis_password:$password,redis_database:0,sync_interval:1,allow_degradation:false,rejected_code:429,show_limit_quota_header:true}}}' \
  | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/rate-accuracy-sliding-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
      -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-

allow_degradation: false returns an error rather than allowing unmetered traffic if Redis becomes unreachable, matching Configure Rate Limiting.

Step 3: Apply one complete desired state with ADC

Skip this alternative if you completed steps 1 and 2. Save both routes in one restricted template:

rate-accuracy-adc.yaml.tmpl
services:
  - name: rate-accuracy-guide-service
    labels: {guide: rate-limiting-accuracy}
    upstream:
      name: default
      scheme: https
      pass_host: node
      nodes: [{host: httpbin.org, port: 443, weight: 100}]
    routes:
      - name: rate-accuracy-fixed
        labels: {guide: rate-limiting-accuracy}
        uris: [/rate-accuracy/fixed]
        methods: [GET]
        plugins:
          proxy-rewrite: {uri: /get}
          limit-count-advanced:
            window_type: fixed
            count: 1
            time_window: 5
            key_type: var
            key: http_x_rate_key
            policy: redis
            redis_host: "${REDIS_HOST}"
            redis_port: ${REDIS_PORT}
            redis_password: "${REDIS_PASSWORD_REF}"
            redis_database: 0
            sync_interval: 1
            allow_degradation: false
            rejected_code: 429
            show_limit_quota_header: true
      - name: rate-accuracy-sliding
        labels: {guide: rate-limiting-accuracy}
        uris: [/rate-accuracy/sliding]
        methods: [GET]
        plugins:
          proxy-rewrite: {uri: /get}
          limit-count-advanced:
            window_type: sliding
            count: 1
            time_window: 5
            key_type: var
            key: http_x_rate_key
            policy: redis
            redis_host: "${REDIS_HOST}"
            redis_port: ${REDIS_PORT}
            redis_password: "${REDIS_PASSWORD_REF}"
            redis_database: 0
            sync_interval: 1
            allow_degradation: false
            rejected_code: 429
            show_limit_quota_header: true

Render only the three Redis values, then lint, validate, inspect, and apply the selected state:

envsubst '${REDIS_HOST} ${REDIS_PORT} ${REDIS_PASSWORD_REF}' < rate-accuracy-adc.yaml.tmpl > rate-accuracy-adc.yaml
adc lint -f rate-accuracy-adc.yaml
adc validate -f rate-accuracy-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=rate-limiting-accuracy
adc diff -f rate-accuracy-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=rate-limiting-accuracy
adc sync -f rate-accuracy-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=rate-limiting-accuracy

Expected: validation succeeds and the reviewed diff affects only the labeled service and two routes. The rendered file contains a password reference rather than its secret value; keep its restricted permissions and remove it during cleanup.

Validate

Confirm each route enforces its quota on one instance

Use a new key per route and require one success followed by one rejection:

FIXED_KEY="fixed-$(date +%s)"
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL}/rate-accuracy/fixed" -H "X-Rate-Key: ${FIXED_KEY}")" = 200
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL}/rate-accuracy/fixed" -H "X-Rate-Key: ${FIXED_KEY}")" = 429

SLIDING_KEY="sliding-$(date +%s)"
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL}/rate-accuracy/sliding" -H "X-Rate-Key: ${SLIDING_KEY}")" = 200
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL}/rate-accuracy/sliding" -H "X-Rate-Key: ${SLIDING_KEY}")" = 429

Observe the delayed-synchronization staleness window

With a positive sync_interval, an instance does not push every admission to Redis immediately — it flushes on its own one-second schedule. A second instance that has not yet flushed or re-read still sees the quota as available, so it can admit a request that the first instance already consumed. Reproduce this against the fixed route, where a full reset at the window boundary cannot be confused with the staleness effect:

STALE_KEY="stale-$(date +%s)"
test "$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL_A}/rate-accuracy/fixed" -H "X-Rate-Key: ${STALE_KEY}")" = 200
CODE_B_IMMEDIATE=$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL_B}/rate-accuracy/fixed" -H "X-Rate-Key: ${STALE_KEY}")
sleep 2
CODE_B_AFTER_SYNC=$(curl -sS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${GATEWAY_URL_B}/rate-accuracy/fixed" -H "X-Rate-Key: ${STALE_KEY}")
echo "B immediately after A: ${CODE_B_IMMEDIATE}; B after a two-second wait: ${CODE_B_AFTER_SYNC}"
test "${CODE_B_AFTER_SYNC}" = 429

Expected: CODE_B_AFTER_SYNC is 429 — after a synchronization has had time to complete, instance B correctly sees the quota as exhausted. CODE_B_IMMEDIATE is commonly 200, demonstrating that B admitted a second request against a count: 1 quota before its next synchronization. If CODE_B_IMMEDIATE is already 429, the two curl commands took longer than sync_interval to run on your system; rerun with a larger sync_interval (and a proportionally larger time_window) to widen the window, or issue the two requests closer together. Either outcome for CODE_B_IMMEDIATE is consistent with the documented behavior — this check demonstrates that the staleness window exists and clears at the next synchronization; it does not bound its size for your deployment.

Fixed and sliding windows reset differently

This guide does not assert a specific timing for the boundary itself, because window alignment depends on when the counter was first created. The limit-count-advanced overview states the qualitative difference directly: a fixed window "tracks requests in non-overlapping time intervals," rejecting until "the next time window begins," while a sliding window "tracks requests in overlapping intervals... counting recent requests within the last configured time period, regardless of when the interval began." In practice this means a fixed window can admit a fresh count immediately after every reset — including right after a previous burst — while a sliding window keeps counting that previous burst against the new admission until it fully ages out. The plugin's own documentation notes that X-RateLimit-Remaining can occasionally show a small negative value under the sliding algorithm, which it describes as an expected property of the approximation, not a defect.

Roll back / Clean up

For the Admin API path, delete routes before their service:

for id in rate-accuracy-sliding-id rate-accuracy-fixed-id; do
  curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/rate-accuracy-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"

For ADC, review and apply an empty selected state:

rate-accuracy-cleanup.yaml
services: []
adc lint -f rate-accuracy-cleanup.yaml
adc diff -f rate-accuracy-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=rate-limiting-accuracy
adc sync -f rate-accuracy-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=rate-limiting-accuracy

Remove the working files and shell variables:

rm -f rate-accuracy-adc.yaml.tmpl rate-accuracy-adc.yaml rate-accuracy-cleanup.yaml
unset REDIS_PASSWORD_REF FIXED_KEY SLIDING_KEY STALE_KEY

Troubleshoot

  • CODE_B_IMMEDIATE is always 429, never 200: the two curl commands are taking longer than sync_interval to reach the gateway, or GATEWAY_URL_A/GATEWAY_URL_B reach the same instance. Confirm they are different instances and increase sync_interval temporarily to widen the window.
  • CODE_B_AFTER_SYNC is still 200 after the two-second wait: confirm both instances resolve the same redis_host, redis_port, and redis_database, and that sync_interval was applied — re-check the route with a GET after the PUT.
  • The Redis route returns 500: verify network access, port, password, database, and TLS settings from every gateway instance. This guide fails closed because allow_degradation is false.
  • A request bypasses its rule: with key_type: var, use http_x_rate_key without $. Always send X-Rate-Key.
  • ADC proposes unrelated changes: stop and verify the gateway-group display name, guide=rate-limiting-accuracy selector, labels, and complete desired-state file.