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 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user