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:
DD
2022-12-01 12:58:00 +02:00
committed by GitHub
parent 322cb99049
commit 40b504a208
2 changed files with 63 additions and 21 deletions

View File

@@ -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,
};
}

View File

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