diff --git a/package-lock.json b/package-lock.json index 1bf66eb..2b7491f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "axios": "^0.27.2", + "axios-rate-limit": "^1.3.0", "tslib": "^2.4.0" }, "devDependencies": { @@ -1952,6 +1953,14 @@ "form-data": "^4.0.0" } }, + "node_modules/axios-rate-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.3.0.tgz", + "integrity": "sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==", + "peerDependencies": { + "axios": "*" + } + }, "node_modules/babel-jest": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.3.tgz", @@ -7807,6 +7816,12 @@ "form-data": "^4.0.0" } }, + "axios-rate-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.3.0.tgz", + "integrity": "sha512-cKR5wTbU/CeeyF1xVl5hl6FlYsmzDVqxlN4rGtfO5x7J83UxKDckudsW0yW21/ZJRcO0Qrfm3fUFbhEbWTLayw==", + "requires": {} + }, "babel-jest": { "version": "28.1.3", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-28.1.3.tgz", diff --git a/package.json b/package.json index 64ffb65..c4e1d57 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "axios": "^0.27.2", + "axios-rate-limit": "^1.3.0", "tslib": "^2.4.0" } } diff --git a/resources/structs.ts b/resources/structs.ts index fc4481b..5527e47 100644 --- a/resources/structs.ts +++ b/resources/structs.ts @@ -11,9 +11,19 @@ export interface ClientConfig { apiKey?: string; /** - * The default language for all endpoints. Defaults to 'en' + * The default language for all endpoints. Defaults to `en` */ language: Language; + + /** + * Extra timeout for stats ratelimits. Defaults to `0`. + * + * Normally the client will send 3 stats requests per 1100 milliseconds. + * Setting this option to `100` increases the rate to 3 per 1200 milliseconds. + * + * You should increase this option if you're getting 429 responses + */ + rateLimitExtraTimeout: number; } export interface ClientOptions extends Partial {} diff --git a/src/client/Client.ts b/src/client/Client.ts index b2c0b7a..2b12a44 100644 --- a/src/client/Client.ts +++ b/src/client/Client.ts @@ -21,6 +21,7 @@ class Client { constructor(config?: ClientOptions) { this.config = { language: Language.English, + rateLimitExtraTimeout: 0, ...config, }; @@ -186,7 +187,7 @@ class Client { * @param options Options for this endpoint */ public async brStats(options: BRStatsRequestParams): Promise { - return this.http.fetch('/v2/stats/br/v2', options); + return this.http.fetchStats('/v2/stats/br/v2', options); } /** @@ -195,7 +196,7 @@ class Client { * @param options Options for this endpoint */ public async brStatsByID(options: { id: string } & BRStatsByAccountIDRequestParams): Promise { - return this.http.fetch(`/v2/stats/br/v2/${options.id}`, options); + return this.http.fetchStats(`/v2/stats/br/v2/${options.id}`, options); } } diff --git a/src/http/HTTP.ts b/src/http/HTTP.ts index 49d3582..8cf4c6f 100644 --- a/src/http/HTTP.ts +++ b/src/http/HTTP.ts @@ -1,16 +1,18 @@ /* eslint-disable no-restricted-syntax */ import axios, { AxiosError, AxiosInstance } from 'axios'; -import { URLSearchParams } from 'url'; +import rateLimit, { RateLimitedAxiosInstance } from 'axios-rate-limit'; import { version } from '../../package.json'; import Client from '../client/Client'; import FortniteAPIError from '../exceptions/FortniteAPIError'; import InvalidAPIKeyError from '../exceptions/InvalidAPIKeyError'; import MissingAPIKeyError from '../exceptions/MissingAPIKeyError'; +import { serializeParams } from '../util/util'; import { FortniteAPIResponseData } from './httpStructs'; class HTTP { public client: Client; public axios: AxiosInstance; + public statsAxios: RateLimitedAxiosInstance; constructor(client: Client) { this.client = client; @@ -26,6 +28,11 @@ class HTTP { } : {}, }, }); + + this.statsAxios = rateLimit(this.axios, { + maxRequests: 3, + perMilliseconds: 1100 + this.client.config.rateLimitExtraTimeout, + }); } public async fetch(url: string, params?: any): Promise { @@ -33,19 +40,33 @@ class HTTP { const response = await this.axios({ url, params, - paramsSerializer: (p) => { - const searchParams = new URLSearchParams(); + paramsSerializer: serializeParams, + }); - for (const [key, value] of Object.entries(p)) { - if (Array.isArray(value)) { - for (const singleValue of value) searchParams.append(key, singleValue); - } else { - searchParams.append(key, (value as any)); - } + return response.data; + } catch (e) { + if (e instanceof AxiosError && e.response?.data?.error) { + if (e.response.status === 401) { + if (this.client.config.apiKey) { + throw new InvalidAPIKeyError(url); + } else { + throw new MissingAPIKeyError(url); } + } + + throw new FortniteAPIError(e.response.data, e.config, e.response.status); + } - return searchParams.toString(); - }, + throw e; + } + } + + public async fetchStats(url: string, params?: any): Promise { + try { + const response = await this.statsAxios({ + url, + params, + paramsSerializer: serializeParams, }); return response.data; diff --git a/src/util/util.ts b/src/util/util.ts new file mode 100644 index 0000000..dac88c6 --- /dev/null +++ b/src/util/util.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable import/prefer-default-export */ +import { URLSearchParams } from 'url'; + +export const serializeParams = (params: any) => { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const singleValue of value) searchParams.append(key, singleValue); + } else { + searchParams.append(key, (value as any)); + } + } + + return searchParams.toString(); +};