CogniPrep's Redis client is twelve lines, and the interesting part is the last expression:
const redisClient =
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({ url: ..., token: ... })
: null;
export const redis = redisClient;
export function isRedisConfigured(): boolean {
return redisClient !== null;
}
A module-level singleton, so each serverless instance reuses one client for its lifetime, and Upstash is REST based so there is no socket pool to exhaust. None of that is the design decision. The design decision is null.
Local development has no Upstash credentials. Preview branches often have none either. The lazy option is to throw on import, which makes every preview deploy of a marketing change fail on a feature it never touches. The next laziest is a no-op client that always says yes, which quietly removes whatever Redis was there to enforce.
So the type is Redis | null, and every caller is forced to write down what absence means for it. There are three callers, and they do not agree, which is the point.
Caller one: rate limiting, which does not fail open at all
The limiter has somewhere to go when Redis is missing, because an approximate limit is better than none:
function getLimiter(config: RateLimitConfig): Ratelimit | null {
if (!redis) {
if (process.env.NODE_ENV === 'production') {
console.error('[rate-limit] Redis not configured, falling back to in-memory.');
}
return null;
}
...
}
The in-memory fallback is per instance, so with ten warm functions a caller gets roughly ten times the allowance. That is a real weakening and it is written down as one. What matters is that the fallback is keyed the same way production is:
const key = config.namespace ? `${config.namespace}:${identifier}` : identifier;
Buckets that are separate in Redis stay separate in memory. Pooling them in the fallback would make local development disagree with production about which limit a request consumed, which is the kind of difference you only discover from a support ticket.
Two smaller things in the same file earned their comments.
The memory store used to be swept by a module-scope setInterval. That timer was created in every runtime that imported the module, including the edge runtime and the build, and because it was never unref'd it held the event loop open, which is why the test runner stopped exiting. Sweeping on access needs no timer:
function sweepMemStoreIfDue(now: number): void {
if (now - lastSweep < MEMORY_SWEEP_INTERVAL_MS) return;
lastSweep = now;
for (const key of Object.keys(memStore)) {
if (memStore[key].resetTime < now) delete memStore[key];
}
}
The store only grows while Redis is unavailable, so amortising cleanup across the calls that use it is free.
And the limiter instances are cached, which means the cache key has to carry the namespace:
const prefix = config.namespace ? `rl:${config.namespace}` : 'rl';
const key = `${prefix}:${config.limit}:${config.windowMs}`;
Key that cache on the numbers alone and two different limits that happen to share a limit and a window get the same Ratelimit instance, which means the same Redis prefix, which means one bucket. Two presets aliasing into one bucket is how a resend of a confirmation email can eat an unrelated allowance for the same client. The prefix is what separates buckets, so it belongs in anything that caches a limiter.
Caller two: webhook de-duplication, which fails open and says so
Inbound webhooks are de-duplicated with SET NX on the provider's event id. The return type has two booleans rather than one:
export interface IdempotencyResult {
isFirstDelivery: boolean;
/** True when the check could not be performed (Redis absent or erroring). */
degraded: boolean;
}
With no Redis, the answer is "go ahead, and know that I did not actually check":
if (!redis) {
logError('[webhook-idempotency] Redis not configured, cannot de-duplicate ...');
return { isFirstDelivery: true, degraded: true };
}
The reasoning is in the module header: dropping a real support email is worse than forwarding a duplicate. That is only true because of what this particular handler does. The Stripe handler, which grants paid entitlements, does not use this at all. It uses a table with a unique constraint, because its side effect has to be durable indefinitely rather than for a provider's retry window, and because somebody may well want to audit it years later. Same problem, different storage, decided by how long the guarantee has to last.
There is a matching release, which is the part that is easy to leave out:
export async function releaseWebhookEvent(provider: string, eventId: string): Promise<void> {
if (!redis) return;
await redis.del(`webhook:${provider}:${eventId}`);
}
Claim before doing the work and the claim outlives a failure, so the provider's retry is classified as a duplicate and the event disappears. The claim has to be given back on the error path or the retry is worse than useless.
Caller three: a courtesy email, which fails open for free
The "we got your message" acknowledgement is sent once per reporter, claimed with the same SET NX pattern and a long TTL. With no Redis it sends:
if (!redis) {
logDebug('[support-emails] Redis not configured, sending ack without first-request check');
return true;
}
What makes that safe is not Redis. It is that the submission endpoint behind it already has its own hourly cap, so failing open cannot be turned into a mail flood. A fail-open decision is only ever as good as the next constraint underneath it, and this one names its backstop.
The actual rule
Presets are named rather than numeric at the call site, and only some of them carry failClosed:
/**
* Default is fail-open: a Redis outage should not take the product down. But
* for endpoints whose cost is unbounded, an outage would otherwise remove the
* only thing capping spend, so those fail closed and return 429 until Redis
* recovers.
*/
failClosed?: boolean;
Which endpoints those are is the whole of the policy. Reading a page costs us nothing, so a Redis outage should never stop it. Starting a mock interview spends an AI completion per answer, and creating a checkout session spends a Stripe API call plus whatever a successful payment then obliges us to deliver. For those, the rate limiter is not a politeness mechanism, it is the only cap on spend, and a dependency being down is not a reason to lift it.
The support form is the opposite end: the thing Redis protects there is a duplicate courtesy email, and nobody has ever been harmed by two of those.
One nullable export, three call sites, and three different right answers. The null is doing more design work than the client is.
Top comments (0)