fix(whatsapp): stop retry loop on non-retryable 440 close

This commit is contained in:
Mark Musson
2026-02-24 19:28:47 +00:00
committed by Peter Steinberger
parent def993dbd8
commit e22a2d77ba
2 changed files with 72 additions and 0 deletions

View File

@@ -145,6 +145,56 @@ describe("web auto-reply", () => {
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(scenario.expectedError));
}
});
it("treats status 440 as non-retryable and stops without retrying", async () => {
const closeResolvers: Array<(reason?: unknown) => void> = [];
const sleep = vi.fn(async () => {});
const listenerFactory = vi.fn(async () => {
const onClose = new Promise<unknown>((res) => {
closeResolvers.push(res);
});
return { close: vi.fn(), onClose };
});
const { runtime, controller, run } = startMonitorWebChannel({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
sleep,
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
});
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);
closeResolvers.shift()?.({
status: 440,
isLoggedOut: false,
error: "Unknown Stream Errored (conflict)",
});
const completedQuickly = await Promise.race([
run.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 60)),
]);
if (!completedQuickly) {
await vi.waitFor(
() => {
expect(listenerFactory).toHaveBeenCalledTimes(2);
},
{ timeout: 250, interval: 2 },
);
controller.abort();
closeResolvers[1]?.({ status: 499, isLoggedOut: false, error: "aborted" });
await run;
}
expect(completedQuickly).toBe(true);
expect(listenerFactory).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("status 440"));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("session conflict"));
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring"));
});
it("forces reconnect when watchdog closes without onClose", async () => {
vi.useFakeTimers();
try {

View File

@@ -31,6 +31,12 @@ import { createWebOnMessageHandler } from "./monitor/on-message.js";
import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js";
import { isLikelyWhatsAppCryptoError } from "./util.js";
function isNonRetryableWebCloseStatus(statusCode: unknown): boolean {
// WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)").
// This is persistent until the operator resolves the conflicting session.
return statusCode === 440;
}
export async function monitorWebChannel(
verbose: boolean,
listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox,
@@ -402,6 +408,22 @@ export async function monitorWebChannel(
break;
}
if (isNonRetryableWebCloseStatus(statusCode)) {
reconnectLogger.warn(
{
connectionId,
status: statusCode,
error: errorStr,
},
"web reconnect: non-retryable close status; stopping monitor",
);
runtime.error(
`WhatsApp Web connection closed (status ${statusCode}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`,
);
await closeListener();
break;
}
reconnectAttempts += 1;
status.reconnectAttempts = reconnectAttempts;
emitStatus();