From 6839353f349dc54088dad7c3d800c189405d65bb Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:24:34 +0100 Subject: [PATCH] [#418] improved retry logic to prevent loading loop (#427) * [#418] improved retry logic to prevent loading loop * [#418] added FullJitter algorithm Co-authored-by: Benoit TELLIER --- src/components/Error/Error.tsx | 7 +++--- src/features/User/HandleLogin.tsx | 15 ++++++++++-- src/features/User/LoginCallback.tsx | 17 ++++++++++++-- src/features/User/userSlice.ts | 4 ++++ src/utils/apiUtils.ts | 36 +++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx index bf32a2c..1bb1af6 100644 --- a/src/components/Error/Error.tsx +++ b/src/components/Error/Error.tsx @@ -1,7 +1,7 @@ import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; import ReplayIcon from "@mui/icons-material/Replay"; import { Box, Button, Fade, Paper, Stack, Typography } from "@mui/material"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { push } from "redux-first-history"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useI18n } from "twake-i18n"; @@ -11,12 +11,13 @@ export function Error() { const dispatch = useAppDispatch(); const userError = useAppSelector((state) => state.user.error); const calendarError = useAppSelector((state) => state.calendars.error); + const initialUserError = useRef(userError); useEffect(() => { - if (!userError) { + if (!initialUserError.current) { dispatch(push("/")); } - }, [calendarError, dispatch]); + }, [dispatch]); const errorMessage = userError || calendarError || t("error.unknown"); diff --git a/src/features/User/HandleLogin.tsx b/src/features/User/HandleLogin.tsx index c7df37f..77c96dc 100644 --- a/src/features/User/HandleLogin.tsx +++ b/src/features/User/HandleLogin.tsx @@ -59,10 +59,21 @@ export function HandleLogin() { if (userData.error) { dispatch(push("/error")); } - if (!calendars.pending && !userData.loading && !userData.error) { + if ( + !calendars.pending && + !userData.loading && + !userData.error && + !calendars.error + ) { dispatch(push("/calendar")); } - }, [calendars.pending, userData.loading, userData.error, dispatch]); + }, [ + calendars.pending, + userData.loading, + userData.error, + calendars.error, + dispatch, + ]); return ; } diff --git a/src/features/User/LoginCallback.tsx b/src/features/User/LoginCallback.tsx index e5b5574..7b20f6a 100644 --- a/src/features/User/LoginCallback.tsx +++ b/src/features/User/LoginCallback.tsx @@ -1,8 +1,13 @@ import { useEffect, useRef } from "react"; import { Callback } from "./oidcAuth"; -import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { useAppDispatch } from "../../app/hooks"; import { push } from "redux-first-history"; -import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice"; +import { + getOpenPaasUserDataAsync, + setTokens, + setUserData, + setUserError, +} from "./userSlice"; import { Loading } from "../../components/Loading/Loading"; import { getCalendarsListAsync } from "../Calendars/CalendarSlice"; @@ -21,6 +26,9 @@ export function CallbackResume() { const runCallback = async () => { try { const data = await Callback(saved?.code_verifier, saved?.state); + if (!data || !data.userinfo || !data.tokenSet) { + throw new Error("OAuth callback failed"); + } dispatch(setUserData(data?.userinfo)); dispatch(setTokens(data?.tokenSet)); await dispatch(getOpenPaasUserDataAsync()); @@ -37,6 +45,11 @@ export function CallbackResume() { dispatch(push("/")); } catch (e) { console.error("OIDC callback error:", e); + // Redirect to error page after error + dispatch( + setUserError(e instanceof Error ? e.message : "OAuth callback failed") + ); + dispatch(push("/error")); } }; diff --git a/src/features/User/userSlice.ts b/src/features/User/userSlice.ts index e82c3e7..7e067c4 100644 --- a/src/features/User/userSlice.ts +++ b/src/features/User/userSlice.ts @@ -86,6 +86,9 @@ export const userSlice = createSlice({ setAlarmEmails: (state, action) => { state.alarmEmailsEnabled = action.payload; }, + setUserError: (state, action) => { + state.error = action.payload; + }, clearError: (state) => { state.error = null; }, @@ -218,6 +221,7 @@ export const { setLanguage, setTimezone, setAlarmEmails, + setUserError, clearError, } = userSlice.actions; diff --git a/src/utils/apiUtils.ts b/src/utils/apiUtils.ts index 48be742..ae788e7 100644 --- a/src/utils/apiUtils.ts +++ b/src/utils/apiUtils.ts @@ -1,8 +1,26 @@ import ky from "ky"; import { Auth } from "../features/User/oidcAuth"; +const RETRY_CONFIG = { + maxRetries: 10, + initialDelay: 1000, + maxDelay: 120000, +}; + +function getRetryDelay(attemptNumber: number): number { + const cap = RETRY_CONFIG.maxDelay; + const base = RETRY_CONFIG.initialDelay * Math.pow(2, attemptNumber); + const jitter = 0.5 + Math.random(); + return Math.min(cap, base * jitter); +} + export const api = ky.extend({ prefixUrl: (window as any).CALENDAR_BASE_URL, + retry: { + limit: RETRY_CONFIG.maxRetries, + backoffLimit: RETRY_CONFIG.maxDelay, + delay: (attemptCount) => getRetryDelay(attemptCount - 1), + }, hooks: { beforeRequest: [ async (request) => { @@ -10,11 +28,25 @@ export const api = ky.extend({ ? JSON.parse(sessionStorage.getItem("tokenSet")!) : null; const access_token = saved?.access_token; - - request.headers.set("Authorization", `Bearer ${access_token}`); + if (access_token) { + request.headers.set("Authorization", `Bearer ${access_token}`); + } return request; }, ], + + beforeRetry: [ + async ({ request, error, retryCount }) => { + console.warn( + `[API Retry] Attempt ${retryCount}/${RETRY_CONFIG.maxRetries}`, + { + url: request.url, + error: error?.message, + } + ); + }, + ], + afterResponse: [ async (request, options, response) => { if (response.status === 401) {