Initial commit
This commit is contained in:
80
src/rest/APIRequest.js
Normal file
80
src/rest/APIRequest.js
Normal file
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const https = require('node:https');
|
||||
const { setTimeout } = require('node:timers');
|
||||
const FormData = require('form-data');
|
||||
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||||
const { UserAgent } = require('../util/Constants');
|
||||
|
||||
let agent = null;
|
||||
|
||||
class APIRequest {
|
||||
constructor(rest, method, path, options) {
|
||||
this.rest = rest;
|
||||
this.client = rest.client;
|
||||
this.method = method;
|
||||
this.route = options.route;
|
||||
this.options = options;
|
||||
this.retries = 0;
|
||||
|
||||
let queryString = '';
|
||||
if (options.query) {
|
||||
const query = Object.entries(options.query)
|
||||
.filter(([, value]) => value !== null && typeof value !== 'undefined')
|
||||
.flatMap(([key, value]) => (Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]]));
|
||||
queryString = new URLSearchParams(query).toString();
|
||||
}
|
||||
this.path = `${path}${queryString && `?${queryString}`}`;
|
||||
}
|
||||
|
||||
async make() {
|
||||
agent ??= new https.Agent({ ...this.client.options.http.agent, keepAlive: true });
|
||||
|
||||
const API =
|
||||
this.options.versioned === false
|
||||
? this.client.options.http.api
|
||||
: `${this.client.options.http.api}/v${this.client.options.http.version}`;
|
||||
const url = API + this.path;
|
||||
|
||||
let headers = {
|
||||
...this.client.options.http.headers,
|
||||
'User-Agent': UserAgent,
|
||||
};
|
||||
|
||||
if (this.options.auth !== false) headers.Authorization = this.rest.getAuth();
|
||||
if (this.options.reason) headers['X-Audit-Log-Reason'] = encodeURIComponent(this.options.reason);
|
||||
if (this.options.headers) headers = Object.assign(headers, this.options.headers);
|
||||
|
||||
let body;
|
||||
if (this.options.files?.length) {
|
||||
body = new FormData();
|
||||
for (const [index, file] of this.options.files.entries()) {
|
||||
if (file?.file) body.append(file.key ?? `files[${index}]`, file.file, file.name);
|
||||
}
|
||||
if (typeof this.options.data !== 'undefined') {
|
||||
if (this.options.dontUsePayloadJSON) {
|
||||
for (const [key, value] of Object.entries(this.options.data)) body.append(key, value);
|
||||
} else {
|
||||
body.append('payload_json', JSON.stringify(this.options.data));
|
||||
}
|
||||
}
|
||||
headers = Object.assign(headers, body.getHeaders());
|
||||
// eslint-disable-next-line eqeqeq
|
||||
} else if (this.options.data != null) {
|
||||
body = JSON.stringify(this.options.data);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.client.options.restRequestTimeout).unref();
|
||||
return fetch(url, {
|
||||
method: this.method,
|
||||
headers,
|
||||
agent,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = APIRequest;
|
53
src/rest/APIRouter.js
Normal file
53
src/rest/APIRouter.js
Normal file
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
const noop = () => {}; // eslint-disable-line no-empty-function
|
||||
const methods = ['get', 'post', 'delete', 'patch', 'put'];
|
||||
const reflectors = [
|
||||
'toString',
|
||||
'valueOf',
|
||||
'inspect',
|
||||
'constructor',
|
||||
Symbol.toPrimitive,
|
||||
Symbol.for('nodejs.util.inspect.custom'),
|
||||
];
|
||||
|
||||
function buildRoute(manager) {
|
||||
const route = [''];
|
||||
const handler = {
|
||||
get(target, name) {
|
||||
if (reflectors.includes(name)) return () => route.join('/');
|
||||
if (methods.includes(name)) {
|
||||
const routeBucket = [];
|
||||
for (let i = 0; i < route.length; i++) {
|
||||
// Reactions routes and sub-routes all share the same bucket
|
||||
if (route[i - 1] === 'reactions') break;
|
||||
// Literal ids should only be taken account if they are the Major id (the Channel/Guild id)
|
||||
if (/\d{16,19}/g.test(route[i]) && !/channels|guilds/.test(route[i - 1])) routeBucket.push(':id');
|
||||
// All other parts of the route should be considered as part of the bucket identifier
|
||||
else routeBucket.push(route[i]);
|
||||
}
|
||||
return options =>
|
||||
manager.request(
|
||||
name,
|
||||
route.join('/'),
|
||||
Object.assign(
|
||||
{
|
||||
versioned: manager.versioned,
|
||||
route: routeBucket.join('/'),
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
}
|
||||
route.push(name);
|
||||
return new Proxy(noop, handler);
|
||||
},
|
||||
apply(target, _, args) {
|
||||
route.push(...args.filter(x => x != null)); // eslint-disable-line eqeqeq
|
||||
return new Proxy(noop, handler);
|
||||
},
|
||||
};
|
||||
return new Proxy(noop, handler);
|
||||
}
|
||||
|
||||
module.exports = buildRoute;
|
82
src/rest/DiscordAPIError.js
Normal file
82
src/rest/DiscordAPIError.js
Normal file
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Represents an error from the Discord API.
|
||||
* @extends Error
|
||||
*/
|
||||
class DiscordAPIError extends Error {
|
||||
constructor(error, status, request) {
|
||||
super();
|
||||
const flattened = this.constructor.flattenErrors(error.errors ?? error).join('\n');
|
||||
this.name = 'DiscordAPIError';
|
||||
this.message = error.message && flattened ? `${error.message}\n${flattened}` : error.message ?? flattened;
|
||||
|
||||
/**
|
||||
* The HTTP method used for the request
|
||||
* @type {string}
|
||||
*/
|
||||
this.method = request.method;
|
||||
|
||||
/**
|
||||
* The path of the request relative to the HTTP endpoint
|
||||
* @type {string}
|
||||
*/
|
||||
this.path = request.path;
|
||||
|
||||
/**
|
||||
* HTTP error code returned by Discord
|
||||
* @type {number}
|
||||
*/
|
||||
this.code = error.code;
|
||||
|
||||
/**
|
||||
* The HTTP status code
|
||||
* @type {number}
|
||||
*/
|
||||
this.httpStatus = status;
|
||||
|
||||
/**
|
||||
* The data associated with the request that caused this error
|
||||
* @type {HTTPErrorData}
|
||||
*/
|
||||
this.requestData = {
|
||||
json: request.options.data,
|
||||
files: request.options.files ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens an errors object returned from the API into an array.
|
||||
* @param {APIError} obj Discord errors object
|
||||
* @param {string} [key] Used internally to determine key names of nested fields
|
||||
* @returns {string[]}
|
||||
* @private
|
||||
*/
|
||||
static flattenErrors(obj, key = '') {
|
||||
let messages = [];
|
||||
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (k === 'message') continue;
|
||||
const newKey = key ? (isNaN(k) ? `${key}.${k}` : `${key}[${k}]`) : k;
|
||||
|
||||
if (v._errors) {
|
||||
messages.push(`${newKey}: ${v._errors.map(e => e.message).join(' ')}`);
|
||||
} else if (v.code ?? v.message) {
|
||||
messages.push(`${v.code ? `${v.code}: ` : ''}${v.message}`.trim());
|
||||
} else if (typeof v === 'string') {
|
||||
messages.push(v);
|
||||
} else {
|
||||
messages = messages.concat(this.flattenErrors(v, newKey));
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DiscordAPIError;
|
||||
|
||||
/**
|
||||
* @external APIError
|
||||
* @see {@link https://discord.com/developers/docs/reference#error-messages}
|
||||
*/
|
61
src/rest/HTTPError.js
Normal file
61
src/rest/HTTPError.js
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Represents an HTTP error from a request.
|
||||
* @extends Error
|
||||
*/
|
||||
class HTTPError extends Error {
|
||||
constructor(message, name, code, request) {
|
||||
super(message);
|
||||
|
||||
/**
|
||||
* The name of the error
|
||||
* @type {string}
|
||||
*/
|
||||
this.name = name;
|
||||
|
||||
/**
|
||||
* HTTP error code returned from the request
|
||||
* @type {number}
|
||||
*/
|
||||
this.code = code ?? 500;
|
||||
|
||||
/**
|
||||
* The HTTP method used for the request
|
||||
* @type {string}
|
||||
*/
|
||||
this.method = request.method;
|
||||
|
||||
/**
|
||||
* The path of the request relative to the HTTP endpoint
|
||||
* @type {string}
|
||||
*/
|
||||
this.path = request.path;
|
||||
|
||||
/**
|
||||
* The HTTP data that was sent to Discord
|
||||
* @typedef {Object} HTTPErrorData
|
||||
* @property {*} json The JSON data that was sent
|
||||
* @property {HTTPAttachmentData[]} files The files that were sent with this request, if any
|
||||
*/
|
||||
|
||||
/**
|
||||
* The attachment data that is sent to Discord
|
||||
* @typedef {Object} HTTPAttachmentData
|
||||
* @property {string|Buffer|Stream} attachment The source of this attachment data
|
||||
* @property {string} name The file name
|
||||
* @property {Buffer|Stream} file The file buffer
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data associated with the request that caused this error
|
||||
* @type {HTTPErrorData}
|
||||
*/
|
||||
this.requestData = {
|
||||
json: request.options.data,
|
||||
files: request.options.files ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HTTPError;
|
63
src/rest/RESTManager.js
Normal file
63
src/rest/RESTManager.js
Normal file
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const { setInterval } = require('node:timers');
|
||||
const { Collection } = require('@discordjs/collection');
|
||||
const APIRequest = require('./APIRequest');
|
||||
const routeBuilder = require('./APIRouter');
|
||||
const RequestHandler = require('./RequestHandler');
|
||||
const { Error } = require('../errors');
|
||||
const { Endpoints } = require('../util/Constants');
|
||||
|
||||
class RESTManager {
|
||||
constructor(client) {
|
||||
this.client = client;
|
||||
this.handlers = new Collection();
|
||||
this.versioned = true;
|
||||
this.globalLimit = client.options.restGlobalRateLimit > 0 ? client.options.restGlobalRateLimit : Infinity;
|
||||
this.globalRemaining = this.globalLimit;
|
||||
this.globalReset = null;
|
||||
this.globalDelay = null;
|
||||
if (client.options.restSweepInterval > 0) {
|
||||
this.sweepInterval = setInterval(() => {
|
||||
this.handlers.sweep(handler => handler._inactive);
|
||||
}, client.options.restSweepInterval * 1_000).unref();
|
||||
}
|
||||
}
|
||||
|
||||
get api() {
|
||||
return routeBuilder(this);
|
||||
}
|
||||
|
||||
getAuth() {
|
||||
const token = this.client.token ?? this.client.accessToken;
|
||||
if (token && !this.client.bot) return `${token}`;
|
||||
else if(token && this.client.bot) return `Bot ${token}`;
|
||||
throw new Error('TOKEN_MISSING');
|
||||
}
|
||||
|
||||
get cdn() {
|
||||
return Endpoints.CDN(this.client.options.http.cdn);
|
||||
}
|
||||
|
||||
request(method, url, options = {}) {
|
||||
const apiRequest = new APIRequest(this, method, url, options);
|
||||
let handler = this.handlers.get(apiRequest.route);
|
||||
|
||||
if (!handler) {
|
||||
handler = new RequestHandler(this);
|
||||
this.handlers.set(apiRequest.route, handler);
|
||||
}
|
||||
|
||||
return handler.push(apiRequest);
|
||||
}
|
||||
|
||||
get endpoint() {
|
||||
return this.client.options.http.api;
|
||||
}
|
||||
|
||||
set endpoint(endpoint) {
|
||||
this.client.options.http.api = endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RESTManager;
|
55
src/rest/RateLimitError.js
Normal file
55
src/rest/RateLimitError.js
Normal file
@@ -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 endpoint
|
||||
* @type {number}
|
||||
*/
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RateLimitError;
|
379
src/rest/RequestHandler.js
Normal file
379
src/rest/RequestHandler.js
Normal file
@@ -0,0 +1,379 @@
|
||||
'use strict';
|
||||
|
||||
const { setTimeout } = require('node:timers');
|
||||
const { setTimeout: sleep } = require('node:timers/promises');
|
||||
const { AsyncQueue } = require('@sapphire/async-queue');
|
||||
const DiscordAPIError = require('./DiscordAPIError');
|
||||
const HTTPError = require('./HTTPError');
|
||||
const RateLimitError = require('./RateLimitError');
|
||||
const {
|
||||
Events: { DEBUG, RATE_LIMIT, INVALID_REQUEST_WARNING, API_RESPONSE, API_REQUEST },
|
||||
} = require('../util/Constants');
|
||||
|
||||
function parseResponse(res) {
|
||||
if (res.headers.get('content-type').startsWith('application/json')) return res.json();
|
||||
return res.buffer();
|
||||
}
|
||||
|
||||
function getAPIOffset(serverDate) {
|
||||
return new Date(serverDate).getTime() - Date.now();
|
||||
}
|
||||
|
||||
function calculateReset(reset, resetAfter, serverDate) {
|
||||
// Use direct reset time when available, server date becomes irrelevant in this case
|
||||
if (resetAfter) {
|
||||
return Date.now() + Number(resetAfter) * 1_000;
|
||||
}
|
||||
return new Date(Number(reset) * 1_000).getTime() - getAPIOffset(serverDate);
|
||||
}
|
||||
|
||||
/* Invalid request limiting is done on a per-IP basis, not a per-token basis.
|
||||
* The best we can do is track invalid counts process-wide (on the theory that
|
||||
* users could have multiple bots run from one process) rather than per-bot.
|
||||
* Therefore, store these at file scope here rather than in the client's
|
||||
* RESTManager object.
|
||||
*/
|
||||
let invalidCount = 0;
|
||||
let invalidCountResetTime = null;
|
||||
|
||||
class RequestHandler {
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
this.queue = new AsyncQueue();
|
||||
this.reset = -1;
|
||||
this.remaining = -1;
|
||||
this.limit = -1;
|
||||
}
|
||||
|
||||
async push(request) {
|
||||
await this.queue.wait();
|
||||
try {
|
||||
return await this.execute(request);
|
||||
} finally {
|
||||
this.queue.shift();
|
||||
}
|
||||
}
|
||||
|
||||
get globalLimited() {
|
||||
return this.manager.globalRemaining <= 0 && Date.now() < this.manager.globalReset;
|
||||
}
|
||||
|
||||
get localLimited() {
|
||||
return this.remaining <= 0 && Date.now() < this.reset;
|
||||
}
|
||||
|
||||
get limited() {
|
||||
return this.globalLimited || this.localLimited;
|
||||
}
|
||||
|
||||
get _inactive() {
|
||||
return this.queue.remaining === 0 && !this.limited;
|
||||
}
|
||||
|
||||
globalDelayFor(ms) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
this.manager.globalDelay = null;
|
||||
resolve();
|
||||
}, ms).unref();
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
* Potentially loop until this task can run if e.g. the global rate limit is hit twice
|
||||
*/
|
||||
while (this.limited) {
|
||||
const isGlobal = this.globalLimited;
|
||||
let limit, timeout, delayPromise;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (this.manager.client.listenerCount(RATE_LIMIT)) {
|
||||
/**
|
||||
* Emitted when the client hits a rate limit while making a request
|
||||
* @event BaseClient#rateLimit
|
||||
* @param {RateLimitData} rateLimitData Object containing the rate limit info
|
||||
*/
|
||||
this.manager.client.emit(RATE_LIMIT, {
|
||||
timeout,
|
||||
limit,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
route: request.route,
|
||||
global: isGlobal,
|
||||
});
|
||||
}
|
||||
|
||||
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 = sleep(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
|
||||
}
|
||||
|
||||
// As the request goes out, update the global usage information
|
||||
if (!this.manager.globalReset || this.manager.globalReset < Date.now()) {
|
||||
this.manager.globalReset = Date.now() + 1_000;
|
||||
this.manager.globalRemaining = this.manager.globalLimit;
|
||||
}
|
||||
this.manager.globalRemaining--;
|
||||
|
||||
/**
|
||||
* Represents a request that will or has been made to the Discord API
|
||||
* @typedef {Object} APIRequest
|
||||
* @property {HTTPMethod} method The HTTP method used in this request
|
||||
* @property {string} path The full path used to make the request
|
||||
* @property {string} route The API route identifying the rate limit for this request
|
||||
* @property {Object} options Additional options for this request
|
||||
* @property {number} retries The number of times this request has been attempted
|
||||
*/
|
||||
|
||||
if (this.manager.client.listenerCount(API_REQUEST)) {
|
||||
/**
|
||||
* Emitted before every API request.
|
||||
* This event can emit several times for the same request, e.g. when hitting a rate limit.
|
||||
* <info>This is an informational event that is emitted quite frequently,
|
||||
* it is highly recommended to check `request.path` to filter the data.</info>
|
||||
* @event BaseClient#apiRequest
|
||||
* @param {APIRequest} request The request that is about to be sent
|
||||
*/
|
||||
this.manager.client.emit(API_REQUEST, {
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
route: request.route,
|
||||
options: request.options,
|
||||
retries: request.retries,
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the request
|
||||
let res;
|
||||
try {
|
||||
res = await request.make();
|
||||
} catch (error) {
|
||||
// Retry the specified number of times for request abortions
|
||||
if (request.retries === this.manager.client.options.retryLimit) {
|
||||
throw new HTTPError(error.message, error.constructor.name, error.status, request);
|
||||
}
|
||||
|
||||
request.retries++;
|
||||
return this.execute(request);
|
||||
}
|
||||
|
||||
if (this.manager.client.listenerCount(API_RESPONSE)) {
|
||||
/**
|
||||
* Emitted after every API request has received a response.
|
||||
* This event does not necessarily correlate to completion of the request, e.g. when hitting a rate limit.
|
||||
* <info>This is an informational event that is emitted quite frequently,
|
||||
* it is highly recommended to check `request.path` to filter the data.</info>
|
||||
* @event BaseClient#apiResponse
|
||||
* @param {APIRequest} request The request that triggered this response
|
||||
* @param {Response} response The response received from the Discord API
|
||||
*/
|
||||
this.manager.client.emit(
|
||||
API_RESPONSE,
|
||||
{
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
route: request.route,
|
||||
options: request.options,
|
||||
retries: request.retries,
|
||||
},
|
||||
res.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let sublimitTimeout;
|
||||
if (res.headers) {
|
||||
const serverDate = res.headers.get('date');
|
||||
const limit = res.headers.get('x-ratelimit-limit');
|
||||
const remaining = res.headers.get('x-ratelimit-remaining');
|
||||
const reset = res.headers.get('x-ratelimit-reset');
|
||||
const resetAfter = res.headers.get('x-ratelimit-reset-after');
|
||||
this.limit = limit ? Number(limit) : Infinity;
|
||||
this.remaining = remaining ? Number(remaining) : 1;
|
||||
|
||||
this.reset = reset || resetAfter ? calculateReset(reset, resetAfter, serverDate) : Date.now();
|
||||
|
||||
// https://github.com/discord/discord-api-docs/issues/182
|
||||
if (!resetAfter && request.route.includes('reactions')) {
|
||||
this.reset = new Date(serverDate).getTime() - getAPIOffset(serverDate) + 250;
|
||||
}
|
||||
|
||||
// Handle retryAfter, which means we have actually hit a rate limit
|
||||
let retryAfter = res.headers.get('retry-after');
|
||||
retryAfter = retryAfter ? Number(retryAfter) * 1_000 : -1;
|
||||
if (retryAfter > 0) {
|
||||
// If the global rate limit header is set, that means we hit the global rate limit
|
||||
if (res.headers.get('x-ratelimit-global')) {
|
||||
this.manager.globalRemaining = 0;
|
||||
this.manager.globalReset = Date.now() + retryAfter;
|
||||
} else if (!this.localLimited) {
|
||||
/*
|
||||
* This is a sublimit (e.g. 2 channel name changes/10 minutes) since the headers don't indicate a
|
||||
* route-wide rate limit. Don't update remaining or reset to avoid rate limiting the whole
|
||||
* endpoint, just set a reset time on the request itself to avoid retrying too soon.
|
||||
*/
|
||||
sublimitTimeout = retryAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count the invalid requests
|
||||
if (res.status === 401 || res.status === 403 || res.status === 429) {
|
||||
if (!invalidCountResetTime || invalidCountResetTime < Date.now()) {
|
||||
invalidCountResetTime = Date.now() + 1_000 * 60 * 10;
|
||||
invalidCount = 0;
|
||||
}
|
||||
invalidCount++;
|
||||
|
||||
const emitInvalid =
|
||||
this.manager.client.listenerCount(INVALID_REQUEST_WARNING) &&
|
||||
this.manager.client.options.invalidRequestWarningInterval > 0 &&
|
||||
invalidCount % this.manager.client.options.invalidRequestWarningInterval === 0;
|
||||
if (emitInvalid) {
|
||||
/**
|
||||
* @typedef {Object} InvalidRequestWarningData
|
||||
* @property {number} count Number of invalid requests that have been made in the window
|
||||
* @property {number} remainingTime Time in ms remaining before the count resets
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted periodically when the process sends invalid requests to let users avoid the
|
||||
* 10k invalid requests in 10 minutes threshold that causes a ban
|
||||
* @event BaseClient#invalidRequestWarning
|
||||
* @param {InvalidRequestWarningData} invalidRequestWarningData Object containing the invalid request info
|
||||
*/
|
||||
this.manager.client.emit(INVALID_REQUEST_WARNING, {
|
||||
count: invalidCount,
|
||||
remainingTime: invalidCountResetTime - Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 2xx and 3xx responses
|
||||
if (res.ok) {
|
||||
// Nothing wrong with the request, proceed with the next one
|
||||
return parseResponse(res);
|
||||
}
|
||||
|
||||
// Handle 4xx responses
|
||||
if (res.status >= 400 && res.status < 500) {
|
||||
// Handle ratelimited requests
|
||||
if (res.status === 429) {
|
||||
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();
|
||||
}
|
||||
|
||||
this.manager.client.emit(
|
||||
DEBUG,
|
||||
`Hit a 429 while executing a request.
|
||||
Global : ${isGlobal}
|
||||
Method : ${request.method}
|
||||
Path : ${request.path}
|
||||
Route : ${request.route}
|
||||
Limit : ${limit}
|
||||
Timeout : ${timeout}ms
|
||||
Sublimit: ${sublimitTimeout ? `${sublimitTimeout}ms` : 'None'}`,
|
||||
);
|
||||
|
||||
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 sleep(sublimitTimeout);
|
||||
}
|
||||
return this.execute(request);
|
||||
}
|
||||
|
||||
// Handle possible malformed requests
|
||||
let data;
|
||||
try {
|
||||
data = await parseResponse(res);
|
||||
} catch (err) {
|
||||
throw new HTTPError(err.message, err.constructor.name, err.status, request);
|
||||
}
|
||||
|
||||
throw new DiscordAPIError(data, res.status, request);
|
||||
}
|
||||
|
||||
// Handle 5xx responses
|
||||
if (res.status >= 500 && res.status < 600) {
|
||||
// Retry the specified number of times for possible serverside issues
|
||||
if (request.retries === this.manager.client.options.retryLimit) {
|
||||
throw new HTTPError(res.statusText, res.constructor.name, res.status, request);
|
||||
}
|
||||
|
||||
request.retries++;
|
||||
return this.execute(request);
|
||||
}
|
||||
|
||||
// Fallback in the rare case a status code outside the range 200..=599 is returned
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RequestHandler;
|
||||
|
||||
/**
|
||||
* @external HTTPMethod
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @external Response
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response}
|
||||
*/
|
Reference in New Issue
Block a user