fix(gateway): stop stale device token reconnect loops

This commit is contained in:
Peter Steinberger
2026-04-28 11:37:10 +01:00
parent 205d8d4994
commit 885806d5ca
5 changed files with 90 additions and 1 deletions

View File

@@ -890,6 +890,36 @@ describe("GatewayClient connect auth payload", () => {
});
});
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
loadDeviceAuthTokenMock.mockReturnValue({
token: "stored-device-token",
scopes: ["operator.read"],
});
const onReconnectPaused = vi.fn();
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
onReconnectPaused,
});
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
expect(firstConnect.params?.auth?.token).toBe("stored-device-token");
await expectNoReconnectAfterConnectFailure({
client,
firstWs: ws1,
connectId: firstConnect.id,
failureDetails: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
});
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({
deviceId: expect.any(String),
role: "operator",
});
expect(onReconnectPaused).toHaveBeenCalledWith({
code: 1008,
reason: "connect failed",
detailCode: "AUTH_DEVICE_TOKEN_MISMATCH",
});
});
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({

View File

@@ -594,6 +594,23 @@ export class GatewayClient {
resolvedDeviceToken,
storedToken: storedToken ?? undefined,
});
if (
this.opts.deviceIdentity &&
usingStoredDeviceToken &&
err instanceof GatewayClientRequestError &&
readConnectErrorDetailCode(err.details) ===
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
const deviceId = this.opts.deviceIdentity.deviceId;
try {
clearDeviceAuthToken({ deviceId, role });
logDebug(`cleared stale device-auth token for device ${deviceId}`);
} catch (clearErr) {
logDebug(
`failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`,
);
}
}
if (shouldRetryWithDeviceToken) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
@@ -653,6 +670,7 @@ export class GatewayClient {
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED

View File

@@ -45,6 +45,12 @@ describe("isNonRecoverableAuthError", () => {
);
});
it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => {
expect(
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)),
).toBe(true);
});
it("blocks reconnect for PAIRING_REQUIRED", () => {
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
true,

View File

@@ -515,6 +515,36 @@ describe("GatewayBrowserClient", () => {
vi.useRealTimers();
});
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
vi.useFakeTimers();
const client = new GatewayBrowserClient({
url: "ws://127.0.0.1:18789",
});
const { ws, connectFrame } = await startConnect(client);
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
ws.emitMessage({
type: "res",
id: connectFrame.id,
ok: false,
error: {
code: "INVALID_REQUEST",
message: "unauthorized",
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
},
});
await expectSocketClosed(ws);
ws.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull();
await vi.advanceTimersByTimeAsync(30_000);
expect(wsInstances).toHaveLength(1);
vi.useRealTimers();
});
});
describe("shouldRetryWithDeviceToken", () => {

View File

@@ -86,6 +86,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
@@ -519,8 +520,12 @@ export class GatewayBrowserClient {
} else {
this.pendingConnectError = undefined;
}
const usedStoredDeviceToken =
Boolean(plan.selectedAuth.storedToken) &&
(plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken ||
plan.selectedAuth.authDeviceToken === plan.selectedAuth.storedToken);
if (
plan.selectedAuth.canFallbackToShared &&
usedStoredDeviceToken &&
plan.deviceIdentity &&
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {