Skip to content

Commit 5e4fb8f

Browse files
committed
fix(auth/redis): fail-fast circuit breaker + offline queue off
OAuth was hanging 18-23s per request when REDIS_URL was set but Redis wasn't actually running. ioredis was buffering commands and retrying on every get/set, multiplied by the number of secondaryStorage calls Better Auth makes per request. - enableOfflineQueue: false — commands fail instantly, no buffering - connectTimeout: 500ms - maxRetriesPerRequest: 1 - process-local circuit breaker — once 'error' fires, get/set/delete short-circuit to null/no-op until the 'ready' event resets it - single connection-error log per outage; 'reconnected' log on recovery NOTE: when REDIS_URL is set but unreachable, Better Auth still uses the in-memory state store but secondaryStorage calls return null — which means OAuth state, magic-link tokens etc. cannot round-trip. For local dev: run `pnpm redis:start` OR unset REDIS_URL in .env.
1 parent 268abeb commit 5e4fb8f

1 file changed

Lines changed: 49 additions & 11 deletions

File tree

packages/auth/src/lib/redis.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
// in-memory store (fine for dev / single-instance). In production, set
55
// REDIS_URL to enable the shared rate-limit window across replicas and
66
// survive container restarts.
7+
//
8+
// Fail-fast policy: when Redis is unreachable we trip a process-local
9+
// circuit breaker so subsequent get/set/delete calls return immediately
10+
// (null / no-op) instead of blocking the request thread on retries.
11+
// The breaker resets on `ready` so a recovered Redis is picked up
12+
// without a server restart.
713

814
import "server-only";
915
import { env } from "@starter-saas/env/server";
1016
import Redis from "ioredis";
1117

1218
let client: Redis | null = null;
19+
let breakerOpen = false;
1320
let warned = false;
1421

1522
function getRedis(): Redis | null {
@@ -21,18 +28,41 @@ function getRedis(): Redis | null {
2128
}
2229
client = new Redis(env.REDIS_URL, {
2330
password: env.REDIS_PASSWORD,
24-
// Connection retries are handled by ioredis defaults. We don't want
25-
// auth requests to hang for minutes on a downed Redis — fail fast
26-
// and fall back to the next layer.
27-
maxRetriesPerRequest: 2,
31+
// Aggressive fail-fast settings — auth requests must never block on a
32+
// downed Redis. Better Auth's secondaryStorage is best-effort; when
33+
// it errors we fall back to the in-memory layer.
34+
maxRetriesPerRequest: 1,
2835
enableReadyCheck: true,
2936
lazyConnect: false,
37+
connectTimeout: 500,
38+
// Reject queued commands immediately instead of waiting for a
39+
// connection that may never come up. Without this ioredis buffers
40+
// commands and resolves them after `maxRetriesPerRequest` * backoff.
41+
enableOfflineQueue: false,
42+
retryStrategy(times) {
43+
// Back off exponentially up to 30s. Don't return `null` (would
44+
// kill the client) — we want the breaker to flip closed if Redis
45+
// recovers.
46+
return Math.min(times * 1000, 30_000);
47+
},
3048
});
3149
client.on("error", (err) => {
50+
breakerOpen = true;
3251
if (!warned) {
3352
warned = true;
34-
console.error("[auth/redis] connection error:", err.message);
53+
console.error(
54+
"[auth/redis] connection error:",
55+
err.message,
56+
"— falling back to in-memory (no further errors will be logged until reconnect)",
57+
);
58+
}
59+
});
60+
client.on("ready", () => {
61+
if (breakerOpen) {
62+
console.log("[auth/redis] reconnected");
3563
}
64+
breakerOpen = false;
65+
warned = false;
3666
});
3767
return client;
3868
}
@@ -50,29 +80,37 @@ export function createRedisSecondaryStorage(): SecondaryStorage | undefined {
5080
}
5181
return {
5282
async get(key) {
83+
if (breakerOpen) {
84+
return null;
85+
}
5386
try {
5487
return await redis.get(key);
55-
} catch (err) {
56-
console.error("[auth/redis] get failed:", (err as Error).message);
88+
} catch {
5789
return null;
5890
}
5991
},
6092
async set(key, value, ttl) {
93+
if (breakerOpen) {
94+
return;
95+
}
6196
try {
6297
if (ttl && ttl > 0) {
6398
await redis.set(key, value, "EX", ttl);
6499
} else {
65100
await redis.set(key, value);
66101
}
67-
} catch (err) {
68-
console.error("[auth/redis] set failed:", (err as Error).message);
102+
} catch {
103+
// Swallow — adapter is best-effort.
69104
}
70105
},
71106
async delete(key) {
107+
if (breakerOpen) {
108+
return;
109+
}
72110
try {
73111
await redis.del(key);
74-
} catch (err) {
75-
console.error("[auth/redis] delete failed:", (err as Error).message);
112+
} catch {
113+
// Swallow.
76114
}
77115
},
78116
};

0 commit comments

Comments
 (0)