Skip to content

Commit

Permalink
feat(Rest): optional ratelimit errors (#5659)
Browse files Browse the repository at this point in the history
Co-authored-by: SpaceEEC <spaceeec@yahoo.com>
  • Loading branch information
Vendicated and SpaceEEC committed Jun 9, 2021
1 parent c2b3ed0 commit 16f261e
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 7 deletions.
6 changes: 6 additions & 0 deletions src/client/Client.js
Expand Up @@ -492,6 +492,12 @@ class Client extends BaseClient {
if (typeof options.retryLimit !== 'number' || isNaN(options.retryLimit)) {
throw new TypeError('CLIENT_INVALID_OPTION', 'retryLimit', 'a number');
}
if (
typeof options.rejectOnRateLimit !== 'undefined' &&
!(typeof options.rejectOnRateLimit === 'function' || Array.isArray(options.rejectOnRateLimit))
) {
throw new TypeError('CLIENT_INVALID_OPTION', 'rejectOnRateLimit', 'an array or a function');
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/index.js
Expand Up @@ -21,6 +21,7 @@ module.exports = {
BaseManager: require('./managers/BaseManager'),
DiscordAPIError: require('./rest/DiscordAPIError'),
HTTPError: require('./rest/HTTPError'),
RateLimitError: require('./rest/RateLimitError'),
MessageFlags: require('./util/MessageFlags'),
Intents: require('./util/Intents'),
Permissions: require('./util/Permissions'),
Expand Down
55 changes: 55 additions & 0 deletions src/rest/RateLimitError.js
@@ -0,0 +1,55 @@
'use strict';

/**
* Represents a RateLimit error from a request.
* @extends Error
*/
class RateLimitError extends Error {
constructor({ timeout, limit, method, path, route, global }) {
super(`A ${global ? 'global ' : ''}rate limit was hit on route ${route}`);

/**
* The name of the error
* @type {string}
*/
this.name = 'RateLimitError';

/**
* Time until this rate limit ends, in ms
* @type {number}
*/
this.timeout = timeout;

/**
* The HTTP method used for the request
* @type {string}
*/
this.method = method;

/**
* The path of the request relative to the HTTP endpoint
* @type {string}
*/
this.path = path;

/**
* The route of the request relative to the HTTP endpoint
* @type {string}
*/
this.route = route;

/**
* Whether this rate limit is global
* @type {boolean}
*/
this.global = global;

/**
* The maximum amount of requests of this end point
* @type {number}
*/
this.limit = limit;
}
}

module.exports = RateLimitError;
60 changes: 53 additions & 7 deletions src/rest/RequestHandler.js
Expand Up @@ -3,6 +3,7 @@
const { AsyncQueue } = require('@sapphire/async-queue');
const DiscordAPIError = require('./DiscordAPIError');
const HTTPError = require('./HTTPError');
const RateLimitError = require('./RateLimitError');
const {
Events: { RATE_LIMIT, INVALID_REQUEST_WARNING },
} = require('../util/Constants');
Expand Down Expand Up @@ -77,6 +78,30 @@ class RequestHandler {
});
}

/*
* Determines whether the request should be queued or whether a RateLimitError should be thrown
*/
async onRateLimit(request, limit, timeout, isGlobal) {
const { options } = this.manager.client;
if (!options.rejectOnRateLimit) return;

const rateLimitData = {
timeout,
limit,
method: request.method,
path: request.path,
route: request.route,
global: isGlobal,
};
const shouldThrow =
typeof options.rejectOnRateLimit === 'function'
? await options.rejectOnRateLimit(rateLimitData)
: options.rejectOnRateLimit.some(route => rateLimitData.route.startsWith(route.toLowerCase()));
if (shouldThrow) {
throw new RateLimitError(rateLimitData);
}
}

async execute(request) {
/*
* After calculations have been done, pre-emptively stop further requests
Expand All @@ -90,17 +115,10 @@ class RequestHandler {
// Set the variables based on the global rate limit
limit = this.manager.globalLimit;
timeout = this.manager.globalReset + this.manager.client.options.restTimeOffset - Date.now();
// If this is the first task to reach the global timeout, set the global delay
if (!this.manager.globalDelay) {
// The global delay function should clear the global delay state when it is resolved
this.manager.globalDelay = this.globalDelayFor(timeout);
}
delayPromise = this.manager.globalDelay;
} else {
// Set the variables based on the route-specific rate limit
limit = this.limit;
timeout = this.reset + this.manager.client.options.restTimeOffset - Date.now();
delayPromise = Util.delayFor(timeout);
}

if (this.manager.client.listenerCount(RATE_LIMIT)) {
Expand All @@ -125,6 +143,20 @@ class RequestHandler {
});
}

if (isGlobal) {
// If this is the first task to reach the global timeout, set the global delay
if (!this.manager.globalDelay) {
// The global delay function should clear the global delay state when it is resolved
this.manager.globalDelay = this.globalDelayFor(timeout);
}
delayPromise = this.manager.globalDelay;
} else {
delayPromise = Util.delayFor(timeout);
}

// Determine whether a RateLimitError should be thrown
await this.onRateLimit(request, limit, timeout, isGlobal); // eslint-disable-line no-await-in-loop

// Wait for the timeout to expire in order to avoid an actual 429
await delayPromise; // eslint-disable-line no-await-in-loop
}
Expand Down Expand Up @@ -225,6 +257,20 @@ class RequestHandler {
if (res.status === 429) {
// A ratelimit was hit - this should never happen
this.manager.client.emit('debug', `429 hit on route ${request.route}${sublimitTimeout ? ' for sublimit' : ''}`);

const isGlobal = this.globalLimited;
let limit, timeout;
if (isGlobal) {
// Set the variables based on the global rate limit
limit = this.manager.globalLimit;
timeout = this.manager.globalReset + this.manager.client.options.restTimeOffset - Date.now();
} else {
// Set the variables based on the route-specific rate limit
limit = this.limit;
timeout = this.reset + this.manager.client.options.restTimeOffset - Date.now();
}
await this.onRateLimit(request, limit, timeout, isGlobal);

// If caused by a sublimit, wait it out here so other requests on the route can be handled
if (sublimitTimeout) {
await Util.delayFor(sublimitTimeout);
Expand Down
22 changes: 22 additions & 0 deletions src/util/Constants.js
Expand Up @@ -3,6 +3,24 @@
const Package = (exports.Package = require('../../package.json'));
const { Error, RangeError } = require('../errors');

/**
* Rate limit data
* @typedef {Object} RateLimitData
* @property {number} timeout Time until this rate limit ends, in ms
* @property {number} limit The maximum amount of requests of this endpoint
* @property {string} method The http method of this request
* @property {string} path The path of the request relative to the HTTP endpoint
* @property {string} route The route of the request relative to the HTTP endpoint
* @property {boolean} global Whether this is a global rate limit
*/

/**
* Whether this rate limit should throw an Error
* @typedef {Function} RateLimitQueueFilter
* @param {RateLimitData} rateLimitData The data of this rate limit
* @returns {boolean|Promise<boolean>}
*/

/**
* Options for a client.
* @typedef {Object} ClientOptions
Expand Down Expand Up @@ -34,6 +52,10 @@ const { Error, RangeError } = require('../errors');
* (or 0 for never)
* @property {number} [restGlobalRateLimit=0] How many requests to allow sending per second (0 for unlimited, 50 for
* the standard global limit used by Discord)
* @property {string[]|RateLimitQueueFilter} [rejectOnRateLimit] Decides how rate limits and pre-emptive throttles
* should be handled. If this option is an array containing the prefix of the request route (e.g. /channels to match any
* route starting with /channels, such as /channels/222197033908436994/messages) or a function returning true, a
* {@link RateLimitError} will be thrown. Otherwise the request will be queued for later
* @property {number} [retryLimit=1] How many times to retry on 5XX errors (Infinity for indefinite amount of retries)
* @property {PresenceData} [presence={}] Presence data to use upon login
* @property {IntentsResolvable} intents Intents to enable for this connection
Expand Down
8 changes: 8 additions & 0 deletions typings/index.d.ts
Expand Up @@ -1081,6 +1081,13 @@ declare module 'discord.js' {
public requestData: HTTPErrorData;
}

// tslint:disable-next-line:no-empty-interface - Merge RateLimitData into RateLimitError to not have to type it again
interface RateLimitError extends RateLimitData {}
export class RateLimitError extends Error {
constructor(data: RateLimitData);
public name: 'RateLimitError';
}

export class Integration extends Base {
constructor(client: Client, data: unknown, guild: Guild);
public account: IntegrationAccount;
Expand Down Expand Up @@ -2875,6 +2882,7 @@ declare module 'discord.js' {
intents: BitFieldResolvable<IntentsString, number>;
ws?: WebSocketOptions;
http?: HTTPOptions;
rejectOnRateLimit?: string[] | ((data: RateLimitData) => boolean | Promise<boolean>);
}

type ClientPresenceStatus = 'online' | 'idle' | 'dnd';
Expand Down

0 comments on commit 16f261e

Please sign in to comment.