fix(redis): apply TLS SNI override to pub/sub clients (#4638)

* fix(redis): apply TLS SNI override to pub/sub clients too

Pub/sub clients in lib/events/pubsub.ts build their own ioredis instances
directly via new Redis(redisUrl, ...) because pub/sub needs dedicated
connections (can't multiplex on the shared client from getRedisClient).
That path skipped the resolveTlsOptions helper added for trigger.dev's
PrivateLink VPCE IP, so every pub/sub channel hit
'Hostname/IP does not match certificate's altnames' on connect.

Export the helper as resolveRedisTlsOptions and use it from pubsub.ts.

* refactor(redis): share connection defaults via one helper

Extract keepAlive/connectTimeout/enableOfflineQueue + TLS SNI into a
single getRedisConnectionDefaults helper. Main client and pub/sub
clients both spread it; caller-specific retry/timeout policy stays
per-caller (pub/sub still needs maxRetriesPerRequest: null and a
different retry strategy for SUBSCRIBE).

* fix(pubsub): surface TLS config errors instead of silently degrading

resolveRedisTlsOptions (via getRedisConnectionDefaults) throws if
REDIS_TLS_SERVERNAME is missing for an IP-based rediss:// URL. Calling
it inside the constructor let createPubSubChannel's try/catch swallow
the error and fall back to in-process EventEmitter — silent
cross-replica pub/sub breakage in prod. Resolve defaults before the
try so config errors propagate; only catch genuine runtime construction
failures.
This commit is contained in:
Theodore Li
2026-05-17 01:51:17 -04:00
committed by GitHub
parent bd9e692b91
commit 4945d55671
2 changed files with 36 additions and 20 deletions
+21 -7
View File
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { randomFloat } from '@sim/utils/random'
import Redis from 'ioredis'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
const logger = createLogger('Redis')
@@ -16,7 +16,7 @@ const redisUrl = env.REDIS_URL
*
* For DNS hosts: no override needed, default verification works.
*/
function resolveTlsOptions(url: string | undefined): { servername: string } | undefined {
function resolveRedisTlsOptions(url: string | undefined): { servername: string } | undefined {
if (!url) return undefined
let parsed: URL
try {
@@ -37,6 +37,23 @@ function resolveTlsOptions(url: string | undefined): { servername: string } | un
return { servername: env.REDIS_TLS_SERVERNAME }
}
/**
* Shared connection defaults — keepAlive, connectTimeout, enableOfflineQueue,
* and TLS SNI when REDIS_URL targets an IP. Every Redis client we open should
* spread this; callers add their own retry / timeout policy on top.
*/
export function getRedisConnectionDefaults(
url: string | undefined
): Pick<RedisOptions, 'keepAlive' | 'connectTimeout' | 'enableOfflineQueue' | 'tls'> {
const tls = resolveRedisTlsOptions(url)
return {
keepAlive: 1000,
connectTimeout: 10000,
enableOfflineQueue: true,
...(tls ? { tls } : {}),
}
}
let globalRedisClient: Redis | null = null
let pingFailures = 0
let pingInterval: NodeJS.Timeout | null = null
@@ -117,18 +134,15 @@ export function getRedisClient(): Redis | null {
if (globalRedisClient) return globalRedisClient
// Outside the try/catch so config errors aren't silently swallowed.
const tls = resolveTlsOptions(redisUrl)
const defaults = getRedisConnectionDefaults(redisUrl)
try {
logger.info('Initializing Redis client')
globalRedisClient = new Redis(redisUrl, {
keepAlive: 1000,
connectTimeout: 10000,
...defaults,
commandTimeout: 5000,
maxRetriesPerRequest: 5,
enableOfflineQueue: true,
...(tls ? { tls } : {}),
retryStrategy: (times) => {
if (times > 10) {
+15 -13
View File
@@ -9,6 +9,7 @@ import { EventEmitter } from 'events'
import { createLogger } from '@sim/logger'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
import { getRedisConnectionDefaults } from '@/lib/core/config/redis'
const logger = createLogger('PubSub')
@@ -31,13 +32,12 @@ class RedisPubSubChannel<T> implements PubSubChannel<T> {
constructor(
redisUrl: string,
connectionDefaults: ReturnType<typeof getRedisConnectionDefaults>,
private config: PubSubChannelConfig
) {
const commonOpts = {
keepAlive: 1000,
connectTimeout: 10000,
...connectionDefaults,
maxRetriesPerRequest: null,
enableOfflineQueue: true,
retryStrategy: (times: number) => {
if (times > 10) return 30000
return Math.min(times * 500, 5000)
@@ -139,16 +139,18 @@ class LocalPubSubChannel<T> implements PubSubChannel<T> {
export function createPubSubChannel<T>(config: PubSubChannelConfig): PubSubChannel<T> {
const redisUrl = env.REDIS_URL
if (!redisUrl) return new LocalPubSubChannel<T>(config)
if (redisUrl) {
try {
logger.info(`${config.label}: Using Redis`)
return new RedisPubSubChannel<T>(redisUrl, config)
} catch (err) {
logger.error(`Failed to create Redis ${config.label}, falling back to local:`, err)
return new LocalPubSubChannel<T>(config)
}
// Resolve config-derived defaults outside the try so a missing
// REDIS_TLS_SERVERNAME (config error) surfaces instead of silently degrading
// to the in-process EventEmitter — that would break cross-replica pub/sub.
const connectionDefaults = getRedisConnectionDefaults(redisUrl)
try {
logger.info(`${config.label}: Using Redis`)
return new RedisPubSubChannel<T>(redisUrl, connectionDefaults, config)
} catch (err) {
logger.error(`Failed to create Redis ${config.label}, falling back to local:`, err)
return new LocalPubSubChannel<T>(config)
}
return new LocalPubSubChannel<T>(config)
}