diff --git a/packages/auth/src/auth-redis-storage.test.ts b/packages/auth/src/auth-redis-storage.test.ts new file mode 100644 index 000000000..2c17ac95f --- /dev/null +++ b/packages/auth/src/auth-redis-storage.test.ts @@ -0,0 +1,24 @@ +import { afterAll, describe, expect, it } from "bun:test"; + +process.env.REDIS_URL = "redis://test-host:6379"; + +const { getRedisCache, resetAuthCacheFailFast } = await import( + "@databuddy/redis" +); +const { createAuthSecondaryStorage } = await import("./auth-redis-storage"); + +describe("createAuthSecondaryStorage", () => { + afterAll(() => { + resetAuthCacheFailFast(); + getRedisCache().disconnect(); + }); + + it("fails fast on later session reads after Redis fails", async () => { + const storage = createAuthSecondaryStorage(); + await expect(storage.get("session-key")).rejects.toThrow(); + + const startedAt = performance.now(); + await expect(storage.get("session-key")).rejects.toThrow("failing fast"); + expect(performance.now() - startedAt).toBeLessThan(100); + }); +}); diff --git a/packages/auth/src/auth-redis-storage.ts b/packages/auth/src/auth-redis-storage.ts new file mode 100644 index 000000000..011b327b2 --- /dev/null +++ b/packages/auth/src/auth-redis-storage.ts @@ -0,0 +1,20 @@ +import { redisStorage } from "@better-auth/redis-storage"; +import { getRedisCache, runAuthCacheCommand } from "@databuddy/redis"; + +export function createAuthSecondaryStorage() { + const storage = redisStorage({ + client: getRedisCache(), + keyPrefix: "ba:", + }); + + return { + get: (key: string) => runAuthCacheCommand(() => storage.get(key)), + getAndDelete: (key: string) => + runAuthCacheCommand(() => storage.getAndDelete(key)), + set: (key: string, value: string, ttl?: number) => + runAuthCacheCommand(() => storage.set(key, value, ttl)), + delete: (key: string) => runAuthCacheCommand(() => storage.delete(key)), + listKeys: () => runAuthCacheCommand(() => storage.listKeys()), + clear: () => runAuthCacheCommand(() => storage.clear()), + }; +} diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index aab4f47f9..708692c90 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { redisStorage } from "@better-auth/redis-storage"; import { sso } from "@better-auth/sso"; import { getCurrentAdapter, @@ -54,8 +53,9 @@ import { } from "better-auth/plugins"; import { log } from "evlog"; import { Resend } from "resend"; -import { ac, admin, member, owner, viewer } from "./permissions"; import { getAuthAuditContext } from "./audit-context"; +import { createAuthSecondaryStorage } from "./auth-redis-storage"; +import { ac, admin, member, owner, viewer } from "./permissions"; function generateOrgSlug(name: string): string { const base = name @@ -387,10 +387,7 @@ export const auth = betterAuth({ schema, transaction: true, }), - secondaryStorage: redisStorage({ - client: getRedisCache(), - keyPrefix: "ba:", - }), + secondaryStorage: createAuthSecondaryStorage(), session: { storeSessionInDatabase: true, cookieCache: { diff --git a/packages/redis/000-redis.test.ts b/packages/redis/000-redis.test.ts index 85b24bd6d..6855f3029 100644 --- a/packages/redis/000-redis.test.ts +++ b/packages/redis/000-redis.test.ts @@ -6,9 +6,13 @@ import { process.env.REDIS_URL = "redis://test-host:6379"; -const { getRedisCache, runLinkCacheCommand, runRateLimitCommand, shutdownRedis } = await import( - "./redis" -); +const { + getRedisCache, + runAuthCacheCommand, + runLinkCacheCommand, + runRateLimitCommand, + shutdownRedis, +} = await import("./redis"); describe("redis", () => { describe("latency-sensitive rate limit options", () => { @@ -101,4 +105,41 @@ describe("redis", () => { expect(linkCacheError.message).not.toContain("failing fast"); }); }); + + describe("auth cache fail-fast", () => { + afterAll(async () => { + await shutdownRedis(); + }); + + it("rejects immediately after a recent failure without running the operation", async () => { + await expect( + runAuthCacheCommand(async () => { + throw new Error("redis down"); + }) + ).rejects.toThrow("redis down"); + + const operation = mock(async () => "value"); + const startedAt = performance.now(); + await expect(runAuthCacheCommand(operation)).rejects.toThrow( + "failing fast" + ); + expect(performance.now() - startedAt).toBeLessThan(100); + expect(operation).not.toHaveBeenCalled(); + }); + + it("tracks its window independently of the link cache", async () => { + await shutdownRedis(); + await expect( + runAuthCacheCommand(async () => { + throw new Error("redis down"); + }) + ).rejects.toThrow("redis down"); + + const linkCacheError = await runLinkCacheCommand( + async () => "value" + ).catch((caught: Error) => caught); + expect(linkCacheError).toBeInstanceOf(Error); + expect(linkCacheError.message).not.toContain("failing fast"); + }); + }); }); diff --git a/packages/redis/redis.ts b/packages/redis/redis.ts index dc7373b26..20d52d918 100644 --- a/packages/redis/redis.ts +++ b/packages/redis/redis.ts @@ -18,9 +18,11 @@ export const LINK_CACHE_OPERATION_DEADLINE_MS = 1500; const REDIS_FAIL_FAST_WINDOW_MS = 5000; const RATE_LIMIT_CONNECT_DEADLINE_MS = 1250; export const RATE_LIMIT_OPERATION_DEADLINE_MS = 1500; +export const AUTH_CACHE_OPERATION_DEADLINE_MS = 1500; let linkCacheFailFastUntil = 0; let rateLimitFailFastUntil = 0; +let authCacheFailFastUntil = 0; export function resetLinkCacheFailFast(): void { linkCacheFailFastUntil = 0; @@ -30,6 +32,10 @@ export function resetRateLimitFailFast(): void { rateLimitFailFastUntil = 0; } +export function resetAuthCacheFailFast(): void { + authCacheFailFastUntil = 0; +} + function withDeadline( operation: Promise, timeoutMs: number, @@ -156,6 +162,12 @@ export function runRateLimitCommand( return runRateLimitRedisCommand(operation); } +export function runAuthCacheCommand( + operation: (redis: Redis) => Promise +): Promise { + return runAuthCacheRedisCommand(operation); +} + let _linkCacheTimingFn: ((durationMs: number) => void) | null = null; export function setLinkCacheTimingFn( @@ -227,9 +239,31 @@ async function runRateLimitRedisCommand( } } +async function runAuthCacheRedisCommand( + operation: (redis: Redis) => Promise +): Promise { + if (Date.now() < authCacheFailFastUntil) { + throw new Error("Auth cache is failing fast after a recent Redis failure"); + } + + try { + const result = await withDeadline( + Promise.resolve(operation(getRedisCache())), + AUTH_CACHE_OPERATION_DEADLINE_MS, + `Auth cache operation exceeded ${AUTH_CACHE_OPERATION_DEADLINE_MS}ms` + ); + authCacheFailFastUntil = 0; + return result; + } catch (error) { + authCacheFailFastUntil = Date.now() + REDIS_FAIL_FAST_WINDOW_MS; + throw error; + } +} + export async function shutdownRedis() { resetLinkCacheFailFast(); resetRateLimitFailFast(); + resetAuthCacheFailFast(); const linkCacheInstance = linkCacheRedisInstance; linkCacheRedisInstance = null; linkCacheConnectPromise = null;