Skip to content

feat: add redis_decay backend and soft-breach response header - #1215

Open
ysolomon-plat wants to merge 4 commits into
envoyproxy:mainfrom
depoplabs:redis-decay-backend
Open

feat: add redis_decay backend and soft-breach response header#1215
ysolomon-plat wants to merge 4 commits into
envoyproxy:mainfrom
depoplabs:redis-decay-backend

Conversation

@ysolomon-plat

@ysolomon-plat ysolomon-plat commented Aug 18, 2026

Copy link
Copy Markdown

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 behind Envoy's ratelimit filter (Istio 1.30 / Gateway API): 3,950 rps sustained on one replica, +2.3ms p50 overhead, exact admission under concurrency (100-budget key admitted exactly 100 of 2,000 concurrent requests).

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

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>
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