Summary
The Redis rate limiter increments a counter and then sets its expiry as two separate commands, with the expiry applied only when the counter reads exactly 1. If anything interrupts the pair, the key is left with no TTL: the fixed window never resets, the counter grows forever, and every subsequent request on that key is denied until someone deletes the key by hand.
The most damaging key this can happen to is strict:/v1/github/webhook:installation:{id} — a stuck key means every webhook delivery for that installation is 429'd indefinitely, and GitHub does not auto-redeliver.
Mechanism (verified at HEAD, 776c414)
src/selfhost/redis-ratelimit.ts:24-27:
const count = await redis.incr(k);
if (count === 1) await redis.expire(k, body.windowSeconds); // start the window on first hit
const ttlMs = await redis.pttl(k);
const resetMs = ttlMs > 0 ? ttlMs : body.windowSeconds * 1000;
Failure paths that leave a TTL-less key:
- the process dies or the connection drops between
INCR and EXPIRE;
- the
EXPIRE itself errors — which surfaces as fail-open for that one request via checkRateLimitBucket's catch, and then never runs again, because count will never be 1 for that key afterwards.
The symptom is then actively masked: pttl returning -1 (no expiry) is treated identically to "no key", so resetMs falls back to a full window and the 429 keeps promising "retry in 60 s" forever.
This file has not been touched since it was created (#977/#1157) and has never been revisited.
Requirements
- Counter increment and window establishment must be atomic.
- A key that somehow ends up without a TTL must self-heal rather than deny forever.
- The reported
retryAfter must not claim a reset that will never happen.
Deliverables
Tests (must fail against current main)
Related
Summary
The Redis rate limiter increments a counter and then sets its expiry as two separate commands, with the expiry applied only when the counter reads exactly 1. If anything interrupts the pair, the key is left with no TTL: the fixed window never resets, the counter grows forever, and every subsequent request on that key is denied until someone deletes the key by hand.
The most damaging key this can happen to is
strict:/v1/github/webhook:installation:{id}— a stuck key means every webhook delivery for that installation is 429'd indefinitely, and GitHub does not auto-redeliver.Mechanism (verified at HEAD, 776c414)
src/selfhost/redis-ratelimit.ts:24-27:Failure paths that leave a TTL-less key:
INCRandEXPIRE;EXPIREitself errors — which surfaces as fail-open for that one request viacheckRateLimitBucket's catch, and then never runs again, becausecountwill never be 1 for that key afterwards.The symptom is then actively masked:
pttlreturning-1(no expiry) is treated identically to "no key", soresetMsfalls back to a full window and the 429 keeps promising "retry in 60 s" forever.This file has not been touched since it was created (#977/#1157) and has never been revisited.
Requirements
retryAftermust not claim a reset that will never happen.Deliverables
INCR+PEXPIRE NXin one round trip;SET k 0 EX w NXbeforeINCR; or an unconditionalEXPIRE k w NX(Redis ≥ 7). Note the atomic form also removes the second round trip, so this is a latency win as well.pttlreturns-1(key exists, no expiry), set the expiry and treat the window as fresh rather than reporting a bogus reset.Tests (must fail against current main)
EXPIREfailure after a successfulINCR⇒ the key still acquires a TTL on the next request, and the bucket resets on schedule.Related