Prevent sending requests that are known to fail

In case it's still unknown if auth is set up on the server, only the initial request to detect this should be sent. Since all other requests will also just fail, they should be blocked until the auth initialization is completed.

While the access token is getting refreshed, no request will succeed since the access token is expired; thus, they should not be sent.
This commit is contained in:
schroda
2025-10-15 02:50:43 +02:00
parent 17a811cd39
commit 6636cc66b1
7 changed files with 186 additions and 64 deletions

View File

@@ -298,6 +298,7 @@ export const App: React.FC = () => (
<ScrollToTop />
<GlobalDialog />
<AuthGuard>
<ServerUpdateChecker />
<WebUIUpdateChecker />
<InitialBackgroundRequests />
@@ -307,7 +308,6 @@ export const App: React.FC = () => (
<CssBaseline enableColorScheme />
<AuthGuard>
<Box sx={{ display: 'flex' }}>
<Box sx={{ flexShrink: 0, position: 'relative', height: '100vh' }}>
<DefaultNavBar />

View File

@@ -18,6 +18,10 @@ export class AuthManager {
private static accessToken: string | null = null;
private static authInitialized: boolean = false;
private static refreshingToken: boolean = false;
static isAuthRequired(): boolean | null {
return AppStorage.session.getItemParsed(AuthManager.AUTH_REQUIRED_KEY, null);
}
@@ -90,4 +94,24 @@ export class AuthManager {
static useListenToReactSessionContextRefreshEvent(): void {
useSessionStorage(AuthManager.REACT_SESSION_REFRESH_KEY, 0);
}
static isAuthInitialized(): boolean {
return AuthManager.authInitialized;
}
static setAuthInitialized(value: boolean): void {
AuthManager.authInitialized = value;
}
static isRefreshingToken(): boolean {
return AuthManager.refreshingToken;
}
static setIsRefreshingToken(value: boolean): void {
AuthManager.refreshingToken = value;
}
static shouldQueueRequests(): boolean {
return !AuthManager.isAuthInitialized() || AuthManager.isRefreshingToken();
}
}

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { ReactNode, useEffect } from 'react';
import { ReactNode } from 'react';
import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
import { SplashScreen } from '@/features/authentication/components/SplashScreen.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
@@ -17,17 +17,17 @@ export const AuthGuard = ({ children }: { children: ReactNode }) => {
requestManager.useGetAbout({
skip: isAuthRequired !== null,
onCompleted: () => AuthManager.setAuthRequired(false),
onCompleted: () => {
if (AuthManager.isAuthInitialized()) {
return;
}
AuthManager.setAuthRequired(false);
AuthManager.setAuthInitialized(true);
requestManager.processQueues();
},
});
useEffect(() => {
const onUnload = () => AuthManager.setAuthRequired(null);
window.addEventListener('beforeunload', onUnload);
return () => window.removeEventListener('beforeunload', onUnload);
}, []);
if (isAuthRequired === null) {
return <SplashScreen />;
}

View File

@@ -29,6 +29,7 @@ import { MaybeMasked, OperationVariables, Reference } from '@apollo/client/core'
import { useEffect, useMemo, useRef, useState } from 'react';
import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts';
import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts';
import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
import {
CategoryOrderBy,
ChapterConditionInput,
@@ -452,6 +453,12 @@ export class RequestManager {
private readonly imageQueue = new Queue(5);
constructor() {
BaseClient.setTokenRefreshCompleteCallback(() => {
this.processQueues();
});
}
public getClient(): IRestClient {
return this.restClient;
}
@@ -463,6 +470,7 @@ export class RequestManager {
public reset(): void {
AuthManager.setAuthRequired(null);
AuthManager.setAuthInitialized(false);
AuthManager.removeTokens();
this.graphQLClient.client.resetStore();
this.graphQLClient.terminateSubscriptions();
@@ -470,6 +478,11 @@ export class RequestManager {
this.imageQueue.clear();
}
public processQueues(): void {
this.graphQLClient.processQueue();
this.restClient.processQueue();
}
public getBaseUrl(): string {
return this.restClient.getBaseUrl();
}

View File

@@ -11,6 +11,13 @@ import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts';
import { AuthManager } from '@/features/authentication/AuthManager.ts';
import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
interface QueuedRequest {
execute: () => void;
resolve: (value: any) => void;
reject: (error: any) => void;
}
export abstract class BaseClient<Client, ClientConfig, Fetcher> {
protected abstract client: Client;
@@ -19,6 +26,14 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
private static activeTokenRefreshPromise: Promise<UserRefreshMutation | null | undefined> | null = null;
private static onTokenRefreshComplete: (() => void) | null = null;
protected requestQueue: QueuedRequest[] = [];
public static setTokenRefreshCompleteCallback(callback: (() => void) | null): void {
BaseClient.onTokenRefreshComplete = callback;
}
protected static async refreshAccessToken(
refreshFn: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
): Promise<UserRefreshMutation | null | undefined> {
@@ -36,6 +51,8 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
return this.activeTokenRefreshPromise;
}
AuthManager.setIsRefreshingToken(true);
const refreshRequest = refreshFn(refreshToken).response;
this.activeTokenRefreshPromise = refreshRequest.then((result) => result.data);
@@ -48,6 +65,9 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
}
AuthManager.setAccessToken(data.refreshToken.accessToken);
AuthManager.setAuthInitialized(true);
BaseClient.onTokenRefreshComplete?.();
return data;
} catch (e) {
@@ -55,6 +75,7 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
throw e;
} finally {
this.activeTokenRefreshPromise = null;
AuthManager.setIsRefreshingToken(false);
}
}
@@ -75,5 +96,45 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
return SubpathUtil.getApiBaseUrl(serverBaseURL);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldQueueRequest(operationName?: string): boolean {
return AuthManager.shouldQueueRequests();
}
protected enqueueRequest<T>(executor: () => Promise<T>, operationName?: string): Promise<T> {
if (!this.shouldQueueRequest(operationName)) {
return executor();
}
const { promise: requestPromise, reject, resolve } = new ControlledPromise<T>();
this.requestQueue.push({
execute: () => {
executor().then(resolve).catch(reject);
},
resolve,
reject,
});
return requestPromise;
}
public processQueue(): void {
const queue = [...this.requestQueue];
this.requestQueue = [];
queue.forEach((request) => {
request.execute();
});
}
protected clearQueue(error?: Error): void {
const queue = [...this.requestQueue];
this.requestQueue = [];
queue.forEach((request) => {
request.reject(error ?? new Error('Request queue cleared'));
});
}
public abstract updateConfig(config: Partial<ClientConfig>): void;
}

View File

@@ -12,11 +12,12 @@ import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
from,
fromPromise,
InMemoryCache,
NormalizedCacheObject,
split,
from,
fromPromise,
toPromise,
} from '@apollo/client';
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
@@ -210,6 +211,27 @@ export class GraphQLClient extends BaseClient<
this.wsClient.terminate();
}
protected override shouldQueueRequest(operationName: string | undefined): boolean {
const authOperations = ['GET_ABOUT', 'USER_LOGIN', 'USER_REFRESH'];
if (authOperations.includes(operationName!)) {
return false;
}
return super.shouldQueueRequest();
}
private createAuthGuardLink() {
return new ApolloLink((operation, forward) => {
const { operationName } = operation;
if (this.shouldQueueRequest(operationName)) {
return fromPromise(this.enqueueRequest(() => toPromise(forward(operation)), operationName));
}
return forward(operation);
});
}
private createErrorLink() {
return onError(({ graphQLErrors, operation, forward }) => {
if (!graphQLErrors) {
@@ -262,6 +284,7 @@ export class GraphQLClient extends BaseClient<
},
this.createWSLink(),
from([
this.createAuthGuardLink(),
this.createErrorLink(),
this.createAuthLink(),
removeTypenameLink,

View File

@@ -49,7 +49,8 @@ export class RestClient
config?: RequestInit;
checkResponseIsJson?: boolean;
} = {},
): Promise<Response> => {
): Promise<Response> =>
this.enqueueRequest(async () => {
const updatedUrl = url.startsWith('http') ? url : `${this.getBaseUrl()}${url}`;
const isAuthRequired = AuthManager.isAuthRequired();
const accessToken = AuthManager.getAccessToken();
@@ -98,7 +99,7 @@ export class RestClient
}
return result;
};
});
constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>) {
super(handleRefreshToken);