Handle http 429 errors
This commit is contained in:
@@ -12,6 +12,8 @@ import { AuthManager } from '@/features/authentication/AuthManager.ts';
|
||||
import type { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
|
||||
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
|
||||
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
|
||||
import { d } from 'koration';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface QueuedRequest {
|
||||
execute: () => void;
|
||||
@@ -19,7 +21,14 @@ interface QueuedRequest {
|
||||
reject: (error: any) => void;
|
||||
}
|
||||
|
||||
interface RateLimitInfo {
|
||||
timestamp: number;
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||
private static readonly RATE_LIMIT_STORAGE_KEY = 'RATE_LIMIT_STATE';
|
||||
|
||||
static readonly BASE_URL_KEY = 'serverBaseURL';
|
||||
|
||||
protected abstract client: Client;
|
||||
@@ -32,6 +41,16 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||
|
||||
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 {
|
||||
BaseClient.activeTokenRefreshPromise = null;
|
||||
this.clearQueue(new Error('Client reset'));
|
||||
@@ -86,11 +105,7 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||
}
|
||||
}
|
||||
|
||||
protected constructor(
|
||||
protected handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
|
||||
) {}
|
||||
|
||||
public getBaseUrl(): string {
|
||||
private static getBaseUrl(): string {
|
||||
const { hostname, port, protocol } = window.location;
|
||||
|
||||
const defaultUrl = import.meta.env.DEV
|
||||
@@ -103,6 +118,10 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||
return SubpathUtil.getApiBaseUrl(serverBaseURL);
|
||||
}
|
||||
|
||||
public getBaseUrl(): string {
|
||||
return BaseClient.getBaseUrl();
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
protected shouldQueueRequest(operationName?: string): boolean {
|
||||
return AuthManager.shouldQueueRequests();
|
||||
@@ -147,4 +166,82 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
import { ErrorLink } from '@apollo/client/link/error';
|
||||
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 UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
|
||||
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 {
|
||||
return errors.some((graphQLError) =>
|
||||
graphQLError.message.includes('suwayomi.tachidesk.server.user.UnauthorizedException'),
|
||||
@@ -256,9 +282,21 @@ export class GraphQLClient extends BaseClient<ApolloClient, ApolloClient.Options
|
||||
|
||||
private createErrorLink() {
|
||||
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)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.isAuthError(error.errors)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export class RestClient
|
||||
const isAuthRequired = AuthManager.isAuthRequired();
|
||||
const accessToken = AuthManager.getAccessToken();
|
||||
|
||||
await this.awaitRateLimit(updatedUrl);
|
||||
|
||||
let result: Response;
|
||||
|
||||
switch (httpMethod) {
|
||||
@@ -90,6 +92,11 @@ export class RestClient
|
||||
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) {
|
||||
throw new Error(`status ${result.status}: ${result.statusText}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user