From f71bdbc5d200853edd29d245d0057837508e45df Mon Sep 17 00:00:00 2001 From: Gustavo Gonzaga Date: Mon, 21 Sep 2026 12:19:10 -0300 Subject: [PATCH 1/2] fix: reconnect on 408 with bounded backoff instead of destroying the session Baileys reports 408 (DisconnectReason.connectionLost / timedOut) when the 30s keep-alive detects a socket that stopped answering. That is a transient network condition, not a real logout. 408 was added to codesToNotReconnect in #2501 to avoid reconnect loops on transient drops. The side effect is that a timed-out keep-alive now takes the same path as a real logout: 'logout.instance' fires, cleaningUp() runs, and the persisted session rows and instance directory are deleted. The credentials are destroyed and the only way back is a fresh QR scan. Observed in production: an instance closed with 428, reconnected on its own and was back to open seven seconds later; twenty minutes later a 408 close on the same instance went straight to LOGOUT and wiped its credentials, although the account itself was still perfectly valid. This keeps the intent of #2501 (bound the reconnect loop) without paying for it with destroyed credentials: - 408 leaves codesToNotReconnect; terminal codes are now loggedOut, forbidden, 402 and 406, where the credentials really are invalid - transient closes retry with an exponential backoff ladder (3s, 6s, 12s, 30s, 60s, 120s, 300s) capped at 20 attempts, which is what actually bounds the loop rather than treating one status code as fatal - when the ladder is exhausted the instance is left closed without emitting 'logout.instance', so the session survives and /instance/connect is enough - the attempt counter resets on a healthy open and on any externally triggered connect, but not on the ladder's own retries, so the ladder can exhaust Co-Authored-By: Claude Opus 5 --- .../whatsapp/whatsapp.baileys.service.ts | 76 ++++++++++++++++--- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..538840c5ad 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -279,6 +279,18 @@ export class BaileysStartupService extends ChannelStartupService { // After WhatsApp emits 515 it usually closes with `loggedOut`; that close is *not* a real logout // and we should reconnect. We treat any close arriving within this grace window as 515-driven. private static readonly STREAM_515_RECONNECT_GRACE_MS = 30_000; + + // Exponential backoff for transient disconnects (e.g. 408 connectionLost/timedOut). + // The last value repeats once the ladder is exhausted. + private static readonly RECONNECT_BACKOFF_MS = [3_000, 6_000, 12_000, 30_000, 60_000, 120_000, 300_000]; + + private static readonly RECONNECT_MAX_ATTEMPTS = 20; + + private _reconnectAttempts = 0; + + // True only while a backoff-scheduled reconnect is being dispatched, so that connectToWhatsapp can + // tell an automatic retry from an external connect request (/instance/connect, boot, watchdog). + private _reconnectScheduled = false; // The numeric WhatsApp stream-error code that triggers the grace-period reconnect above. private static readonly STREAM_ERROR_CODE_RECONNECT = '515'; @@ -500,9 +512,15 @@ export class BaileysStartupService extends ChannelStartupService { } const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; - // 408 = request timeout — added per #2501 to avoid reconnect loops on - // transient network drops where the server returned a 408 in the close. - const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406, 408]; + // Terminal codes: the credentials themselves are no longer valid, so re-pairing (QR) is + // genuinely required and dropping the session is the correct outcome. + // + // 408 used to be listed here (per #2501, to avoid reconnect loops on transient drops), but in + // Baileys 408 is connectionLost/timedOut — i.e. how the keep-alive reports a socket that stopped + // answering. Treating it as terminal made every transient drop emit 'logout.instance', which + // wipes the persisted session and forces a QR re-scan. It is now retried with the exponential + // backoff below; the loop #2501 worried about is bounded by RECONNECT_MAX_ATTEMPTS instead. + const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; // FIX: Do not reconnect if it's the initial connection (waiting for QR code) // This prevents infinite loop that blocks QR code generation @@ -517,24 +535,38 @@ export class BaileysStartupService extends ChannelStartupService { // a follow-up loggedOut is the expected restart signal — not an actual // logout — so reconnect anyway. const recentStream515 = Date.now() - this._lastStream515At < BaileysStartupService.STREAM_515_RECONNECT_GRACE_MS; - const shouldReconnect = - !codesToNotReconnect.includes(statusCode) || (statusCode === DisconnectReason.loggedOut && recentStream515); + const isTerminal = + codesToNotReconnect.includes(statusCode) && !(statusCode === DisconnectReason.loggedOut && recentStream515); + const attemptsExhausted = this._reconnectAttempts >= BaileysStartupService.RECONNECT_MAX_ATTEMPTS; + const shouldReconnect = !isTerminal && !attemptsExhausted; this.logger.info({ message: 'Connection closed, evaluating reconnection', statusCode, shouldReconnect, + isTerminal, + reconnectAttempts: this._reconnectAttempts, instanceName: this.instance.name, }); if (shouldReconnect) { - // Add 3 second delay before reconnection to prevent rapid reconnection loops - this.logger.info('Reconnecting in 3 seconds...'); + const ladder = BaileysStartupService.RECONNECT_BACKOFF_MS; + const delay = ladder[Math.min(this._reconnectAttempts, ladder.length - 1)]; + this._reconnectAttempts += 1; + + this.logger.info( + `Reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempts}/${BaileysStartupService.RECONNECT_MAX_ATTEMPTS}, status code ${statusCode})`, + ); setTimeout(async () => { + this._reconnectScheduled = true; await this.connectToWhatsapp(this.phoneNumber); - }, 3000); + }, delay); } else { - this.logger.info(`Skipping reconnection for status code ${statusCode} (code is in codesToNotReconnect list)`); + this.logger.info( + isTerminal + ? `Skipping reconnection for status code ${statusCode} (code is in codesToNotReconnect list)` + : `Reconnect attempts exhausted for status code ${statusCode} after ${this._reconnectAttempts} attempts`, + ); this.sendDataWebhook(Events.STATUS_INSTANCE, { instance: this.instance.name, status: 'closed', @@ -561,9 +593,20 @@ export class BaileysStartupService extends ChannelStartupService { ); } - this.eventEmitter.emit('logout.instance', this.instance.name, 'inner'); + if (isTerminal) { + // 'logout.instance' runs cleaningUp(), which deletes the persisted credentials (session rows + // and instance dir). That is correct here: the credentials are no longer usable. + this.eventEmitter.emit('logout.instance', this.instance.name, 'inner'); + } else { + // Transient failure that outlived the backoff ladder. Leave the credentials untouched so the + // instance can be brought back with /instance/connect — no QR re-scan needed. + this.logger.warn( + `Instance "${this.instance.name}" left disconnected with session preserved; reconnect with /instance/connect`, + ); + } + this.client?.ws?.close(); - this.client.end(new Error('Close connection')); + this.client?.end(new Error('Close connection')); this.sendDataWebhook(Events.CONNECTION_UPDATE, { instance: this.instance.name, ...this.stateConnection }); } @@ -574,6 +617,9 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.warn('connectionUpdate: connection open but client.user is undefined, skipping'); return; } + + // Connection is healthy again — restart the backoff ladder for the next drop. + this._reconnectAttempts = 0; this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); try { const profilePic = await this.profilePicture(this.instance.wuid); @@ -838,6 +884,14 @@ export class BaileysStartupService extends ChannelStartupService { public async connectToWhatsapp(number?: string): Promise { try { + // An external connect request (/instance/connect, boot auto-connect, watchdog) restarts the + // backoff ladder; a retry dispatched by the ladder itself must not. + if (this._reconnectScheduled) { + this._reconnectScheduled = false; + } else { + this._reconnectAttempts = 0; + } + this.loadChatwoot(); this.loadSettings(); this.loadWebhook(); From 4fc76609a9bee1bba357691c1491118ff30cd8be Mon Sep 17 00:00:00 2001 From: Gustavo Gonzaga Date: Mon, 21 Sep 2026 13:43:32 -0300 Subject: [PATCH 2/2] fix: cancel a pending backoff retry when the instance is logged out The retry scheduled by the backoff ladder was never cancelled. If the instance was logged out or deleted while a retry was pending, the timer still fired and called connectToWhatsapp, rebuilding a socket for an instance the user had deliberately shut down, after its credentials were already removed. connectionUpdate already refuses to act when isDeleting or endSession is set; this applies the same rule at the moment the timer fires, which the ladder can delay by up to five minutes. - store the timeout handle and clear it in logoutInstance() - re-check isDeleting/endSession inside the timer callback before reconnecting - reset the ladder state on logout so a later reconnect starts clean Reported by Sourcery on #2732. Co-Authored-By: Claude Opus 5 --- .../whatsapp/whatsapp.baileys.service.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 538840c5ad..b44d30bb09 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -291,6 +291,9 @@ export class BaileysStartupService extends ChannelStartupService { // True only while a backoff-scheduled reconnect is being dispatched, so that connectToWhatsapp can // tell an automatic retry from an external connect request (/instance/connect, boot, watchdog). private _reconnectScheduled = false; + + // Handle of the pending backoff retry, so logout/deletion can cancel it. + private _reconnectTimer: NodeJS.Timeout | null = null; // The numeric WhatsApp stream-error code that triggers the grace-period reconnect above. private static readonly STREAM_ERROR_CODE_RECONNECT = '515'; @@ -307,6 +310,14 @@ export class BaileysStartupService extends ChannelStartupService { this.isDeleting = true; this.endSession = true; + // Drop any retry still waiting on the backoff ladder, so it neither fires nor holds a timer. + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + this._reconnectScheduled = false; + this._reconnectAttempts = 0; + this.messageProcessor.onDestroy(); if (this.client) { @@ -557,7 +568,17 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.info( `Reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempts}/${BaileysStartupService.RECONNECT_MAX_ATTEMPTS}, status code ${statusCode})`, ); - setTimeout(async () => { + this._reconnectTimer = setTimeout(async () => { + this._reconnectTimer = null; + + // The ladder can wait up to five minutes, and the instance may have been logged out or + // deleted in the meantime. Reconnecting here would rebuild a socket for an instance the + // user deliberately shut down, after its credentials were already removed. + if (this.isDeleting || this.endSession) { + this.logger.info('Reconnect cancelled: instance was logged out or deleted while waiting'); + return; + } + this._reconnectScheduled = true; await this.connectToWhatsapp(this.phoneNumber); }, delay);