fix(client): gracefully handle errors while fetching user (#61623)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams 2025-08-05 15:22:49 +02:00 committed by GitHub
parent 69ebb7e37a
commit 7fdaa034c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 52 additions and 11 deletions

View File

@ -181,7 +181,7 @@ function DefaultLayout({
const isJapanese = clientLocale === 'japanese';
if (fetchState.pending) {
if (!fetchState.complete) {
return <Loader fullScreen={true} messageDelay={5000} />;
} else {
return (

View File

@ -46,7 +46,8 @@ export const actionTypes = createTypes(
...createAsyncTypes('showCert'),
...createAsyncTypes('reportUser'),
...createAsyncTypes('deleteUserToken'),
...createAsyncTypes('saveChallenge')
...createAsyncTypes('saveChallenge'),
'fetchUserTimeout'
],
ns
);

View File

@ -50,6 +50,7 @@ export const acceptTermsError = createAction(actionTypes.acceptTermsError);
export const fetchUser = createAction(actionTypes.fetchUser);
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
export const fetchUserTimeout = createAction(actionTypes.fetchUserTimeout);
export const fetchUserError = createAction(actionTypes.fetchUserError);
export const toggleTheme = createAction(actionTypes.toggleTheme);

View File

@ -1,20 +1,41 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { call, cancel, delay, fork, put, takeEvery } from 'redux-saga/effects';
import { getSessionUser, getUserProfile } from '../utils/ajax';
import {
fetchProfileForUserComplete,
fetchProfileForUserError,
fetchUserComplete,
fetchUserError
fetchUserError,
fetchUserTimeout
} from './actions';
function* fetchSessionUser() {
try {
const { data: user } = yield call(getSessionUser);
const timeoutTask = yield fork(function* () {
// The server should not take anywhere near 2 seconds to respond. If it
// does, we assume the request has failed and dispatch a timeout action to
// dismiss the loading state.
yield delay(2000);
yield put(fetchUserTimeout());
});
try {
// This is on a longer timeout to make sure that users with slow connections
// do, eventually, get signed in.
const res = yield call(getSessionUser, AbortSignal.timeout(10000));
const isSignedOut = res.response.status === 401;
if (!res.response.ok && !isSignedOut) {
throw new Error(
`HTTP Error: ${res.response.status} ${res.response.statusText}`
);
}
const { data: user } = res;
yield put(fetchUserComplete({ user }));
} catch (e) {
console.log('failed to fetch user', e);
yield put(fetchUserError(e));
} finally {
yield cancel(timeoutTask);
}
}

View File

@ -218,11 +218,22 @@ export const reducer = handleActions(
error: null
}
}),
[actionTypes.fetchUserTimeout]: state => ({
...state,
userFetchState: {
// Pending because the fetch may still complete. This allows the UI to
// render what it can while waiting for the fetch to complete.
pending: true,
complete: true,
errored: false,
error: null
}
}),
[actionTypes.fetchUserError]: (state, { payload }) => ({
...state,
userFetchState: {
pending: false,
complete: false,
complete: true,
errored: true,
error: payload
}

View File

@ -37,10 +37,14 @@ export interface ResponseWithData<T> {
// TODO: Might want to handle flash messages as close to the request as possible
// to make use of the Response object (message, status, etc)
async function get<T>(path: string): Promise<ResponseWithData<T>> {
async function get<T>(
path: string,
signal?: AbortSignal
): Promise<ResponseWithData<T>> {
const response = await fetch(`${base}${path}`, {
...defaultOptions,
headers: { 'CSRF-Token': getCSRFToken() }
headers: { 'CSRF-Token': getCSRFToken() },
signal
});
return combineDataWithResponse(response);
@ -144,9 +148,12 @@ function mapKeyToFileKey<K>(
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
}
export function getSessionUser(): Promise<ResponseWithData<User | null>> {
export function getSessionUser(
signal?: AbortSignal
): Promise<ResponseWithData<User | null>> {
const responseWithData: Promise<ResponseWithData<ApiUserResponse>> = get(
'/user/get-session-user'
'/user/get-session-user',
signal
);
// TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc.
return responseWithData.then(({ response, data }) => {