From 703969278be2a6b2cae30ca89dba4b73b0c969fa Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Thu, 30 Jul 2026 12:30:08 +0200 Subject: [PATCH] fix: retry qr code login polling for known transient gateway responses --- .../login/alternative/device/+page.svelte | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/routes/login/alternative/device/+page.svelte b/frontend/src/routes/login/alternative/device/+page.svelte index 535f56bd..6f177873 100644 --- a/frontend/src/routes/login/alternative/device/+page.svelte +++ b/frontend/src/routes/login/alternative/device/+page.svelte @@ -13,6 +13,7 @@ import userStore from '$lib/stores/user-store'; import type { DeviceLoginRequest } from '$lib/types/device-login.type'; import { getAxiosErrorMessage } from '$lib/utils/error-util'; + import { isAxiosError } from 'axios'; import { mode } from 'mode-watcher'; import { onMount } from 'svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; @@ -37,6 +38,7 @@ async function startRequest() { stopRequest(); + // Track this attempt so stale responses cannot update the current page const controller = new AbortController(); requestController = controller; request = undefined; @@ -45,12 +47,14 @@ try { request = await deviceLoginService.createRequest(controller.signal); + // Ignore a response from an attempt replaced while the request was in flight if (requestController !== controller) return; schedulePoll(controller); } catch (error) { if (controller.signal.aborted) return; errorMessage = getAxiosErrorMessage(error); } finally { + // Stop the initial loading state only if this attempt still owns the page if (requestController === controller) { isStarting = false; } @@ -58,15 +62,19 @@ } function schedulePoll(controller: AbortController) { + // Ignore schedules from stale attempts or before request metadata exists if (!request || requestController !== controller) return; + // Delay the next exchange so the client respects the server-provided polling interval pollTimer = setTimeout(() => void exchangeRequest(controller), request.interval * 1000); } async function exchangeRequest(controller: AbortController) { + // Ignore work from a stopped or superseded attempt if (!request || requestController !== controller) return; try { const user = await deviceLoginService.exchangeRequest(request.id, controller.signal); + // Ignore a response from an attempt replaced while the exchange was in flight if (requestController !== controller) return; if (!user) { schedulePoll(controller); @@ -75,22 +83,42 @@ clearTimers(); await userStore.setUser(user); + // Navigate only after confirming that this attempt is still active if (requestController !== controller) return; await goto(data.redirect); } catch (error) { if (controller.signal.aborted) return; + // Retry transport and proxy failures because the device login request remains pending + if (isRetryableDeviceLoginExchangeError(error)) { + schedulePoll(controller); + return; + } + // Stop retrying when the server reports a non-transient failure clearTimers(); errorMessage = getAxiosErrorMessage(error); } } + function isRetryableDeviceLoginExchangeError(error: unknown) { + // Ignore non-Axios errors and intentional cancellations + if (!isAxiosError(error) || error.code === 'ERR_CANCELED') return false; + + const retryableExchangeStatuses = new Set([408, 425, 429, 502, 503, 504, 522, 524]); + // Retry failures without an HTTP response or known transient gateway responses + return ( + error.response?.status === undefined || retryableExchangeStatuses.has(error.response.status) + ); + } + function stopRequest() { + // Abort the active exchange and clear its timer before replacing or leaving it requestController?.abort(); requestController = undefined; clearTimers(); } function clearTimers() { + // Ensure an old scheduled poll cannot start after the request lifecycle ends if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined; }