Skip to content

Commit f613f32

Browse files
authored
fix(whatsapp): retry QR login 408 timeouts (#88183)
1 parent 03415bb commit f613f32

4 files changed

Lines changed: 210 additions & 7 deletions

File tree

extensions/whatsapp/src/connection-controller.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { EventEmitter } from "node:events";
2+
import { DisconnectReason } from "baileys";
23
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
4-
import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js";
5+
import {
6+
closeWaSocket,
7+
waitForWhatsAppLoginResult,
8+
WhatsAppConnectionController,
9+
} from "./connection-controller.js";
510
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
611
import { createWaSocket, waitForWaConnection } from "./session.js";
712

@@ -127,6 +132,120 @@ describe("WhatsAppConnectionController", () => {
127132
expect(callOrder).toEqual(["create", "wait-for-connection"]);
128133
});
129134

135+
it("restarts login once on status 408 and preserves replacement socket options", async () => {
136+
const initialSock = createSocketWithTransportEmitter();
137+
const replacementSock = createSocketWithTransportEmitter();
138+
const waitForConnection = vi
139+
.fn()
140+
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
141+
.mockResolvedValueOnce(undefined);
142+
const onQr = vi.fn();
143+
const onSocketReplaced = vi.fn();
144+
const createSocket = vi.fn(
145+
async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => {
146+
opts?.onQr?.("qr-after-timeout");
147+
return replacementSock;
148+
},
149+
);
150+
151+
const result = await waitForWhatsAppLoginResult({
152+
sock: initialSock as never,
153+
authDir: "/tmp/wa-auth",
154+
isLegacyAuthDir: false,
155+
verbose: true,
156+
runtime: { log: vi.fn() } as never,
157+
waitForConnection: waitForConnection as never,
158+
createSocket: createSocket as never,
159+
socketTiming: {
160+
connectTimeoutMs: 10_000,
161+
defaultQueryTimeoutMs: 20_000,
162+
keepAliveIntervalMs: 30_000,
163+
},
164+
onQr,
165+
onSocketReplaced,
166+
});
167+
168+
expect(result).toEqual({
169+
outcome: "connected",
170+
restarted: true,
171+
sock: replacementSock,
172+
});
173+
expect(initialSock.end).toHaveBeenCalledOnce();
174+
expect(createSocket).toHaveBeenCalledWith(false, true, {
175+
authDir: "/tmp/wa-auth",
176+
connectTimeoutMs: 10_000,
177+
defaultQueryTimeoutMs: 20_000,
178+
keepAliveIntervalMs: 30_000,
179+
onQr,
180+
});
181+
expect(onQr).toHaveBeenCalledWith("qr-after-timeout");
182+
expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock);
183+
});
184+
185+
it("still honors the post-pairing 515 restart after a status 408 recovery", async () => {
186+
const initialSock = createSocketWithTransportEmitter();
187+
const afterTimeoutSock = createSocketWithTransportEmitter();
188+
const afterPairingRestartSock = createSocketWithTransportEmitter();
189+
const waitForConnection = vi
190+
.fn()
191+
.mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } })
192+
.mockRejectedValueOnce({ output: { statusCode: 515 } })
193+
.mockResolvedValueOnce(undefined);
194+
const createSocket = vi
195+
.fn()
196+
.mockResolvedValueOnce(afterTimeoutSock)
197+
.mockResolvedValueOnce(afterPairingRestartSock);
198+
199+
const result = await waitForWhatsAppLoginResult({
200+
sock: initialSock as never,
201+
authDir: "/tmp/wa-auth",
202+
isLegacyAuthDir: false,
203+
verbose: false,
204+
runtime: { log: vi.fn() } as never,
205+
waitForConnection: waitForConnection as never,
206+
createSocket: createSocket as never,
207+
});
208+
209+
expect(result).toEqual({
210+
outcome: "connected",
211+
restarted: true,
212+
sock: afterPairingRestartSock,
213+
});
214+
expect(createSocket).toHaveBeenCalledTimes(2);
215+
expect(waitForConnection).toHaveBeenCalledTimes(3);
216+
expect(initialSock.end).toHaveBeenCalledOnce();
217+
expect(afterTimeoutSock.end).toHaveBeenCalledOnce();
218+
});
219+
220+
it("does not keep recreating sockets when login status 408 persists", async () => {
221+
const initialSock = createSocketWithTransportEmitter();
222+
const replacementSock = createSocketWithTransportEmitter();
223+
const timeoutError = { output: { statusCode: DisconnectReason.timedOut } };
224+
const waitForConnection = vi
225+
.fn()
226+
.mockRejectedValueOnce(timeoutError)
227+
.mockRejectedValueOnce(timeoutError);
228+
const createSocket = vi.fn(async () => replacementSock);
229+
230+
const result = await waitForWhatsAppLoginResult({
231+
sock: initialSock as never,
232+
authDir: "/tmp/wa-auth",
233+
isLegacyAuthDir: false,
234+
verbose: false,
235+
runtime: { log: vi.fn() } as never,
236+
waitForConnection: waitForConnection as never,
237+
createSocket: createSocket as never,
238+
});
239+
240+
expect(result).toMatchObject({
241+
outcome: "failed",
242+
statusCode: DisconnectReason.timedOut,
243+
error: timeoutError,
244+
});
245+
expect(createSocket).toHaveBeenCalledOnce();
246+
expect(waitForConnection).toHaveBeenCalledTimes(2);
247+
});
248+
130249
it("keeps the previous registered controller until a replacement listener is ready", async () => {
131250
const liveController = new WhatsAppConnectionController({
132251
accountId: "work",

extensions/whatsapp/src/connection-controller.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ import {
1717
import type { WhatsAppSocketTimingOptions } from "./socket-timing.js";
1818

1919
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
20+
const POST_PAIRING_RESTART_STATUS = 515;
21+
const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408;
2022
const WHATSAPP_LOGIN_RESTART_MESSAGE =
2123
"WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…";
24+
const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE =
25+
"WhatsApp connection timed out before login; retrying with a fresh socket…";
2226
const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE =
2327
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again.";
2428
export const WHATSAPP_LOGGED_OUT_QR_MESSAGE =
@@ -85,10 +89,28 @@ type WhatsAppReconnectAttemptDecision = {
8589
healthState: "stopped" | "reconnecting";
8690
};
8791

92+
type LoginSocketRestartKind = "post-pairing" | "timeout";
93+
8894
function createNeverResolvePromise<T>(): Promise<T> {
8995
return new Promise<T>(() => {});
9096
}
9197

98+
function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null {
99+
if (statusCode === POST_PAIRING_RESTART_STATUS) {
100+
return "post-pairing";
101+
}
102+
if (statusCode === TIMED_OUT_STATUS) {
103+
return "timeout";
104+
}
105+
return null;
106+
}
107+
108+
function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string {
109+
return kind === "timeout"
110+
? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE
111+
: WHATSAPP_LOGIN_RESTART_MESSAGE;
112+
}
113+
92114
type SocketActivityEmitter = {
93115
on?: (event: string, listener: (...args: unknown[]) => void) => void;
94116
off?: (event: string, listener: (...args: unknown[]) => void) => void;
@@ -201,21 +223,30 @@ export async function waitForWhatsAppLoginResult(params: {
201223
const wait = params.waitForConnection ?? waitForWaConnection;
202224
const createSocket = params.createSocket ?? createWaSocket;
203225
let currentSock = params.sock;
204-
let restarted = false;
226+
let postPairingRestarted = false;
227+
let timeoutRestarted = false;
205228

206229
while (true) {
207230
try {
208231
await wait(currentSock);
209232
return {
210233
outcome: "connected",
211-
restarted,
234+
restarted: postPairingRestarted || timeoutRestarted,
212235
sock: currentSock,
213236
};
214237
} catch (err) {
215238
const statusCode = getStatusCode(err);
216-
if (statusCode === 515 && !restarted) {
217-
restarted = true;
218-
params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE));
239+
const restartKind = getLoginSocketRestartKind(statusCode);
240+
const canRestart =
241+
(restartKind === "post-pairing" && !postPairingRestarted) ||
242+
(restartKind === "timeout" && !timeoutRestarted);
243+
if (restartKind && canRestart) {
244+
if (restartKind === "post-pairing") {
245+
postPairingRestarted = true;
246+
} else {
247+
timeoutRestarted = true;
248+
}
249+
params.runtime.log(info(getLoginSocketRestartMessage(restartKind)));
219250
closeWaSocket(currentSock);
220251
try {
221252
currentSock = await createSocket(false, params.verbose, {

extensions/whatsapp/src/login-qr.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,37 @@ describe("login-qr", () => {
129129
expect(logoutWebMock).not.toHaveBeenCalled();
130130
});
131131

132+
it("returns a replacement QR when status 408 happens before the first QR", async () => {
133+
const accountId = "timeout-before-first-qr";
134+
createWaSocketMock
135+
.mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never)
136+
.mockImplementationOnce(
137+
async (
138+
_printQr: boolean,
139+
_verbose: boolean,
140+
opts?: { authDir?: string; onQr?: (qr: string) => void },
141+
) => {
142+
const sock = { ws: { close: vi.fn() } };
143+
setImmediate(() => opts?.onQr?.("qr-after-timeout"));
144+
return sock as never;
145+
},
146+
);
147+
waitForWaConnectionMock
148+
.mockRejectedValueOnce({ output: { statusCode: 408 } })
149+
.mockImplementation(() => new Promise(() => {}));
150+
151+
const start = await startWebLoginWithQr({
152+
timeoutMs: 5000,
153+
accountId,
154+
});
155+
156+
expect(start).toEqual({
157+
qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout",
158+
message: "Scan this QR in WhatsApp → Linked Devices.",
159+
});
160+
expect(createWaSocketMock).toHaveBeenCalledTimes(2);
161+
});
162+
132163
it("clears auth and reports a relink message when WhatsApp is logged out", async () => {
133164
waitForWaConnectionMock.mockRejectedValueOnce({
134165
output: { statusCode: 401 },

extensions/whatsapp/src/login-qr.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
187187
return;
188188
}
189189
const qrVersion = updateLoginQrState(current, qr);
190+
notifyQrUpdate(current);
190191
renderLatestQrDataUrlInBackground({
191192
accountId,
192193
loginId: login.id,
@@ -269,8 +270,29 @@ async function waitForQrOrRecoveredLogin(params: {
269270
message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.",
270271
} as const;
271272
});
273+
const qrUpdateResult = params.login.qrUpdatePromise.then(() => {
274+
const current = activeLogins.get(params.accountId);
275+
if (current?.id !== params.login.id) {
276+
return {
277+
outcome: "failed",
278+
message: "WhatsApp login was replaced by a newer request.",
279+
} as const;
280+
}
281+
if (current.qr) {
282+
return { outcome: "qr", qr: current.qr } as const;
283+
}
284+
if (current.connected) {
285+
return { outcome: "connected" } as const;
286+
}
287+
return {
288+
outcome: "failed",
289+
message: current.error
290+
? `WhatsApp login failed: ${current.error}`
291+
: "WhatsApp QR update ended without an active QR.",
292+
} as const;
293+
});
272294

273-
return await Promise.race([qrResult, loginResult]);
295+
return await Promise.race([qrResult, loginResult, qrUpdateResult]);
274296
}
275297

276298
export async function startWebLoginWithQr(

0 commit comments

Comments
 (0)