2026-03-10 22:11:47 +01:00
|
|
|
import { createClient, type RedisClientType } from 'redis';
|
2026-06-07 18:43:05 +01:00
|
|
|
import { env } from '../../config/env';
|
2025-09-21 19:35:41 +01:00
|
|
|
|
2026-07-27 14:43:30 +01:00
|
|
|
/**
|
|
|
|
|
* General-purpose Redis client (rate limiting, cache, OAuth handoff).
|
|
|
|
|
*
|
|
|
|
|
* Configured for Redis Cloud maintenance / failover resilience:
|
|
|
|
|
* - automatic reconnect with exponential backoff + jitter
|
|
|
|
|
* - error listeners so socket drops never crash the process
|
|
|
|
|
* - fresh DNS on each reconnect (node-redis default)
|
|
|
|
|
*
|
|
|
|
|
* See https://redis.io/docs/latest/operate/rc/resilient-apps/
|
|
|
|
|
* and https://redis.io/docs/latest/develop/clients/nodejs/connect/
|
|
|
|
|
*/
|
2026-03-10 22:11:47 +01:00
|
|
|
const client: RedisClientType = createClient({
|
2026-07-27 14:43:30 +01:00
|
|
|
url: env.REDIS_URL,
|
|
|
|
|
socket: {
|
|
|
|
|
// Keep retrying through Redis Cloud maintenance windows; never return false.
|
|
|
|
|
reconnectStrategy(retries) {
|
|
|
|
|
const jitter = Math.floor(Math.random() * 100);
|
|
|
|
|
const delay = Math.min(2 ** retries * 50, 3000);
|
|
|
|
|
|
|
|
|
|
return delay + jitter;
|
|
|
|
|
},
|
|
|
|
|
keepAlive: 10_000,
|
|
|
|
|
connectTimeout: 10_000
|
|
|
|
|
}
|
2026-03-09 11:18:22 +01:00
|
|
|
});
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
let hasLoggedRedisUnavailable = false;
|
2026-07-27 14:43:30 +01:00
|
|
|
let isConnected = false;
|
2026-03-30 08:24:55 +01:00
|
|
|
|
|
|
|
|
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
|
|
|
|
|
if (hasLoggedRedisUnavailable) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hasLoggedRedisUnavailable = true;
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.warn(message, error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 14:43:30 +01:00
|
|
|
const TRANSIENT_REDIS_ERROR_CODES = new Set([
|
|
|
|
|
'ECONNABORTED',
|
|
|
|
|
'ECONNRESET',
|
|
|
|
|
'ECONNREFUSED',
|
|
|
|
|
'ETIMEDOUT',
|
|
|
|
|
'EPIPE',
|
|
|
|
|
'ENOTFOUND',
|
|
|
|
|
'EAI_AGAIN',
|
|
|
|
|
'EHOSTUNREACH',
|
|
|
|
|
'ENETUNREACH'
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const TRANSIENT_REDIS_MESSAGE_PATTERN =
|
|
|
|
|
/ECONNABORTED|ECONNRESET|ECONNREFUSED|ETIMEDOUT|Connection is closed|Socket closed unexpectedly|Connection timeout|client is closed/i;
|
|
|
|
|
|
|
|
|
|
const TRANSIENT_REDIS_LOG_INTERVAL_MS = 10_000;
|
|
|
|
|
let lastTransientRedisLogAt = 0;
|
|
|
|
|
|
|
|
|
|
export function logTransientRedisEvent(message: string, error?: unknown): void {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastTransientRedisLogAt < TRANSIENT_REDIS_LOG_INTERVAL_MS) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastTransientRedisLogAt = now;
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
console.warn(message, error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* True for the connection/socket errors Redis Cloud maintenance & failover
|
|
|
|
|
* throw (ioredis and node-redis alike). Handles `AggregateError` (node-redis
|
|
|
|
|
* surfaces multi-address connect failures this way, e.g. IPv6 + IPv4) by
|
|
|
|
|
* recursing into its sub-errors. Exported so the API's process-level
|
|
|
|
|
* `uncaughtException` guard can distinguish "Redis is briefly unreachable,
|
|
|
|
|
* the client will reconnect" from a genuinely fatal bug.
|
|
|
|
|
*/
|
|
|
|
|
export function isTransientRedisError(error: unknown): boolean {
|
|
|
|
|
if (!error || typeof error !== 'object') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const code = 'code' in error && typeof error.code === 'string' ? error.code : '';
|
|
|
|
|
if (TRANSIENT_REDIS_ERROR_CODES.has(code)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = 'name' in error && typeof error.name === 'string' ? error.name : '';
|
|
|
|
|
const message = 'message' in error && typeof error.message === 'string' ? error.message : '';
|
|
|
|
|
if (TRANSIENT_REDIS_MESSAGE_PATTERN.test(message) || TRANSIENT_REDIS_MESSAGE_PATTERN.test(name)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ('errors' in error && Array.isArray((error as AggregateError).errors)) {
|
|
|
|
|
return (error as AggregateError).errors.some((sub) => isTransientRedisError(sub));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
// Handle connection events
|
|
|
|
|
client.on('error', (err) => {
|
2026-07-27 14:43:30 +01:00
|
|
|
if (isTransientRedisError(err)) {
|
|
|
|
|
// Expected during Redis Cloud maintenance — client reconnects automatically.
|
|
|
|
|
logTransientRedisEvent('Redis socket error (reconnecting):', err);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
|
2026-03-09 11:18:22 +01:00
|
|
|
});
|
|
|
|
|
|
2026-07-27 14:43:30 +01:00
|
|
|
client.on('reconnecting', () => {
|
|
|
|
|
logTransientRedisEvent('Redis client reconnecting');
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
client.on('connect', () => {
|
|
|
|
|
console.log('Redis Client Connected');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-27 14:43:30 +01:00
|
|
|
client.on('ready', () => {
|
|
|
|
|
console.log('Redis Client Ready');
|
|
|
|
|
isConnected = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on('end', () => {
|
|
|
|
|
isConnected = false;
|
|
|
|
|
});
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
export async function connectRedis(): Promise<void> {
|
2026-03-11 12:57:44 +01:00
|
|
|
if (!env.REDIS_URL) {
|
2026-03-30 08:24:55 +01:00
|
|
|
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
|
2026-03-11 12:57:44 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-27 14:43:30 +01:00
|
|
|
if (isConnected || client.isOpen) return;
|
2026-03-11 12:57:44 +01:00
|
|
|
try {
|
2026-03-09 11:18:22 +01:00
|
|
|
await client.connect();
|
|
|
|
|
isConnected = true;
|
2026-03-11 12:57:44 +01:00
|
|
|
} catch (error) {
|
2026-03-30 08:24:55 +01:00
|
|
|
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
|
2026-03-11 12:57:44 +01:00
|
|
|
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
|
2026-03-09 11:18:22 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Export the client (connection will be established before server starts)
|
2026-03-10 22:11:47 +01:00
|
|
|
export const redis: RedisClientType = client;
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
// Export type for use in other files
|
2026-03-10 22:11:47 +01:00
|
|
|
export type RedisClient = RedisClientType;
|