Fail-Open vs Fail-Closed on Redis Outage

Every limiter has a third outcome besides allow and deny: cannot decide. What happens then is a policy choice, and leaving it to a default β€” or to whatever an exception handler happens to do β€” is how an outage in a counter store becomes either an unlimited API or a total one. This is the degradation half of the Redis counter architecture guide.

The problem in concrete numbers

Redis becomes unreachable for 90 seconds during a failover that took longer than expected.

Fail-open, correctly implemented: every request is admitted, the ratelimit_failopen_total counter climbs at 800 per second, an alert fires within 30 seconds, and the backend absorbs unlimited traffic for a minute and a half. If your service can survive that, this is the right answer for public read endpoints.

Fail-closed: every request returns 429, clients back off, the backend is idle, and 72,000 requests are rejected. For a payments endpoint that is correct β€” better to reject a charge than to process it without a quota check.

Neither, which is what most systems ship: a 5-second client timeout with no policy, so every request blocks a worker for 5 seconds, the pool exhausts in under a second, and the service returns 502 for everything including endpoints that have no limiter at all.

Three outcomes of the same ninety-second store outage Fail-open admits everything and raises an alert, fail-closed rejects everything and protects the backend, and having no policy at all blocks every worker on a long timeout and takes the whole service down. 90 seconds without the counter store fail-open 72,000 admitted no limits enforced alert fires in 30 s right for public reads fail-closed 72,000 rejected backend untouched clients back off right for payments no policy every worker blocked 5 s pool exhausted in < 1 s 502 on unrelated routes right for nothing the third column is the default when nobody makes the decision

Choosing per route class

Route class Policy Reasoning
Public reads, search, listings Fail-open Unlimited reads for minutes is cheaper than an outage
Authenticated reads Fail-open Same, and the identity is known for later reconciliation
Writes with side effects Fail-closed An unmetered write may cost money or create duplicates
Payments, provisioning, spending Fail-closed Never process value without a quota check
Authentication, password reset Fail-closed An unlimited login endpoint is a credential-stuffing invitation
Abuse-prone anonymous endpoints Fail-closed, or local fallback The limiter is the only defence
Internal service-to-service Fail-open The caller is trusted and capacity is planned

The right answer is not one policy for the whole API. It is a default β€” usually fail-open β€” with an explicit list of route classes that override it, reviewed whenever a new endpoint moves money.

The middle option: local fallback

Neither extreme is necessary if the process can enforce something while the store is away. A small in-memory bucket per node, sized at the fleet limit divided by the node count, keeps a bound on the damage without rejecting everything.

typescript
// limiter.ts β€” shared store first, local bucket as a bounded fallback.
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!, {
  commandTimeout: 30,              // hard bound: the limiter cannot own the request budget
  maxRetriesPerRequest: 1,
  enableOfflineQueue: false,       // fail fast instead of queueing during an outage
});

type Policy = "open" | "closed" | "local";

interface Verdict { allowed: boolean; degraded: boolean; retryAfterMs: number; }

// One in-memory bucket per key per node, used only while the store is unavailable.
const local = new Map<string, { tokens: number; ts: number }>();

function localCheck(key: string, capacity: number, refillPerSec: number, nodes: number): boolean {
  // Each node enforces its share, so the fleet stays near the intended total.
  const cap = Math.max(1, Math.floor(capacity / nodes));
  const rate = refillPerSec / nodes;
  const now = Date.now();
  const s = local.get(key) ?? { tokens: cap, ts: now };
  s.tokens = Math.min(cap, s.tokens + ((now - s.ts) / 1000) * rate);
  s.ts = now;
  if (s.tokens < 1) { local.set(key, s); return false; }
  s.tokens -= 1; local.set(key, s);
  return true;
}

export async function check(
  key: string, capacity: number, refillPerSec: number,
  { policy, nodes = 1 }: { policy: Policy; nodes?: number },
): Promise<Verdict> {
  try {
    const [allowed, , retryMs] = await redis.evalsha(SHA, 1, key, capacity, refillPerSec) as number[];
    return { allowed: allowed === 1, degraded: false, retryAfterMs: retryMs };
  } catch (err) {
    // The store could not decide. Everything below is the POLICY, not an accident.
    metrics.increment("ratelimit_degraded_total", { policy, key_class: classify(key) });
    switch (policy) {
      case "closed":
        return { allowed: false, degraded: true, retryAfterMs: 1000 };
      case "local":
        return { allowed: localCheck(key, capacity, refillPerSec, nodes), degraded: true, retryAfterMs: 1000 };
      case "open":
      default:
        return { allowed: true, degraded: true, retryAfterMs: 0 };
    }
  }
}

Two details in that code carry most of the value. commandTimeout: 30 bounds how much of the request budget the limiter can consume β€” without it, the policy never runs because every request is still waiting. And enableOfflineQueue: false prevents the client from silently queueing thousands of commands that will all resolve at once when the store returns, producing a second spike right after recovery.

Traffic admitted under each degradation policy Fail-open admits all traffic during the outage, local fallback admits roughly the intended limit spread across nodes, and fail-closed admits none; all three return to normal when the store recovers. Admitted traffic through the outage window store unavailable fail-open: unlimited local fallback: about the limit fail-closed: nothing local fallback keeps a bound without turning an outage into a rejection storm

Step-by-step: implementing the policy

Circuit breaker states around a store outage The breaker starts closed and calls the store, opens after repeated failures so the policy applies immediately without waiting for timeouts, then half-opens to probe and closes again once a probe succeeds. Skip the timeout once you know the store is gone closed call the store N failures open apply the policy at once cooldown half-open one probe decides probe succeeds → closed; without this arrow the limiter never enforces again

Gotchas and edge cases

  • The exception handler is the policy. A try/catch that logs and continues is a fail-open decision nobody made. Make it explicit and configurable.
  • Timeouts that only cover connection setup. Many clients distinguish connect timeout from command timeout; a stalled but connected store then blocks indefinitely. Set both.
  • Recovery stampede. When the store returns, every queued command fires and every client retries at once. Disable offline queues, and jitter Retry-After on the fail-closed path.
  • Fail-closed returning 500. A 500 tells clients to retry immediately and tells your error budget it was a server fault. Use 429 with a short Retry-After.
  • No distinction between β€œstore down” and β€œover limit”. They need different metrics and different alerts; a spike in one is an infrastructure page, the other is business as usual.
  • Local fallback with no node awareness. A per-node bucket sized at the full fleet limit multiplies the effective limit by the node count during the outage. Divide by the node count.
  • Circuit breaker that never closes. A breaker with no half-open probe leaves you degraded long after the store recovers β€” which the drill in chaos testing is designed to catch.

Verification and testing

bash
# 1. Blackhole the store and confirm the configured behaviour on each route class.
sudo iptables -A OUTPUT -p tcp --dport 6379 -j DROP

# public read: expect 200 (fail-open) and a degraded metric
curl -s -o /dev/null -w "read:  %{http_code}  %{time_total}s\n" \
  -H "X-API-Key: $KEY" https://staging.example.com/v1/search
# payments: expect 429 with Retry-After (fail-closed)
curl -s -D- -o /dev/null -X POST -H "X-API-Key: $KEY" \
  https://staging.example.com/v1/charges | grep -iE "^HTTP/|^retry-after"

sudo iptables -D OUTPUT -p tcp --dport 6379 -j DROP

# 2. Latency must stay bounded by the command timeout, not by the outage.
#    time_total above should be ~0.03 s plus normal handling, never 5 s.

# 3. The degraded metric must be non-zero, labelled, and alertable.
curl -s localhost:9090/metrics | grep ratelimit_degraded_total
# ratelimit_degraded_total{policy="open",key_class="search"} 412
# ratelimit_degraded_total{policy="closed",key_class="charges"} 27

# 4. After recovery, enforcement must resume β€” not stay open.
for i in $(seq 1 300); do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: $KEY" \
    https://staging.example.com/v1/search
done | sort | uniq -c
# expect a mix of 200 and 429; all-200 means the breaker never closed

Check four is the one that catches the subtlest failure: a limiter that degrades correctly and then never recovers looks perfectly healthy on every dashboard while enforcing nothing at all.

Frequently Asked Questions

Which policy should be the default?

Fail-open for read paths, because an unlimited API for a few minutes is usually less damaging than an unavailable one. Then list the route classes that override it β€” anything that spends money, creates resources, or authenticates should fail closed. The important part is that both are decisions rather than defaults inherited from a catch block.

Is a local in-memory fallback worth the complexity?

On high-traffic public endpoints, yes: it keeps a bound on the damage during an outage without rejecting legitimate traffic, and the code is a few dozen lines. Size each node's bucket at the fleet limit divided by the node count, and treat the result as approximate β€” it is a safety net, not a replacement for the shared counter.

What status code should a fail-closed limiter return?

429 with a short Retry-After. It produces exactly the client behaviour you want β€” back off and try again shortly β€” whereas a 500 invites immediate retries and pollutes your availability metrics with what is really a deliberate degradation.

How quickly should the alert fire?

Within tens of seconds. Alert on the degraded counter being non-zero for a short window rather than on its rate, because during a full outage the rate is high and steady, and during a partial one it is low but equally serious. Either way, an unlimited API is a condition somebody should know about immediately.