Every check your app runs against external infrastructure, a rate limiter’s Redis backend, an auth token lookup, a cache, a fraud scoring service, has a failure mode where the infrastructure itself is unreachable, not just returning a “no.” When that happens, the code sitting behind the check has exactly two options: let the request through anyway, or reject it. There is no third option where the check “just works” despite its dependency being down, so this decision has to be made explicitly, in code, before it happens in production at 2am.
The two choices have names. Fail open means the request proceeds when the check can’t run. Fail closed means the request is rejected when the check can’t run. People sometimes talk about one of these as the “safe” default, but safety here is a property of what the check is protecting, not of the failure mode itself. The question to ask is: which is worse, letting a request through that the check would have blocked, or blocking a request that the check would have let through?
Fail open: a rate limiter that isn’t the point of the product
flash-sale-system is a ticket-reservation service built to survive a stampede of concurrent buyers without overselling inventory. Every reservation request passes through a rate limiter backed by Redis:
async def check_rate_limit(request: Request):
client_ip = request.client.host if request.client else "unknown"
key = f"rate_limit:{client_ip}"
try:
current = await r_async.incr(key)
if current == 1:
await r_async.expire(key, RATE_LIMIT_DURATION)
if current > RATE_LIMIT_MAX:
raise HTTPException(status_code=429, detail="Too many requests. Slow down!")
except redis.ConnectionError:
# Fail open if Redis is down? Or Fail closed?
# For a demo, let's fail open but log it.
print("Rate limit check skipped (Redis down)")
If Redis is unreachable, the except block doesn’t raise. It logs a line and lets the request continue to the actual reservation logic. That’s a deliberate fail-open choice, and it’s the right one for this specific check, for a specific reason: the rate limiter is not what keeps this system correct. The inventory guarantee, no overselling, comes from a separate Lua script that runs atomically inside Redis and decrements the ticket counter as a single indivisible operation. The rate limiter’s job is narrower: throttle abuse. If Redis goes down entirely, the reservation endpoint has bigger problems than rate limiting anyway, since the same Redis instance holds the inventory counter. But if only the rate limiter’s specific Redis calls are flaking, intermittently timing out, network blip, whatever, failing closed here would mean turning away real buyers during a flash sale because a secondary defense mechanism hiccuped. Blocking legitimate ticket purchases is a worse outcome than temporarily losing the abuse throttle, so fail open is correct.
The general shape: fail open makes sense when the check is a secondary defense, not the source of correctness, and when denying the request is the more expensive failure than letting it through.
Fail closed: an auth check that has nothing to fall back to
The same codebase has a check that behaves the opposite way, with no comment agonizing over it because the answer isn’t ambiguous. get_current_user decodes the JWT on every authenticated request:
async def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None or not isinstance(username, str):
raise HTTPException(status_code=401, detail="Invalid credentials")
return username
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid credentials") from exc
There’s no branch here where a decode failure falls through to “let them in anyway.” Any problem, an expired token, a bad signature, a malformed payload, ends in a 401. That’s fail closed, and it’s correct because there’s no meaningful distinction between “the auth check is broken” and “the request isn’t authenticated.” A rate limiter that can’t run has a fallback state, everyone gets treated as under the limit. An auth check that can’t run doesn’t have an equivalent safe fallback, because the entire point of the check is to distinguish “this is really the user” from “this is not,” and skipping that distinction when it can’t be verified is the same as granting access to anyone who asks.
The actual decision rule
The choice isn’t about which failure mode sounds more cautious in the abstract. It’s about answering one question honestly: if this check is unavailable, what’s the cost of a false negative (letting a bad request through) versus a false positive (blocking a good one)? A rate limiter guarding a ticket sale has a cheap false negative, some extra load makes it to the reservation logic, which has its own correctness guarantee downstream. An auth check has an expensive false negative, letting an unauthenticated request through defeats the purpose of having auth at all. Once you frame it that way, the code stops needing a comment like “fail open if Redis is down? or fail closed?” sitting in the middle of it, because the answer was already implied by what the check exists to protect.