Skip to content

Merge decay backend + allowlist (PLA-8128/8129) into main - #3

Merged
ysolomon-plat merged 6 commits into
mainfrom
redis-decay-backend
Sep 3, 2026
Merged

Merge decay backend + allowlist (PLA-8128/8129) into main#3
ysolomon-plat merged 6 commits into
mainfrom
redis-decay-backend

Conversation

@ysolomon-plat

Copy link
Copy Markdown

Summary

Test plan

  • CI green on this PR
  • Confirm main tip after merge matches the currently deployed image SHA

Adds BACKEND_TYPE=redis_decay: a continuously-decaying counter
(GCRA family) executed as an atomic Redis EVAL. Unlike the fixed-window
redis backend, cache keys carry no window timestamp, so budgets never
reset at a boundary and the 2x boundary-burst is impossible by
construction. Honors per-descriptor hits_addend. Fail-open on Redis
errors. Cluster-safe: EVAL routes by cache key.

Also adds SOFT_BREACH_HEADER_RATIO: when set (0..1), admitted requests
whose remaining budget falls inside the soft band get an
x-ratelimit-soft-breach: 1 response header, so clients can degrade
before hard 429s. Computed generically from CurrentLimit/LimitRemaining,
so it works with any cache backend.

Covered by test/redis_decay (real EVAL execution on miniredis: boundary
immunity, decay recovery, flood behavior, corrupt data, backward clock,
fail-open, hits_addend) and test/service/soft_breach_test.go. All
existing tests pass.

Load-tested on Istio 1.30 / Gateway API: 3,950 rps sustained on one
replica, +2.3ms p50 overhead, exact admission under concurrency.

Refs envoyproxy#32 (rolling window request, open since 2018).

Signed-off-by: ysolomon-plat <yossief.solomon@depop.com>
The backend swallowed Redis errors and returned OK. That hid outages from
the RedisError stat and, worse, overrode the caller's failure-mode policy:
an operator configuring failure_mode_deny would still have got fail-open.

Panic with redis.RedisError as the fixed-window backend does, so the
service layer classifies it and the configured policy decides.

Found by a fault-injection test on a live cluster.

Signed-off-by: ysolomon-plat <yossief.solomon@depop.com>
Replaces the global SOFT_BREACH_HEADER_RATIO with soft_requests_per_unit
configured per descriptor, and sends the signal to the upstream service
rather than back to the client.

Both changes came from auditing the behaviour this mirrors. A ratio applied
to every descriptor is not the same thing as a threshold configured on the
one descriptor that wants it, and the consumer of the signal is the backend
deciding whether to degrade, not the caller.

Adding a field to the config struct is not sufficient on its own:
validateYamlKeys keeps a separate allowlist, and GetLimit takes a defensive
copy via an explicit struct literal, so a new field is silently dropped in
both places. Covered by a test that parses real YAML rather than building
the struct directly, which is what would have caught it.

Signed-off-by: ysolomon-plat <yossief.solomon@depop.com>
Adds RETRY_AFTER_HEADER_ENABLED. When a request is rejected, the response
carries Retry-After set to the breaching descriptor's own period in
seconds, so clients back off instead of retrying immediately into a
saturated bucket.

Opt-in, matching the existing custom-header flag, so no existing
deployment sees a change in response shape.

Placed after the global shadow-mode conversion: a shadowed breach is
admitted, so telling the client to back off would be wrong. Per-descriptor
shadow_mode is skipped for the same reason. Upstream's own suite caught
that ordering when the header was briefly emitted on a shadowed 200.

Signed-off-by: ysolomon-plat <yossief.solomon@depop.com>
Adds a per-request allowlist check to cache_impl.go, run before any decay
counter is touched: an operator SADDs a value to a Redis SET
(allowlist:rl_ip / allowlist:rl_ua / allowlist:rl_subject) and it takes
effect on the very next request, no deploy, no EnvoyFilter apply. Replaces
the compiled-in Lua tables (IP_ALLOWLIST / USER_ID_ALLOWLIST) that made a
config change a deploy.

Matches ratelimiting.lua's existing, asymmetric exemption rules exactly
(ip_rate_limit_with_pooling / user_agent_rate_limit_with_pooling /
auth_rate_limit_with_pooling, :204-253): an allowlisted IP exempts rl_ip,
rl_ua AND rl_subject together for the whole request; an allowlisted
user-agent or user-id exempts only its own bucket; client_id is never
exempted by any allowlist.

This code has been running live in Depop's dev-default cluster since
2026-08-21 (deployed via a ConfigMap, source-controlled only there) without
ever being pushed here, reviewed, or tested. Adds 8 tests covering the
asymmetric exemption rules, the client_id exclusion, the no-allowlist
baseline, and the Redis-down failure path (must still panic as
redis.RedisError, matching the existing decay-pipeline failure convention).
Resolves an add/add conflict on src/redis_decay/cache_impl.go: main only
had PR #1's decay-only version; redis-decay-backend's version is that same
file with PR #2's allowlist logic layered on top (confirmed via diff -
identical base, additive change only). Took the branch's version.
@ysolomon-plat
ysolomon-plat merged commit 23d6701 into main Sep 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant