Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/auth/src/auth-redis-storage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions packages/auth/src/auth-redis-storage.ts
Original file line number Diff line number Diff line change
@@ -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()),
};
}
9 changes: 3 additions & 6 deletions packages/auth/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { redisStorage } from "@better-auth/redis-storage";
import { sso } from "@better-auth/sso";
import {
getCurrentAdapter,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -387,10 +387,7 @@ export const auth = betterAuth({
schema,
transaction: true,
}),
secondaryStorage: redisStorage({
client: getRedisCache(),
keyPrefix: "ba:",
}),
secondaryStorage: createAuthSecondaryStorage(),
session: {
storeSessionInDatabase: true,
cookieCache: {
Expand Down
47 changes: 44 additions & 3 deletions packages/redis/000-redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
});
34 changes: 34 additions & 0 deletions packages/redis/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +32,10 @@ export function resetRateLimitFailFast(): void {
rateLimitFailFastUntil = 0;
}

export function resetAuthCacheFailFast(): void {
authCacheFailFastUntil = 0;
}

function withDeadline<T>(
operation: Promise<T>,
timeoutMs: number,
Expand Down Expand Up @@ -156,6 +162,12 @@ export function runRateLimitCommand<T>(
return runRateLimitRedisCommand(operation);
}

export function runAuthCacheCommand<T>(
operation: (redis: Redis) => Promise<T>
): Promise<T> {
return runAuthCacheRedisCommand(operation);
}

let _linkCacheTimingFn: ((durationMs: number) => void) | null = null;

export function setLinkCacheTimingFn(
Expand Down Expand Up @@ -227,9 +239,31 @@ async function runRateLimitRedisCommand<T>(
}
}

async function runAuthCacheRedisCommand<T>(
operation: (redis: Redis) => Promise<T>
): Promise<T> {
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent success clears failure

If two auth-cache commands overlap and the newer command fails before the older one succeeds, the unconditional reset on success erases the newer five-second failure window. Subsequent session reads then attempt Redis and can incur the full 1.5-second deadline instead of failing fast.

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;
Expand Down