fix(whatsapp): retry QR login 408 timeouts (#88183)

This commit is contained in:
Marcus Castro
2026-05-30 00:59:12 -03:00
committed by GitHub
parent 03415bb696
commit f613f32b22
4 changed files with 210 additions and 7 deletions

View File

@@ -1,7 +1,12 @@
import { EventEmitter } from "node:events";
import { DisconnectReason } from "baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js";
import {
closeWaSocket,
waitForWhatsAppLoginResult,
WhatsAppConnectionController,
} from "./connection-controller.js";
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
@@ -127,6 +132,120 @@ describe("WhatsAppConnectionController", () => {
expect(callOrder).toEqual(["create", "wait-for-connection"]);
});
it("restarts login once on status 408 and preserves replacement socket options", async () => {
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const waitForConnection = vi
.fn()
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
.mockResolvedValueOnce(undefined);
const onQr = vi.fn();
const onSocketReplaced = vi.fn();
const createSocket = vi.fn(
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
opts?.onQr?.("qr-after-timeout");
return replacementSock;
},
);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: true,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
socketTiming: {
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
keepAliveIntervalMs: 30_000,
},
onQr,
onSocketReplaced,
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: replacementSock,
});
expect(initialSock.end).toHaveBeenCalledOnce();
expect(createSocket).toHaveBeenCalledWith(false, true, {
authDir: "/tmp/wa-auth",
connectTimeoutMs: 10_000,
defaultQueryTimeoutMs: 20_000,
keepAliveIntervalMs: 30_000,
onQr,
});
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
});
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
const initialSock = createSocketWithTransportEmitter();
const afterTimeoutSock = createSocketWithTransportEmitter();
const afterPairingRestartSock = createSocketWithTransportEmitter();
const waitForConnection = vi
.fn()
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
.mockRejectedValueOnce({ output: { statusCode: 515 } })
.mockResolvedValueOnce(undefined);
const createSocket = vi
.fn()
.mockResolvedValueOnce(afterTimeoutSock)
.mockResolvedValueOnce(afterPairingRestartSock);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toEqual({
outcome: "connected",
restarted: true,
sock: afterPairingRestartSock,
});
expect(createSocket).toHaveBeenCalledTimes(2);
expect(waitForConnection).toHaveBeenCalledTimes(3);
expect(initialSock.end).toHaveBeenCalledOnce();
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
});
it("does not keep recreating sockets when login status 408 persists", async () => {
const initialSock = createSocketWithTransportEmitter();
const replacementSock = createSocketWithTransportEmitter();
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
const waitForConnection = vi
.fn()
.mockRejectedValueOnce(timeoutError)
.mockRejectedValueOnce(timeoutError);
const createSocket = vi.fn(async () => replacementSock);
const result = await waitForWhatsAppLoginResult({
sock: initialSock as never,
authDir: "/tmp/wa-auth",
isLegacyAuthDir: false,
verbose: false,
runtime: { log: vi.fn() } as never,
waitForConnection: waitForConnection as never,
createSocket: createSocket as never,
});
expect(result).toMatchObject({
outcome: "failed",
statusCode: DisconnectReason.timedOut,
error: timeoutError,
});
expect(createSocket).toHaveBeenCalledOnce();
expect(waitForConnection).toHaveBeenCalledTimes(2);
});
it("keeps the previous registered controller until a replacement listener is ready", async () => {
const liveController = new WhatsAppConnectionController({
accountId: "work",

View File

@@ -17,8 +17,12 @@ import {
import type { WhatsAppSocketTimingOptions } from "./socket-timing.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
const POST_PAIRING_RESTART_STATUS = 515;
const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408;
const WHATSAPP_LOGIN_RESTART_MESSAGE =
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
"WhatsApp connection timed out before login; retrying with a fresh socket…";
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
@@ -85,10 +89,28 @@ type WhatsAppReconnectAttemptDecision = {
healthState: "stopped" | "reconnecting";
};
type LoginSocketRestartKind = "post-pairing" | "timeout";
function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null {
if (statusCode === POST_PAIRING_RESTART_STATUS) {
return "post-pairing";
}
if (statusCode === TIMED_OUT_STATUS) {
return "timeout";
}
return null;
}
function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string {
return kind === "timeout"
? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE
: WHATSAPP_LOGIN_RESTART_MESSAGE;
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
@@ -201,21 +223,30 @@ export async function waitForWhatsAppLoginResult(params: {
const wait = params.waitForConnection ?? waitForWaConnection;
const createSocket = params.createSocket ?? createWaSocket;
let currentSock = params.sock;
let restarted = false;
let postPairingRestarted = false;
let timeoutRestarted = false;
while (true) {
try {
await wait(currentSock);
return {
outcome: "connected",
restarted,
restarted: postPairingRestarted || timeoutRestarted,
sock: currentSock,
};
} catch (err) {
const statusCode = getStatusCode(err);
if (statusCode === 515 && !restarted) {
restarted = true;
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
const restartKind = getLoginSocketRestartKind(statusCode);
const canRestart =
(restartKind === "post-pairing" && !postPairingRestarted) ||
(restartKind === "timeout" && !timeoutRestarted);
if (restartKind && canRestart) {
if (restartKind === "post-pairing") {
postPairingRestarted = true;
} else {
timeoutRestarted = true;
}
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
closeWaSocket(currentSock);
try {
currentSock = await createSocket(false, params.verbose, {

View File

@@ -129,6 +129,37 @@ describe("login-qr", () => {
expect(logoutWebMock).not.toHaveBeenCalled();
});
it("returns a replacement QR when status 408 happens before the first QR", async () => {
const accountId = "timeout-before-first-qr";
createWaSocketMock
.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never)
.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-after-timeout"));
return sock as never;
},
);
waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 408 } })
.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start).toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
message: "Scan this QR in WhatsApp → Linked Devices.",
});
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
});
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
waitForWaConnectionMock.mockRejectedValueOnce({
output: { statusCode: 401 },

View File

@@ -187,6 +187,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
return;
}
const qrVersion = updateLoginQrState(current, qr);
notifyQrUpdate(current);
renderLatestQrDataUrlInBackground({
accountId,
loginId: login.id,
@@ -269,8 +270,29 @@ async function waitForQrOrRecoveredLogin(params: {
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
} as const;
});
const qrUpdateResult = params.login.qrUpdatePromise.then(() => {
const current = activeLogins.get(params.accountId);
if (current?.id !== params.login.id) {
return {
outcome: "failed",
message: "WhatsApp login was replaced by a newer request.",
} as const;
}
if (current.qr) {
return { outcome: "qr", qr: current.qr } as const;
}
if (current.connected) {
return { outcome: "connected" } as const;
}
return {
outcome: "failed",
message: current.error
? `WhatsApp login failed: ${current.error}`
: "WhatsApp QR update ended without an active QR.",
} as const;
});
return await Promise.race([qrResult, loginResult]);
return await Promise.race([qrResult, loginResult, qrUpdateResult]);
}
export async function startWebLoginWithQr(