mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-17 12:03:31 +01:00
fix(WebSocketShard): send ratelimit handling (#8887)
* fix(WebSocketShard): send ratelimit handling * chore: remove unnecessary else Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Collection } from '@discordjs/collection';
|
|||||||
import { lazy } from '@discordjs/util';
|
import { lazy } from '@discordjs/util';
|
||||||
import { APIVersion, GatewayOpcodes } from 'discord-api-types/v10';
|
import { APIVersion, GatewayOpcodes } from 'discord-api-types/v10';
|
||||||
import type { OptionalWebSocketManagerOptions, SessionInfo } from '../ws/WebSocketManager.js';
|
import type { OptionalWebSocketManagerOptions, SessionInfo } from '../ws/WebSocketManager.js';
|
||||||
|
import type { SendRateLimitState } from '../ws/WebSocketShard.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid encoding types
|
* Valid encoding types
|
||||||
@@ -60,3 +61,10 @@ export const ImportantGatewayOpcodes = new Set([
|
|||||||
GatewayOpcodes.Identify,
|
GatewayOpcodes.Identify,
|
||||||
GatewayOpcodes.Resume,
|
GatewayOpcodes.Resume,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function getInitialSendRateLimitState(): SendRateLimitState {
|
||||||
|
return {
|
||||||
|
remaining: 120,
|
||||||
|
resetAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ import {
|
|||||||
import { WebSocket, type RawData } from 'ws';
|
import { WebSocket, type RawData } from 'ws';
|
||||||
import type { Inflate } from 'zlib-sync';
|
import type { Inflate } from 'zlib-sync';
|
||||||
import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy';
|
import type { IContextFetchingStrategy } from '../strategies/context/IContextFetchingStrategy';
|
||||||
import { ImportantGatewayOpcodes } from '../utils/constants.js';
|
import { getInitialSendRateLimitState, ImportantGatewayOpcodes } from '../utils/constants.js';
|
||||||
import type { SessionInfo } from './WebSocketManager.js';
|
import type { SessionInfo } from './WebSocketManager.js';
|
||||||
|
|
||||||
// eslint-disable-next-line promise/prefer-await-to-then
|
// eslint-disable-next-line promise/prefer-await-to-then
|
||||||
const getZlibSync = lazy(async () => import('zlib-sync').then((mod) => mod.default).catch(() => null));
|
const getZlibSync = lazy(async () => import('zlib-sync').then((mod) => mod.default).catch(() => null));
|
||||||
|
|
||||||
export enum WebSocketShardEvents {
|
export enum WebSocketShardEvents {
|
||||||
|
Closed = 'closed',
|
||||||
Debug = 'debug',
|
Debug = 'debug',
|
||||||
Dispatch = 'dispatch',
|
Dispatch = 'dispatch',
|
||||||
Hello = 'hello',
|
Hello = 'hello',
|
||||||
@@ -51,6 +52,7 @@ export enum WebSocketShardDestroyRecovery {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
export type WebSocketShardEventsMap = {
|
export type WebSocketShardEventsMap = {
|
||||||
|
[WebSocketShardEvents.Closed]: [{ code: number }];
|
||||||
[WebSocketShardEvents.Debug]: [payload: { message: string }];
|
[WebSocketShardEvents.Debug]: [payload: { message: string }];
|
||||||
[WebSocketShardEvents.Hello]: [];
|
[WebSocketShardEvents.Hello]: [];
|
||||||
[WebSocketShardEvents.Ready]: [payload: { data: GatewayReadyDispatchData }];
|
[WebSocketShardEvents.Ready]: [payload: { data: GatewayReadyDispatchData }];
|
||||||
@@ -69,6 +71,11 @@ export enum CloseCodes {
|
|||||||
Resuming = 4_200,
|
Resuming = 4_200,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SendRateLimitState {
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
||||||
private connection: WebSocket | null = null;
|
private connection: WebSocket | null = null;
|
||||||
|
|
||||||
@@ -86,10 +93,7 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
|
|
||||||
private isAck = true;
|
private isAck = true;
|
||||||
|
|
||||||
private sendRateLimitState = {
|
private sendRateLimitState: SendRateLimitState = getInitialSendRateLimitState();
|
||||||
remaining: 120,
|
|
||||||
resetAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private heartbeatInterval: NodeJS.Timer | null = null;
|
private heartbeatInterval: NodeJS.Timer | null = null;
|
||||||
|
|
||||||
@@ -146,6 +150,8 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
|
|
||||||
this.status = WebSocketShardStatus.Connecting;
|
this.status = WebSocketShardStatus.Connecting;
|
||||||
|
|
||||||
|
this.sendRateLimitState = getInitialSendRateLimitState();
|
||||||
|
|
||||||
await this.waitForEvent(WebSocketShardEvents.Hello, this.strategy.options.helloTimeout);
|
await this.waitForEvent(WebSocketShardEvents.Hello, this.strategy.options.helloTimeout);
|
||||||
|
|
||||||
if (session?.shardCount === this.strategy.options.shardCount) {
|
if (session?.shardCount === this.strategy.options.shardCount) {
|
||||||
@@ -187,22 +193,32 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
await this.strategy.updateSessionInfo(this.id, null);
|
await this.strategy.updateSessionInfo(this.id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (this.connection) {
|
||||||
this.connection &&
|
|
||||||
(this.connection.readyState === WebSocket.OPEN || this.connection.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
// No longer need to listen to messages
|
// No longer need to listen to messages
|
||||||
this.connection.removeAllListeners('message');
|
this.connection.removeAllListeners('message');
|
||||||
// Prevent a reconnection loop by unbinding the main close event
|
// Prevent a reconnection loop by unbinding the main close event
|
||||||
this.connection.removeAllListeners('close');
|
this.connection.removeAllListeners('close');
|
||||||
this.connection.close(options.code, options.reason);
|
|
||||||
|
|
||||||
// Actually wait for the connection to close
|
const shouldClose =
|
||||||
await once(this.connection, 'close');
|
this.connection.readyState === WebSocket.OPEN || this.connection.readyState === WebSocket.CONNECTING;
|
||||||
|
|
||||||
|
this.debug([
|
||||||
|
'Connection status during destroy',
|
||||||
|
`Needs closing: ${shouldClose}`,
|
||||||
|
`Ready state: ${this.connection.readyState}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (shouldClose) {
|
||||||
|
this.connection.close(options.code, options.reason);
|
||||||
|
await once(this.connection, 'close');
|
||||||
|
this.emit(WebSocketShardEvents.Closed, { code: options.code });
|
||||||
|
}
|
||||||
|
|
||||||
// Lastly, remove the error event.
|
// Lastly, remove the error event.
|
||||||
// Doing this earlier would cause a hard crash in case an error event fired on our `close` call
|
// Doing this earlier would cause a hard crash in case an error event fired on our `close` call
|
||||||
this.connection.removeAllListeners('error');
|
this.connection.removeAllListeners('error');
|
||||||
|
} else {
|
||||||
|
this.debug(['Destroying a shard that has no connection; please open an issue on GitHub']);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.status = WebSocketShardStatus.Idle;
|
this.status = WebSocketShardStatus.Idle;
|
||||||
@@ -227,26 +243,44 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async send(payload: GatewaySendPayload) {
|
public async send(payload: GatewaySendPayload): Promise<void> {
|
||||||
if (!this.connection) {
|
if (!this.connection) {
|
||||||
throw new Error("WebSocketShard wasn't connected");
|
throw new Error("WebSocketShard wasn't connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.status !== WebSocketShardStatus.Ready && !ImportantGatewayOpcodes.has(payload.op)) {
|
if (this.status !== WebSocketShardStatus.Ready && !ImportantGatewayOpcodes.has(payload.op)) {
|
||||||
|
this.debug(['Tried to send a non-crucial payload before the shard was ready, waiting']);
|
||||||
await once(this, WebSocketShardEvents.Ready);
|
await once(this, WebSocketShardEvents.Ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.sendQueue.wait();
|
await this.sendQueue.wait();
|
||||||
|
|
||||||
if (--this.sendRateLimitState.remaining <= 0) {
|
if (--this.sendRateLimitState.remaining <= 0) {
|
||||||
if (this.sendRateLimitState.resetAt < Date.now()) {
|
const now = Date.now();
|
||||||
await sleep(Date.now() - this.sendRateLimitState.resetAt);
|
|
||||||
|
if (this.sendRateLimitState.resetAt > now) {
|
||||||
|
const sleepFor = this.sendRateLimitState.resetAt - now;
|
||||||
|
|
||||||
|
this.debug([`Was about to hit the send rate limit, sleeping for ${sleepFor}ms`]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
// Sleep for the remaining time, but if the connection closes in the meantime, we shouldn't wait the remainder to avoid blocking the new conn
|
||||||
|
const interrupted = await Promise.race([
|
||||||
|
sleep(sleepFor).then(() => false),
|
||||||
|
once(this, WebSocketShardEvents.Closed, { signal: controller.signal }).then(() => true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (interrupted) {
|
||||||
|
this.debug(['Connection closed while waiting for the send rate limit to reset, re-queueing payload']);
|
||||||
|
this.sendQueue.shift();
|
||||||
|
return this.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is so the listener from the `once` call is removed
|
||||||
|
controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendRateLimitState = {
|
this.sendRateLimitState = getInitialSendRateLimitState();
|
||||||
remaining: 119,
|
|
||||||
resetAt: Date.now() + 60_000,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendQueue.shift();
|
this.sendQueue.shift();
|
||||||
@@ -476,9 +510,10 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onClose(code: number) {
|
private async onClose(code: number) {
|
||||||
|
this.emit(WebSocketShardEvents.Closed, { code });
|
||||||
|
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case CloseCodes.Normal: {
|
case CloseCodes.Normal: {
|
||||||
this.debug([`Disconnected normally from code ${code}`]);
|
|
||||||
return this.destroy({
|
return this.destroy({
|
||||||
code,
|
code,
|
||||||
reason: 'Got disconnected by Discord',
|
reason: 'Got disconnected by Discord',
|
||||||
@@ -487,7 +522,6 @@ export class WebSocketShard extends AsyncEventEmitter<WebSocketShardEventsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case CloseCodes.Resuming: {
|
case CloseCodes.Resuming: {
|
||||||
this.debug([`Disconnected normally from code ${code}`]);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user