Skip to content
Open
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
43 changes: 38 additions & 5 deletions src/lib/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,22 @@ if (redisTlsServerName && parsedRedisUrl?.protocol !== "rediss:") {
throw new Error("REDIS_TLS_SERVER_NAME requires REDIS_URL to use rediss://");
}

// Modest backoff to smooth over first-hit cold connections
const reconnectStrategy = (retries: number) =>
Math.min(500 + retries * 100, 2000);
// Upper bound on connecting and on any single command, so an unreachable Redis
// surfaces as an error instead of blocking the caller (e.g. OAuth token exchange).
const CONNECT_TIMEOUT_MS = 5000;
const COMMAND_TIMEOUT_MS = 5000;

// Modest backoff to smooth over first-hit cold connections, but give up after a
// bounded number of attempts rather than retrying forever while Redis is down.
const MAX_RECONNECT_ATTEMPTS = 10;
const reconnectStrategy = (retries: number) => {
if (retries > MAX_RECONNECT_ATTEMPTS) {
return new Error(
`Redis unavailable after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts`,
);
}
return Math.min(500 + retries * 100, 2000);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reconnect limit off by one

Low Severity

reconnectStrategy stops only when retries > MAX_RECONNECT_ATTEMPTS (10), so node-redis can still schedule backoff for retries 0 through 10 before returning an error. That is one more delayed reconnect cycle than MAX_RECONNECT_ATTEMPTS and the error text imply.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3397557. Configure here.


// Connect on first use
let isConnected = false;
Expand All @@ -24,9 +37,11 @@ const client = createClient({
host: parsedRedisUrl!.hostname,
tls: true,
servername: redisTlsServerName,
connectTimeout: CONNECT_TIMEOUT_MS,
reconnectStrategy,
}
: {
connectTimeout: CONNECT_TIMEOUT_MS,
reconnectStrategy,
},
});
Expand Down Expand Up @@ -179,14 +194,32 @@ function isTransientSocketError(error: unknown): boolean {
);
}

async function withTimeout<T>(operation: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(`Redis command timed out after ${COMMAND_TIMEOUT_MS}ms`),
),
COMMAND_TIMEOUT_MS,
);
});
try {
return await Promise.race([operation(), timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Timeout leaves unhandled rejection

Medium Severity

The withTimeout function uses Promise.race but doesn't manage the underlying Redis operation when the timeout wins. This can lead to unhandled promise rejections and unintended Redis mutations if the original operation completes later.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3397557. Configure here.


async function withReconnect<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
return await withTimeout(operation);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Connect await exceeds command timeout

High Severity

The new withTimeout wrapper only covers the Redis command inside withReconnect, while every exported helper still awaits ensureConnected() first. On a cold instance with Redis down, that connect/reconnect loop can run many connectTimeout cycles plus backoff before reconnectStrategy gives up, so OAuth paths like /token can keep blocking far beyond the 5s command ceiling this PR adds.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3397557. Configure here.

} catch (err) {
if (isTransientSocketError(err)) {
isConnected = false;
await ensureConnected();
return await operation();
return await withTimeout(operation);
}
throw err;
}
Expand Down
Loading