Skip to content
31 changes: 25 additions & 6 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
reconnectPlan,
} from './bridge_helpers.js';

// Parse CLI args
Expand Down Expand Up @@ -378,6 +379,9 @@ function rememberSentId(id) {

let sock = null;
let connectionState = 'disconnected';
let reconnectAttempts = 0;
let handshakeFailures = 0;
const RECONNECT_GIVEUP_AFTER = 10; // after this many straight failures, back off hard

function emitPairEvent(event) {
if (!PAIR_JSON) return;
Expand Down Expand Up @@ -433,19 +437,34 @@ async function startSocket() {
}
process.exit(1);
} else {
// 515 = restart requested (common after pairing). Always reconnect.
// 515 = restart requested (common after pairing) - legit and fast.
// Everything else uses capped exponential backoff + jitter so a persistent
// failure (e.g. 405 from a rejected handshake) does not hammer
// WhatsApp's servers and risk the account being flagged as abusive.
emitPairEvent({ event: 'disconnected', reason });
if (!PAIR_JSON) {
if (reason === 515) {
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
const plan = reconnectPlan({ reason, reconnectAttempts, handshakeFailures });
reconnectAttempts = plan.reconnectAttempts;
handshakeFailures = plan.handshakeFailures;
const { delay } = plan;
if (reason === 515) {
if (!PAIR_JSON) console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
Comment thread
exiao marked this conversation as resolved.
} else {
if (handshakeFailures > RECONNECT_GIVEUP_AFTER) {
// Persistent failure: after 10 straight attempts, stop trying for
// a full 12h. Anything wrong at this point (stale client, banned
// handshake) won't fix itself in minutes, and continuing to retry
// only risks the account. Any successful connect resets the count.
if (!PAIR_JSON) console.log(`⛔ ${reconnectAttempts} reconnects failed (reason: ${reason}). Backing off for 12h.`);
} else {
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
if (!PAIR_JSON) console.log(`⚠️ Connection closed (reason: ${reason}). Reconnect attempt ${reconnectAttempts} in ${Math.round(delay / 1000)}s...`);
}
}
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
setTimeout(startSocket, delay);
}
} else if (connection === 'open') {
connectionState = 'connected';
reconnectAttempts = 0;
handshakeFailures = 0;
const connectedUser = sock?.user
? {
id: sock.user.id || null,
Expand Down
63 changes: 63 additions & 0 deletions scripts/whatsapp-bridge/bridge.native.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,75 @@ import {
buildTextSendPayload,
createBoundedMessageStore,
appendMediaFailureNote,
reconnectPlan,
extractBridgeEvent,
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
} from './bridge_helpers.js';

// -- reconnect policy ----------------------------------------------------
{
const restarted = reconnectPlan({ reason: 515, reconnectAttempts: 10 });
assert.deepEqual(restarted, { delay: 1000, reconnectAttempts: 0, handshakeFailures: 0 },
'WhatsApp-requested restarts clear prior failure state');
console.log(' ✓ 515 reconnect clears the consecutive-failure counter');
}

{
const firstRetry = reconnectPlan({ reason: 405, reconnectAttempts: 0, random: () => 0 });
assert.equal(firstRetry.reconnectAttempts, 1);
assert.equal(firstRetry.handshakeFailures, 1);
assert.equal(firstRetry.delay, 3000,
'the first ordinary reconnect stays seconds-scale');
console.log(' ✓ first reconnect is seconds-scale instead of minutes');
}

{
const cappedRetry = reconnectPlan({
reason: 405,
reconnectAttempts: 9,
handshakeFailures: 9,
random: () => 0.5,
});
assert.equal(cappedRetry.reconnectAttempts, 10);
assert.equal(cappedRetry.handshakeFailures, 10);
assert.equal(cappedRetry.delay, 151500,
'repeated failures use capped exponential jitter');
console.log(' ✓ repeated reconnects use capped exponential jitter');
}

{
const giveUp = reconnectPlan({ reason: 405, reconnectAttempts: 10, handshakeFailures: 10 });
assert.deepEqual(giveUp, {
delay: 12 * 60 * 60 * 1000,
reconnectAttempts: 11,
handshakeFailures: 11,
},
'the eleventh consecutive failure enters the 12-hour backoff');
console.log(' ✓ persistent failures enter the 12-hour backoff');
}

{
const transientRetry = reconnectPlan({ reason: 500, reconnectAttempts: 10, random: () => 0.5 });
assert.deepEqual(transientRetry, { delay: 151500, reconnectAttempts: 11, handshakeFailures: 0 },
'transient failures continue capped reconnects instead of entering the 12-hour backoff');
console.log(' ✓ transient failures do not enter the 12-hour backoff');
}

{
const firstHandshakeRejection = reconnectPlan({
reason: 405,
reconnectAttempts: 10,
handshakeFailures: 0,
random: () => 0,
});
assert.equal(firstHandshakeRejection.delay, 3000,
'a first 405 after transient failures stays on the normal retry schedule');
assert.equal(firstHandshakeRejection.handshakeFailures, 1);
console.log(' ✓ a first 405 does not inherit transient failure history');
}

// -- quoted outbound text -------------------------------------------------
{
const store = createBoundedMessageStore(2);
Expand Down
36 changes: 36 additions & 0 deletions scripts/whatsapp-bridge/bridge_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,42 @@ export const MIME_MAP = {
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};

const RECONNECT_BASE_MS = 3 * 1000;
const RECONNECT_MAX_MS = 5 * 60 * 1000;
const RECONNECT_GIVEUP_AFTER = 10;
const RECONNECT_LONG_MS = 12 * 60 * 60 * 1000;

/**
* Return the next reconnect delay and consecutive-failure count without
* importing the live bridge (which creates a socket and HTTP server).
*/
export function reconnectPlan({
reason,
reconnectAttempts,
handshakeFailures = 0,
random = Math.random,
}) {
if (reason === 515) {
return { delay: 1000, reconnectAttempts: 0, handshakeFailures: 0 };
}

const nextAttempts = reconnectAttempts + 1;
const nextHandshakeFailures = reason === 405 ? handshakeFailures + 1 : 0;
if (nextHandshakeFailures > RECONNECT_GIVEUP_AFTER) {
return {
delay: RECONNECT_LONG_MS,
reconnectAttempts: nextAttempts,
handshakeFailures: nextHandshakeFailures,
};
}

const exponent = Math.min(nextAttempts - 1, 10);
const backoff = Math.min(RECONNECT_BASE_MS * (2 ** exponent), RECONNECT_MAX_MS);
const delay = RECONNECT_BASE_MS
+ Math.floor(random() * (backoff - RECONNECT_BASE_MS + 1));
return { delay, reconnectAttempts: nextAttempts, handshakeFailures: nextHandshakeFailures };
}

export function normalizeWhatsAppId(value) {
if (!value) return '';
return String(value).replace(':', '@');
Expand Down
Loading