diff --git a/src/lib/requests/client/BaseClient.ts b/src/lib/requests/client/BaseClient.ts index fb3b757d..0b23ae69 100644 --- a/src/lib/requests/client/BaseClient.ts +++ b/src/lib/requests/client/BaseClient.ts @@ -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 { + 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 { protected requestQueue: QueuedRequest[] = []; + private static rateLimitState = new Map(); + + protected constructor( + protected handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse, + ) { + 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 { } } - protected constructor( - protected handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse, - ) {} - - 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 { 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 { } public abstract updateConfig(config: Partial): 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 { + if (!this.isRateLimited(url)) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, this.getRateLimitTimeout(url)); + }); + } } diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts index 92d9ac14..91dacb3c 100644 --- a/src/lib/requests/client/GraphQLClient.ts +++ b/src/lib/requests/client/GraphQLClient.ts @@ -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 + 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 { + 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; } diff --git a/src/lib/requests/client/RestClient.ts b/src/lib/requests/client/RestClient.ts index 35f1e10c..849cc20d 100644 --- a/src/lib/requests/client/RestClient.ts +++ b/src/lib/requests/client/RestClient.ts @@ -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}`); }