fix: retry qr code login polling for known transient gateway responses

This commit is contained in:
Elias Schneider
2026-07-30 12:30:08 +02:00
parent a1ebf4e98b
commit 703969278b
@@ -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;
}