diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 90eea1e2d..d724a5464 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,6 +1,6 @@ import { clearTimeout, setTimeout } from 'node:timers'; import type { REST } from '@discordjs/rest'; -import { calculateShardId } from '@discordjs/util'; +import { calculateShardId, GatewayRateLimitError } from '@discordjs/util'; import { WebSocketShardEvents } from '@discordjs/ws'; import { DiscordSnowflake } from '@sapphire/snowflake'; import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; @@ -57,6 +57,7 @@ import { type GatewayMessageUpdateDispatchData, type GatewayPresenceUpdateData, type GatewayPresenceUpdateDispatchData, + type GatewayRateLimitedDispatchData, type GatewayReadyDispatchData, type GatewayRequestGuildMembersData, type GatewayStageInstanceCreateDispatchData, @@ -150,6 +151,7 @@ export interface MappedEvents { [GatewayDispatchEvents.MessageReactionRemoveEmoji]: [ToEventProps]; [GatewayDispatchEvents.MessageUpdate]: [ToEventProps]; [GatewayDispatchEvents.PresenceUpdate]: [ToEventProps]; + [GatewayDispatchEvents.RateLimited]: [ToEventProps]; [GatewayDispatchEvents.Ready]: [ToEventProps]; [GatewayDispatchEvents.Resumed]: [ToEventProps]; [GatewayDispatchEvents.StageInstanceCreate]: [ToEventProps]; @@ -182,6 +184,10 @@ export interface RequestGuildMembersResult { presences: NonNullable; } +function createTimer(controller: AbortController, timeout: number) { + return setTimeout(() => controller.abort(), timeout); +} + export class Client extends AsyncEventEmitter { public readonly rest: REST; @@ -220,13 +226,24 @@ export class Client extends AsyncEventEmitter { const controller = new AbortController(); - const createTimer = () => - setTimeout(() => { - controller.abort(); - }, timeout); + let timer: NodeJS.Timeout | undefined = createTimer(controller, timeout); - let timer: NodeJS.Timeout | undefined = createTimer(); + const onRatelimit = ({ data }: ToEventProps) => { + // We could verify meta.guild_id === options.guild_id as well, but really, the nonce check is enough + if (data.meta.nonce === nonce) { + controller.abort(new GatewayRateLimitError(data, options)); + } + }; + const cleanup = () => { + if (timer) { + clearTimeout(timer); + } + + this.off(GatewayDispatchEvents.RateLimited, onRatelimit); + }; + + this.on(GatewayDispatchEvents.RateLimited, onRatelimit); await this.gateway.send(shardId, { op: GatewayOpcodes.RequestGuildMembers, // eslint-disable-next-line id-length @@ -256,22 +273,23 @@ export class Client extends AsyncEventEmitter { chunkCount: data.chunk_count, }; - if (data.chunk_index >= data.chunk_count - 1) { - break; - } else { - timer = createTimer(); - } + if (data.chunk_index >= data.chunk_count - 1) break; + + // eslint-disable-next-line require-atomic-updates + timer = createTimer(controller, timeout); } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { + if (error.cause instanceof GatewayRateLimitError) { + throw error.cause; + } + throw new Error('Request timed out'); } throw error; } finally { - if (timer) { - clearTimeout(timer); - } + cleanup(); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 03a97ad41..ca4c37102 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,8 @@ export * from './util/index.js'; export * from 'discord-api-types/v10'; +export { GatewayRateLimitError } from '@discordjs/util'; + /** * The {@link https://github.com/discordjs/discord.js/blob/main/packages/core#readme | @discordjs/core} version * that you are currently using. diff --git a/packages/util/package.json b/packages/util/package.json index 437fa7270..4c01203da 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -61,6 +61,9 @@ }, "homepage": "https://discord.js.org", "funding": "https://github.com/discordjs/discord.js?sponsor", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", "@discordjs/scripts": "workspace:^", diff --git a/packages/util/src/gatewayRateLimitError.ts b/packages/util/src/gatewayRateLimitError.ts new file mode 100644 index 000000000..884b7fa51 --- /dev/null +++ b/packages/util/src/gatewayRateLimitError.ts @@ -0,0 +1,25 @@ +import type { GatewayOpcodeRateLimitMetadataMap, GatewayRateLimitedDispatchData } from 'discord-api-types/v10'; + +/** + * Represents the error thrown when the gateway emits a `RATE_LIMITED` event after a certain request. + */ +export class GatewayRateLimitError extends Error { + public override readonly name = GatewayRateLimitError.name; + + public constructor( + /** + * The data associated with the rate limit event + */ + public readonly data: GatewayRateLimitedDispatchData, + /** + * The payload data that lead to this rate limit + * + * @privateRemarks + * Too complicated to type properly here (i.e. extract the ['data'] + * of event payloads that have t = keyof GatewayOpcodeRateLimitMetadataMap) + */ + public readonly payload: unknown, + ) { + super(`Request with opcode ${data.opcode} was rate limited. Retry after ${data.retry_after} seconds.`); + } +} diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 2b9f050e1..16ba8a245 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -2,6 +2,7 @@ export * from './types.js'; export * from './functions/index.js'; export * from './JSONEncodable.js'; export * from './Equatable.js'; +export * from './gatewayRateLimitError.js'; /** * The {@link https://github.com/discordjs/discord.js/blob/main/packages/util#readme | @discordjs/util} version diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc23aef21..935e0f19b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1270,6 +1270,10 @@ importers: version: 2.1.9(@edge-runtime/vm@3.2.0)(@types/node@18.19.130)(terser@5.44.1) packages/util: + dependencies: + discord-api-types: + specifier: ^0.38.33 + version: 0.38.33 devDependencies: '@discordjs/api-extractor': specifier: workspace:^