Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Rest): optional ratelimit errors #5659

Merged
merged 13 commits into from Jun 9, 2021
Merged
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('./AsyncQueue');
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 {
});
}

// Determine whether a RateLimitError should be thrown
await this.onRateLimit(request, limit, timeout, isGlobal); // eslint-disable-line no-await-in-loop
iCrawl marked this conversation as resolved.
Show resolved Hide resolved

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);
}

// 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
12 changes: 9 additions & 3 deletions typings/index.d.ts
Expand Up @@ -461,9 +461,7 @@ declare module 'discord.js' {
Endpoints: {
botGateway: string;
invite: (root: string, code: string) => string;
CDN: (
root: string,
) => {
CDN: (root: string) => {
Asset: (name: string) => string;
DefaultAvatar: (id: string | number) => string;
Emoji: (emojiID: string, format: 'png' | 'gif') => string;
Expand Down Expand Up @@ -1022,6 +1020,13 @@ declare module 'discord.js' {
public path: string;
}

// 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: object, guild: Guild);
public account: IntegrationAccount;
Expand Down Expand Up @@ -2659,6 +2664,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