mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-20 21:43:33 +01:00
feat: rest hash and handler sweeping (#7255)
Co-authored-by: SpaceEEC <spaceeec@yahoo.com> Co-authored-by: Antonio Román <kyradiscord@gmail.com> Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
This commit is contained in:
@@ -4,6 +4,9 @@ import { InternalRequest, RequestData, RequestManager, RequestMethod, RouteLike
|
|||||||
import { DefaultRestOptions, RESTEvents } from './utils/constants';
|
import { DefaultRestOptions, RESTEvents } from './utils/constants';
|
||||||
import type { AgentOptions } from 'node:https';
|
import type { AgentOptions } from 'node:https';
|
||||||
import type { RequestInit, Response } from 'node-fetch';
|
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
|
* Options to be passed when creating the REST instance
|
||||||
@@ -74,6 +77,21 @@ export interface RESTOptions {
|
|||||||
* @default '9'
|
* @default '9'
|
||||||
*/
|
*/
|
||||||
version: string;
|
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];
|
response: [request: APIRequest, response: Response];
|
||||||
newListener: [name: string, listener: (...args: any) => void];
|
newListener: [name: string, listener: (...args: any) => void];
|
||||||
removeListener: [name: string, listener: (...args: any) => void];
|
removeListener: [name: string, listener: (...args: any) => void];
|
||||||
|
hashSweep: [sweptHashes: Collection<string, HashData>];
|
||||||
|
handlerSweep: [sweptHandlers: Collection<string, IHandler>];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface REST {
|
export interface REST {
|
||||||
@@ -197,7 +217,8 @@ export class REST extends EventEmitter {
|
|||||||
this.requestManager = new RequestManager(options)
|
this.requestManager = new RequestManager(options)
|
||||||
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
|
.on(RESTEvents.Debug, this.emit.bind(this, RESTEvents.Debug))
|
||||||
.on(RESTEvents.RateLimited, this.emit.bind(this, RESTEvents.RateLimited))
|
.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) => {
|
this.on('newListener', (name, listener) => {
|
||||||
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener);
|
if (name === RESTEvents.Request || name === RESTEvents.Response) this.requestManager.on(name, listener);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { RequestInit, BodyInit } from 'node-fetch';
|
|||||||
import type { IHandler } from './handlers/IHandler';
|
import type { IHandler } from './handlers/IHandler';
|
||||||
import { SequentialHandler } from './handlers/SequentialHandler';
|
import { SequentialHandler } from './handlers/SequentialHandler';
|
||||||
import type { RESTOptions, RestEvents } from './REST';
|
import type { RESTOptions, RestEvents } from './REST';
|
||||||
import { DefaultRestOptions, DefaultUserAgent } from './utils/constants';
|
import { DefaultRestOptions, DefaultUserAgent, RESTEvents } from './utils/constants';
|
||||||
|
|
||||||
let agent: Agent | null = null;
|
let agent: Agent | null = null;
|
||||||
|
|
||||||
@@ -125,6 +125,16 @@ export interface RouteData {
|
|||||||
original: RouteLike;
|
original: RouteLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a hash and its associated fields
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export interface HashData {
|
||||||
|
value: string;
|
||||||
|
lastAccess: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RequestManager {
|
export interface RequestManager {
|
||||||
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
|
on: (<K extends keyof RestEvents>(event: K, listener: (...args: RestEvents[K]) => void) => this) &
|
||||||
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
(<S extends string | symbol>(event: Exclude<S, keyof RestEvents>, listener: (...args: any[]) => void) => this);
|
||||||
@@ -164,7 +174,7 @@ export class RequestManager extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* API bucket hashes that are cached from provided routes
|
* API bucket hashes that are cached from provided routes
|
||||||
*/
|
*/
|
||||||
public readonly hashes = new Collection<string, string>();
|
public readonly hashes = new Collection<string, HashData>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request handlers created from the bucket hash and the major parameters
|
* 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
|
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
|
||||||
#token: string | null = null;
|
#token: string | null = null;
|
||||||
|
|
||||||
|
private hashTimer!: NodeJS.Timer;
|
||||||
|
private handlerTimer!: NodeJS.Timer;
|
||||||
|
|
||||||
public readonly options: RESTOptions;
|
public readonly options: RESTOptions;
|
||||||
|
|
||||||
public constructor(options: Partial<RESTOptions>) {
|
public constructor(options: Partial<RESTOptions>) {
|
||||||
@@ -181,6 +194,71 @@ export class RequestManager extends EventEmitter {
|
|||||||
this.options = { ...DefaultRestOptions, ...options };
|
this.options = { ...DefaultRestOptions, ...options };
|
||||||
this.options.offset = Math.max(0, this.options.offset);
|
this.options.offset = Math.max(0, this.options.offset);
|
||||||
this.globalRemaining = this.options.globalRequestsPerSecond;
|
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<string, HashData>();
|
||||||
|
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<string, IHandler>();
|
||||||
|
|
||||||
|
// 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
|
// Generalize the endpoint to its route data
|
||||||
const routeId = RequestManager.generateRouteData(request.fullRoute, request.method);
|
const routeId = RequestManager.generateRouteData(request.fullRoute, request.method);
|
||||||
// Get the bucket hash for the generic route, or point to a global route otherwise
|
// Get the bucket hash for the generic route, or point to a global route otherwise
|
||||||
const hash =
|
const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? {
|
||||||
this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? `Global(${request.method}:${routeId.bucketRoute})`;
|
value: `Global(${request.method}:${routeId.bucketRoute})`,
|
||||||
|
lastAccess: -1,
|
||||||
|
};
|
||||||
|
|
||||||
// Get the request handler for the obtained hash, with its major parameter
|
// Get the request handler for the obtained hash, with its major parameter
|
||||||
const handler =
|
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
|
// Resolve the request into usable fetch/node-fetch options
|
||||||
const { url, fetchOptions } = this.resolveRequest(request);
|
const { url, fetchOptions } = this.resolveRequest(request);
|
||||||
@@ -323,6 +404,20 @@ export class RequestManager extends EventEmitter {
|
|||||||
return { url, fetchOptions };
|
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
|
* Generates route data for an endpoint:method
|
||||||
* @param endpoint The raw endpoint to generalize
|
* @param endpoint The raw endpoint to generalize
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export interface IHandler {
|
|||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
bodyData: Pick<InternalRequest, 'files' | 'body'>,
|
bodyData: Pick<InternalRequest, 'files' | 'body'>,
|
||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
readonly inactive: boolean;
|
||||||
|
readonly id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,7 +355,16 @@ export class SequentialHandler {
|
|||||||
// Let library users know when rate limit buckets have been updated
|
// 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.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n'));
|
||||||
// This queue will eventually be eliminated via attrition
|
// 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
|
// Handle retryAfter, which means we have actually hit a rate limit
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const DefaultRestOptions: Required<RESTOptions> = {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
userAgentAppendix: `Node.js ${process.version}`,
|
userAgentAppendix: `Node.js ${process.version}`,
|
||||||
version: APIVersion,
|
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',
|
RateLimited = 'rateLimited',
|
||||||
Request = 'request',
|
Request = 'request',
|
||||||
Response = 'response',
|
Response = 'response',
|
||||||
|
HashSweep = 'hashSweep',
|
||||||
|
HandlerSweep = 'handlerSweep',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;
|
export const ALLOWED_EXTENSIONS = ['webp', 'png', 'jpg', 'jpeg', 'gif'] as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user