Skip to content

Commit c2cea38

Browse files
committed
Make Redis connection resilient and add lifecycle logging
A transient Redis outage made ioredis throw MaxRetriesPerRequestError (default 20 retries) and crash the process, restart-looping the container. Provide a bull createClient factory with maxRetriesPerRequest null and a capped reconnect backoff so commands wait for reconnection instead of failing, and log each client's connection lifecycle (target, connect, ready, error, reconnect) for visibility in the docker network. Add process-level uncaughtException/unhandledRejection guards as a safety net, and declare ioredis as a direct dependency.
1 parent 107e6c5 commit c2cea38

3 files changed

Lines changed: 86 additions & 5 deletions

File tree

index.js

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require( 'dotenv' ).config();
22

33
const Arena = require( 'bull-arena' );
4+
const Redis = require( 'ioredis' );
45
const express = require( 'express' );
56
const basicAuth = require( 'express-basic-auth' );
67

@@ -9,6 +10,80 @@ const router = express.Router();
910

1011
const DEFAULT_LISTEN_PORT = 4000;
1112

13+
// A transient Redis outage (Heroku failover, network blip) makes ioredis/bull
14+
// emit errors that, left unhandled, crash the process and put the container in
15+
// a restart loop. This is a read-only monitoring UI, so log and keep running —
16+
// ioredis will reconnect on its own (see retryStrategy below).
17+
process.on( 'uncaughtException', ( error ) => {
18+
console.error( 'Uncaught exception (continuing):', error );
19+
} );
20+
process.on( 'unhandledRejection', ( error ) => {
21+
console.error( 'Unhandled rejection (continuing):', error );
22+
} );
23+
24+
// Build ioredis connection options from a redis:// URL, adding resilience
25+
// settings. We parse the URL rather than passing the string through so we can
26+
// inject these options; the derived fields match what ioredis would parse from
27+
// the same URL, so AUTH/TLS behavior is unchanged.
28+
const buildRedisOptions = function buildRedisOptions ( redisUrl ) {
29+
const parsed = new URL( redisUrl );
30+
31+
const options = {
32+
host: parsed.hostname,
33+
port: Number( parsed.port ) || 6379,
34+
// Keep retrying commands across reconnects instead of throwing
35+
// MaxRetriesPerRequestError after ioredis' default of 20 retries.
36+
maxRetriesPerRequest: null,
37+
// Reconnect with a capped backoff after the connection drops.
38+
retryStrategy: ( times ) => Math.min( times * 200, 5000 ),
39+
};
40+
41+
if ( parsed.username ) {
42+
options.username = parsed.username;
43+
}
44+
45+
if ( parsed.password ) {
46+
options.password = parsed.password;
47+
}
48+
49+
if ( parsed.protocol === 'rediss:' ) {
50+
options.tls = {};
51+
}
52+
53+
return options;
54+
};
55+
56+
// Returns a bull `createClient(type)` factory so we own the ioredis clients and
57+
// can log their connection lifecycle. Bull creates a few client types per queue
58+
// ('client', 'subscriber', and 'bclient' for blocking ops); we label logs by
59+
// type so it's clear which connection is doing what.
60+
const createRedisClient = function createRedisClient ( redisUrl ) {
61+
const baseOptions = buildRedisOptions( redisUrl );
62+
63+
console.log( `[redis] target ${ baseOptions.host }:${ baseOptions.port }${ baseOptions.tls ? ' (tls)' : '' }` );
64+
65+
return ( type ) => {
66+
const options = Object.assign( {}, baseOptions );
67+
68+
// bull requires these for its blocking and subscriber clients.
69+
if ( type === 'bclient' || type === 'subscriber' ) {
70+
options.enableReadyCheck = false;
71+
options.maxRetriesPerRequest = null;
72+
}
73+
74+
const client = new Redis( options );
75+
76+
client.on( 'connect', () => console.log( `[redis:${ type }] connecting` ) );
77+
client.on( 'ready', () => console.log( `[redis:${ type }] ready` ) );
78+
client.on( 'error', ( error ) => console.error( `[redis:${ type }] error: ${ error.message }` ) );
79+
client.on( 'close', () => console.warn( `[redis:${ type }] connection closed` ) );
80+
client.on( 'reconnecting', ( delay ) => console.warn( `[redis:${ type }] reconnecting in ${ delay }ms` ) );
81+
client.on( 'end', () => console.warn( `[redis:${ type }] connection ended; no further reconnects` ) );
82+
83+
return client;
84+
};
85+
};
86+
1287
if ( !process.env.QUEUES ) {
1388
throw new Error( 'Unable to load queues' );
1489
}
@@ -34,9 +109,12 @@ const getUnauthorizedResponse = function getUnauthorizedResponse ( request ) {
34109
return 'No credentials provided';
35110
};
36111

37-
if ( process.env.REDIS_URL ) {
38-
for ( const queue of queues ) {
39-
queue.url = process.env.REDIS_URL;
112+
for ( const queue of queues ) {
113+
const redisUrl = process.env.REDIS_URL || queue.url;
114+
115+
if ( redisUrl ) {
116+
queue.createClient = createRedisClient( redisUrl );
117+
delete queue.url;
40118
}
41119
}
42120

package-lock.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"bull-arena": "^2.6.4",
1616
"dotenv": "^16.0.1",
1717
"express": "^4.17.1",
18-
"express-basic-auth": "^1.2.0"
18+
"express-basic-auth": "^1.2.0",
19+
"ioredis": "^4.28.5"
1920
}
2021
}

0 commit comments

Comments
 (0)