From 25aff7e218af5182b7968a7547e4751a0e050e30 Mon Sep 17 00:00:00 2001 From: Denis-Adrian Cristea Date: Fri, 14 Nov 2025 22:51:54 +0200 Subject: [PATCH] feat(GuildMemberManager): handle gateway request rate limit (#11252) * feat(GuildMemberManager): handle gateway request ratelimit * chore: typo no one saw * fix: cleanup listener properly * types: add error code * refactor: requested changes * fix: update emitted warning * chore: requested changes * refactor: remove event * refactor: warning * chore: wording Co-authored-by: Vlad Frangu --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> Co-authored-by: Vlad Frangu --- .../client/websocket/handlers/RATE_LIMITED.js | 24 +++++++ .../src/client/websocket/handlers/index.js | 1 + .../src/managers/GuildMemberManager.js | 67 ++++++++++++------- 3 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 packages/discord.js/src/client/websocket/handlers/RATE_LIMITED.js diff --git a/packages/discord.js/src/client/websocket/handlers/RATE_LIMITED.js b/packages/discord.js/src/client/websocket/handlers/RATE_LIMITED.js new file mode 100644 index 000000000..60178bbfa --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/RATE_LIMITED.js @@ -0,0 +1,24 @@ +'use strict'; + +const process = require('node:process'); +const { GatewayOpcodes } = require('discord-api-types/v10'); + +const emittedFor = new Set(); + +module.exports = (client, { d: data }) => { + switch (data.opcode) { + case GatewayOpcodes.RequestGuildMembers: { + break; + } + + default: { + if (!emittedFor.has(data.opcode)) { + process.emitWarning( + `Hit a gateway rate limit on opcode ${data.opcode} (${GatewayOpcodes[data.opcode]}). If the discord.js version you're using is up-to-date, please open an issue on GitHub.`, + ); + + emittedFor.add(data.opcode); + } + } + } +}; diff --git a/packages/discord.js/src/client/websocket/handlers/index.js b/packages/discord.js/src/client/websocket/handlers/index.js index 148bcd729..9a42476bc 100644 --- a/packages/discord.js/src/client/websocket/handlers/index.js +++ b/packages/discord.js/src/client/websocket/handlers/index.js @@ -52,6 +52,7 @@ const PacketHandlers = Object.fromEntries([ ['MESSAGE_REACTION_REMOVE_EMOJI', require('./MESSAGE_REACTION_REMOVE_EMOJI.js')], ['MESSAGE_UPDATE', require('./MESSAGE_UPDATE.js')], ['PRESENCE_UPDATE', require('./PRESENCE_UPDATE.js')], + ['RATE_LIMITED', require('./RATE_LIMITED.js')], ['READY', require('./READY.js')], ['SOUNDBOARD_SOUNDS', require('./SOUNDBOARD_SOUNDS.js')], ['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE.js')], diff --git a/packages/discord.js/src/managers/GuildMemberManager.js b/packages/discord.js/src/managers/GuildMemberManager.js index 5e1ad76b0..e75c9a338 100644 --- a/packages/discord.js/src/managers/GuildMemberManager.js +++ b/packages/discord.js/src/managers/GuildMemberManager.js @@ -3,8 +3,10 @@ const { setTimeout, clearTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); const { makeURLSearchParams } = require('@discordjs/rest'); +const { GatewayRateLimitError } = require('@discordjs/util'); +const { WebSocketShardEvents } = require('@discordjs/ws'); const { DiscordSnowflake } = require('@sapphire/snowflake'); -const { Routes, GatewayOpcodes } = require('discord-api-types/v10'); +const { Routes, GatewayOpcodes, GatewayDispatchEvents } = require('discord-api-types/v10'); const { DiscordjsError, DiscordjsTypeError, DiscordjsRangeError, ErrorCodes } = require('../errors/index.js'); const { BaseGuildVoiceChannel } = require('../structures/BaseGuildVoiceChannel.js'); const { GuildMember } = require('../structures/GuildMember.js'); @@ -246,24 +248,27 @@ class GuildMemberManager extends CachedManager { const query = initialQuery ?? (users ? undefined : ''); return new Promise((resolve, reject) => { - this.guild.client.ws.send(this.guild.shardId, { - op: GatewayOpcodes.RequestGuildMembers, - // eslint-disable-next-line id-length - d: { - guild_id: this.guild.id, - presences, - user_ids: users, - query, - nonce, - limit, - }, - }); const fetchedMembers = new Collection(); let index = 0; + + const cleanup = () => { + /* eslint-disable no-use-before-define */ + clearTimeout(timeout); + + this.client.ws.removeListener(WebSocketShardEvents.Dispatch, rateLimitHandler); + this.client.removeListener(Events.GuildMembersChunk, handler); + this.client.decrementMaxListeners(); + /* eslint-enable no-use-before-define */ + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new DiscordjsError(ErrorCodes.GuildMembersTimeout)); + }, time).unref(); + const handler = (members, _, chunk) => { if (chunk.nonce !== nonce) return; - // eslint-disable-next-line no-use-before-define timeout.refresh(); index++; for (const member of members.values()) { @@ -271,21 +276,37 @@ class GuildMemberManager extends CachedManager { } if (members.size < 1_000 || (limit && fetchedMembers.size >= limit) || index === chunk.count) { - // eslint-disable-next-line no-use-before-define - clearTimeout(timeout); - this.client.removeListener(Events.GuildMembersChunk, handler); - this.client.decrementMaxListeners(); + cleanup(); resolve(users && !Array.isArray(users) && fetchedMembers.size ? fetchedMembers.first() : fetchedMembers); } }; - const timeout = setTimeout(() => { - this.client.removeListener(Events.GuildMembersChunk, handler); - this.client.decrementMaxListeners(); - reject(new DiscordjsError(ErrorCodes.GuildMembersTimeout)); - }, time).unref(); + const requestData = { + guild_id: this.guild.id, + presences, + user_ids: users, + query, + nonce, + limit, + }; + + const rateLimitHandler = payload => { + if (payload.t === GatewayDispatchEvents.RateLimited && payload.d.meta.nonce === nonce) { + cleanup(); + reject(new GatewayRateLimitError(payload.d, requestData)); + } + }; + + this.client.ws.on(WebSocketShardEvents.Dispatch, rateLimitHandler); + this.client.incrementMaxListeners(); this.client.on(Events.GuildMembersChunk, handler); + + this.guild.client.ws.send(this.guild.shardId, { + op: GatewayOpcodes.RequestGuildMembers, + // eslint-disable-next-line id-length + d: requestData, + }); }); }