diff --git a/packages/rest/src/lib/REST.ts b/packages/rest/src/lib/REST.ts index 24ea57cb4..63fa44ecb 100644 --- a/packages/rest/src/lib/REST.ts +++ b/packages/rest/src/lib/REST.ts @@ -4,6 +4,9 @@ import { InternalRequest, RequestData, RequestManager, RequestMethod, RouteLike import { DefaultRestOptions, RESTEvents } from './utils/constants'; import type { AgentOptions } from 'node:https'; import type { RequestInit, Response } from 'node-fetch'; +import type { HashData } from './RequestManager'; +import type Collection from '@discordjs/collection'; +import type { IHandler } from './handlers/IHandler'; /** * Options to be passed when creating the REST instance @@ -74,6 +77,21 @@ export interface RESTOptions { * @default '9' */ version: string; + /** + * The amount of time in milliseconds that passes between each hash sweep. (defaults to 4h) + * @default 14_400_000 + */ + hashSweepInterval: number; + /** + * The maximum amount of time a hash can exist in milliseconds without being hit with a request (defaults to 24h) + * @default 86_400_000 + */ + hashLifetime: number; + /** + * The amount of time in milliseconds that passes between each hash sweep. (defaults to 1h) + * @default 3_600_000 + */ + handlerSweepInterval: number; } /** @@ -168,6 +186,8 @@ export interface RestEvents { response: [request: APIRequest, response: Response]; newListener: [name: string, listener: (...args: any) => void]; removeListener: [name: string, listener: (...args: any) => void]; + hashSweep: [sweptHashes: Collection]; + handlerSweep: [sweptHandlers: Collection]; } export interface REST { @@ -197,7 +217,8 @@ export class REST extends EventEmitter { this.requestManager = new RequestManager(options) .on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug)) .on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited)) - .on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning)); + .on(RESTEvents.InvalidRequestWarning, this.emit.bind(this, RESTEvents.InvalidRequestWarning)) + .on(RESTEvents.HashSweep, this.emit.bind(this, RESTEvents.HashSweep)); this.on('newListener', (name, listener) => { if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener); diff --git a/packages/rest/src/lib/RequestManager.ts b/packages/rest/src/lib/RequestManager.ts index d2bd2a657..afb84d35e 100644 --- a/packages/rest/src/lib/RequestManager.ts +++ b/packages/rest/src/lib/RequestManager.ts @@ -7,7 +7,7 @@ import type { RequestInit, BodyInit } from 'node-fetch'; import type { IHandler } from './handlers/IHandler'; import { SequentialHandler } from './handlers/SequentialHandler'; import type { RESTOptions, RestEvents } from './REST'; -import { DefaultRestOptions, DefaultUserAgent } from './utils/constants'; +import { DefaultRestOptions, DefaultUserAgent, RESTEvents } from './utils/constants'; let agent: Agent | null = null; @@ -125,6 +125,16 @@ export interface RouteData { original: RouteLike; } +/** + * Represents a hash and its associated fields + * + * @internal + */ +export interface HashData { + value: string; + lastAccess: number; +} + export interface RequestManager { on: ((event: K, listener: (...args: RestEvents[K]) => void) => this) & ((event: Exclude, listener: (...args: any[]) => void) => this); @@ -164,7 +174,7 @@ export class RequestManager extends EventEmitter { /** * API bucket hashes that are cached from provided routes */ - public readonly hashes = new Collection(); + public readonly hashes = new Collection(); /** * Request handlers created from the bucket hash and the major parameters @@ -174,6 +184,9 @@ export class RequestManager extends EventEmitter { // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility #token: string | null = null; + private hashTimer!: NodeJS.Timer; + private handlerTimer!: NodeJS.Timer; + public readonly options: RESTOptions; public constructor(options: Partial) { @@ -181,6 +194,71 @@ export class RequestManager extends EventEmitter { this.options = { ...DefaultRestOptions, ...options }; this.options.offset = Math.max(0, this.options.offset); this.globalRemaining = this.options.globalRequestsPerSecond; + + // Start sweepers + this.setupSweepers(); + } + + private setupSweepers() { + const validateMaxInterval = (interval: number) => { + if (interval > 14_400_000) { + throw new Error('Cannot set an interval greater than 4 hours'); + } + }; + + if (this.options.hashSweepInterval !== 0 && this.options.hashSweepInterval !== Infinity) { + validateMaxInterval(this.options.hashSweepInterval); + this.hashTimer = setInterval(() => { + const sweptHashes = new Collection(); + const currentDate = Date.now(); + + // Begin sweeping hash based on lifetimes + this.hashes.sweep((v, k) => { + // `-1` indicates a global hash + if (v.lastAccess === -1) return false; + + // Check if lifetime has been exceeded + const shouldSweep = Math.floor(currentDate - v.lastAccess) > this.options.hashLifetime; + + // Add hash to collection of swept hashes + if (shouldSweep) { + // Add to swept hashes + sweptHashes.set(k, v); + } + + // Emit debug information + this.emit(RESTEvents.Debug, `Hash ${v.value} for ${k} swept due to lifetime being exceeded`); + + return shouldSweep; + }); + + // Fire event + this.emit(RESTEvents.HashSweep, sweptHashes); + }, this.options.hashSweepInterval).unref(); + } + + if (this.options.handlerSweepInterval !== 0 && this.options.handlerSweepInterval !== Infinity) { + validateMaxInterval(this.options.handlerSweepInterval); + this.handlerTimer = setInterval(() => { + const sweptHandlers = new Collection(); + + // Begin sweeping handlers based on activity + this.handlers.sweep((v, k) => { + const { inactive } = v; + + // Collect inactive handlers + if (inactive) { + sweptHandlers.set(k, v); + } + + this.emit(RESTEvents.Debug, `Handler ${v.id} for ${k} swept due to being inactive`); + return inactive; + }); + + // Fire event + this.emit(RESTEvents.HandlerSweep, sweptHandlers); + }, this.options.handlerSweepInterval).unref(); + } } /** @@ -201,12 +279,15 @@ export class RequestManager extends EventEmitter { // Generalize the endpoint to its route data const routeId = RequestManager.generateRouteData(request.fullRoute, request.method); // Get the bucket hash for the generic route, or point to a global route otherwise - const hash = - this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? `Global(${request.method}:${routeId.bucketRoute})`; + const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? { + value: `Global(${request.method}:${routeId.bucketRoute})`, + lastAccess: -1, + }; // Get the request handler for the obtained hash, with its major parameter const handler = - this.handlers.get(`${hash}:${routeId.majorParameter}`) ?? this.createHandler(hash, routeId.majorParameter); + this.handlers.get(`${hash.value}:${routeId.majorParameter}`) ?? + this.createHandler(hash.value, routeId.majorParameter); // Resolve the request into usable fetch/node-fetch options const { url, fetchOptions } = this.resolveRequest(request); @@ -323,6 +404,20 @@ export class RequestManager extends EventEmitter { return { url, fetchOptions }; } + /** + * Stops the hash sweeping interval + */ + public clearHashSweeper() { + clearInterval(this.hashTimer); + } + + /** + * Stops the request handler sweeping interval + */ + public clearHandlerSweeper() { + clearInterval(this.handlerTimer); + } + /** * Generates route data for an endpoint:method * @param endpoint The raw endpoint to generalize diff --git a/packages/rest/src/lib/handlers/IHandler.ts b/packages/rest/src/lib/handlers/IHandler.ts index fc358047b..59376618a 100644 --- a/packages/rest/src/lib/handlers/IHandler.ts +++ b/packages/rest/src/lib/handlers/IHandler.ts @@ -8,4 +8,6 @@ export interface IHandler { options: RequestInit, bodyData: Pick, ) => Promise; + readonly inactive: boolean; + readonly id: string; } diff --git a/packages/rest/src/lib/handlers/SequentialHandler.ts b/packages/rest/src/lib/handlers/SequentialHandler.ts index a718c9e15..994f85217 100644 --- a/packages/rest/src/lib/handlers/SequentialHandler.ts +++ b/packages/rest/src/lib/handlers/SequentialHandler.ts @@ -355,7 +355,16 @@ export class SequentialHandler { // Let library users know when rate limit buckets have been updated this.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n')); // This queue will eventually be eliminated via attrition - this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, hash); + this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, { value: hash, lastAccess: Date.now() }); + } else if (hash) { + // Handle the case where hash value doesn't change + // Fetch the hash data from the manager + const hashData = this.manager.hashes.get(`${method}:${routeId.bucketRoute}`); + + // When fetched, update the last access of the hash + if (hashData) { + hashData.lastAccess = Date.now(); + } } // Handle retryAfter, which means we have actually hit a rate limit diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index d026493b3..bc8645350 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -19,6 +19,9 @@ export const DefaultRestOptions: Required = { timeout: 15_000, userAgentAppendix: `Node.js ${process.version}`, version: APIVersion, + hashSweepInterval: 14_400_000, // 4 Hours + hashLifetime: 86_400_000, // 24 Hours + handlerSweepInterval: 3_600_000, // 1 Hour }; /** @@ -30,6 +33,8 @@ export const enum RESTEvents { RateLimited = 'rateLimited', Request = 'request', Response = 'response', + HashSweep = 'hashSweep', + HandlerSweep = 'handlerSweep', } export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;