Handle http 429 errors

This commit is contained in:
schroda
2026-05-13 03:44:56 +02:00
parent 0b373c7785
commit 18c437d77b
3 changed files with 148 additions and 6 deletions

View File

@@ -12,6 +12,8 @@ import { AuthManager } from '@/features/authentication/AuthManager.ts';
import type { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts'; import type { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts'; import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
import { ControlledPromise } from '@/lib/ControlledPromise.ts'; import { ControlledPromise } from '@/lib/ControlledPromise.ts';
import { d } from 'koration';
import dayjs from 'dayjs';
interface QueuedRequest { interface QueuedRequest {
execute: () => void; execute: () => void;
@@ -19,7 +21,14 @@ interface QueuedRequest {
reject: (error: any) => void; reject: (error: any) => void;
} }
interface RateLimitInfo {
timestamp: number;
retryAfter: number;
}
export abstract class BaseClient<Client, ClientConfig, Fetcher> { export abstract class BaseClient<Client, ClientConfig, Fetcher> {
private static readonly RATE_LIMIT_STORAGE_KEY = 'RATE_LIMIT_STATE';
static readonly BASE_URL_KEY = 'serverBaseURL'; static readonly BASE_URL_KEY = 'serverBaseURL';
protected abstract client: Client; protected abstract client: Client;
@@ -32,6 +41,16 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
protected requestQueue: QueuedRequest[] = []; protected requestQueue: QueuedRequest[] = [];
private static rateLimitState = new Map<string, RateLimitInfo>();
protected constructor(
protected handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
) {
const rateLimitState = AppStorage.local.getItemParsed(BaseClient.RATE_LIMIT_STORAGE_KEY, {});
BaseClient.rateLimitState = new Map(Object.entries(rateLimitState));
}
public reset(): void { public reset(): void {
BaseClient.activeTokenRefreshPromise = null; BaseClient.activeTokenRefreshPromise = null;
this.clearQueue(new Error('Client reset')); this.clearQueue(new Error('Client reset'));
@@ -86,11 +105,7 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
} }
} }
protected constructor( private static getBaseUrl(): string {
protected handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
) {}
public getBaseUrl(): string {
const { hostname, port, protocol } = window.location; const { hostname, port, protocol } = window.location;
const defaultUrl = import.meta.env.DEV const defaultUrl = import.meta.env.DEV
@@ -103,6 +118,10 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
return SubpathUtil.getApiBaseUrl(serverBaseURL); return SubpathUtil.getApiBaseUrl(serverBaseURL);
} }
public getBaseUrl(): string {
return BaseClient.getBaseUrl();
}
// oxlint-disable-next-line no-unused-vars // oxlint-disable-next-line no-unused-vars
protected shouldQueueRequest(operationName?: string): boolean { protected shouldQueueRequest(operationName?: string): boolean {
return AuthManager.shouldQueueRequests(); return AuthManager.shouldQueueRequests();
@@ -147,4 +166,82 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
} }
public abstract updateConfig(config: Partial<ClientConfig>): void; public abstract updateConfig(config: Partial<ClientConfig>): void;
private static saveRateLimits() {
AppStorage.local.setItem(
BaseClient.RATE_LIMIT_STORAGE_KEY,
Object.fromEntries([...BaseClient.rateLimitState.entries()]),
);
}
private convertRetryAfter(retryAfter: string | null | undefined): number {
if (retryAfter == null) {
return d(1).minutes.inWholeMilliseconds;
}
const seconds = parseInt(retryAfter, 10);
if (!Number.isNaN(seconds)) {
return d(seconds).seconds.inWholeMilliseconds;
}
const date = new Date(retryAfter);
return dayjs(date).diff();
}
protected getOriginFromUrl(url: string): string {
const { origin } = new URL(url);
if (origin.startsWith(BaseClient.getBaseUrl())) {
return url;
}
return origin;
}
protected addRateLimit(url: string, retryAfter: string | null | undefined) {
BaseClient.rateLimitState.set(this.getOriginFromUrl(url), {
timestamp: Date.now(),
retryAfter: this.convertRetryAfter(retryAfter),
});
BaseClient.saveRateLimits();
}
private deleteRateLimit(origin: string) {
BaseClient.rateLimitState.delete(origin);
BaseClient.saveRateLimits();
}
protected getRateLimitTimeout(url: string): number {
return BaseClient.rateLimitState.get(this.getOriginFromUrl(url))?.retryAfter ?? 0;
}
protected isRateLimited(url: string): boolean {
const origin = this.getOriginFromUrl(url);
const rateLimitInfo = BaseClient.rateLimitState.get(origin);
if (!rateLimitInfo) {
return false;
}
const shouldRetry = Date.now() >= rateLimitInfo.timestamp + rateLimitInfo.retryAfter;
if (!shouldRetry) {
return true;
}
this.deleteRateLimit(origin);
return false;
}
protected async awaitRateLimit(url: string): Promise<void> {
if (!this.isRateLimited(url)) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, this.getRateLimitTimeout(url));
});
}
} }

View File

@@ -8,7 +8,8 @@
import { ErrorLink } from '@apollo/client/link/error'; import { ErrorLink } from '@apollo/client/link/error';
import { SetContextLink } from '@apollo/client/link/context'; import { SetContextLink } from '@apollo/client/link/context';
import { ApolloClient, ApolloLink, CombinedGraphQLErrors, InMemoryCache } from '@apollo/client'; import type { ErrorLike } from '@apollo/client';
import { ApolloClient, ApolloLink, CombinedGraphQLErrors, InMemoryCache, ServerError } from '@apollo/client';
import { from, filter, map, switchMap, firstValueFrom } from 'rxjs'; import { from, filter, map, switchMap, firstValueFrom } from 'rxjs';
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs'; import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
@@ -248,6 +249,31 @@ export class GraphQLClient extends BaseClient<ApolloClient, ApolloClient.Options
}); });
} }
protected override getOriginFromUrl(operation: string): string {
return operation;
}
private getRateLimitOrigin(operation: ApolloLink.Operation): string {
return `${operation.operationName}::${JSON.stringify(operation.variables)}`;
}
private isRateLimitError(error: ErrorLike): boolean {
if (CombinedGraphQLErrors.is(error)) {
return error.errors.some(
(gqlError) =>
gqlError.message.toLowerCase().includes('http 429') ||
gqlError.message.toLowerCase().includes('http error 429') ||
gqlError.message.toLowerCase().includes('too many requests'),
);
}
if (ServerError.is(error)) {
return error.statusCode === 429;
}
return false;
}
private isAuthError(errors: readonly GraphQLFormattedError[]): boolean { private isAuthError(errors: readonly GraphQLFormattedError[]): boolean {
return errors.some((graphQLError) => return errors.some((graphQLError) =>
graphQLError.message.includes('suwayomi.tachidesk.server.user.UnauthorizedException'), graphQLError.message.includes('suwayomi.tachidesk.server.user.UnauthorizedException'),
@@ -256,9 +282,21 @@ export class GraphQLClient extends BaseClient<ApolloClient, ApolloClient.Options
private createErrorLink() { private createErrorLink() {
return new ErrorLink(({ error, operation, forward }) => { return new ErrorLink(({ error, operation, forward }) => {
if (this.isRateLimitError(error)) {
this.addRateLimit(
this.getRateLimitOrigin(operation),
operation.getContext()?.headers?.get?.('Retry-After'),
);
return from(this.awaitRateLimit(this.getRateLimitOrigin(operation))).pipe(
switchMap(() => forward(operation)),
);
}
if (!CombinedGraphQLErrors.is(error)) { if (!CombinedGraphQLErrors.is(error)) {
return undefined; return undefined;
} }
if (!this.isAuthError(error.errors)) { if (!this.isAuthError(error.errors)) {
return undefined; return undefined;
} }

View File

@@ -55,6 +55,8 @@ export class RestClient
const isAuthRequired = AuthManager.isAuthRequired(); const isAuthRequired = AuthManager.isAuthRequired();
const accessToken = AuthManager.getAccessToken(); const accessToken = AuthManager.getAccessToken();
await this.awaitRateLimit(updatedUrl);
let result: Response; let result: Response;
switch (httpMethod) { switch (httpMethod) {
@@ -90,6 +92,11 @@ export class RestClient
return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson }); return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson });
} }
if (result.status === 429) {
this.addRateLimit(updatedUrl, result.headers.get('Retry-After'));
return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson });
}
if (result.status !== 200) { if (result.status !== 200) {
throw new Error(`status ${result.status}: ${result.statusText}`); throw new Error(`status ${result.status}: ${result.statusText}`);
} }