Skip to content

Commit 46f289a

Browse files
authored
Merge branch 'main' into feat/max-stage
2 parents e098f45 + db8df10 commit 46f289a

9 files changed

Lines changed: 511 additions & 124 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/* eslint-disable id-length */
2+
/* eslint-disable promise/prefer-await-to-then */
3+
import { performance } from 'node:perf_hooks';
4+
import { MockAgent, setGlobalDispatcher } from 'undici';
5+
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
6+
import { beforeEach, afterEach, test, expect, vitest } from 'vitest';
7+
import { DiscordAPIError, HTTPError, RateLimitError, REST, BurstHandlerMajorIdKey } from '../src/index.js';
8+
import { BurstHandler } from '../src/lib/handlers/BurstHandler.js';
9+
import { genPath } from './util.js';
10+
11+
const callbackKey = `Global(POST:/interactions/:id/:token/callback):${BurstHandlerMajorIdKey}`;
12+
const callbackPath = new RegExp(genPath('/interactions/[0-9]{17,19}/.+/callback'));
13+
14+
const api = new REST();
15+
16+
let mockAgent: MockAgent;
17+
let mockPool: Interceptable;
18+
19+
beforeEach(() => {
20+
mockAgent = new MockAgent();
21+
mockAgent.disableNetConnect();
22+
setGlobalDispatcher(mockAgent);
23+
24+
mockPool = mockAgent.get('https://discord.com');
25+
api.setAgent(mockAgent);
26+
});
27+
28+
afterEach(async () => {
29+
await mockAgent.close();
30+
});
31+
32+
// @discordjs/rest uses the `content-type` header to detect whether to parse
33+
// the response as JSON or as an ArrayBuffer.
34+
const responseOptions: MockInterceptor.MockResponseOptions = {
35+
headers: {
36+
'content-type': 'application/json',
37+
},
38+
};
39+
40+
test('Interaction callback creates burst handler', async () => {
41+
mockPool.intercept({ path: callbackPath, method: 'POST' }).reply(200);
42+
43+
expect(api.requestManager.handlers.get(callbackKey)).toBe(undefined);
44+
expect(
45+
await api.post('/interactions/1234567890123456789/totallyarealtoken/callback', {
46+
auth: false,
47+
body: { type: 4, data: { content: 'Reply' } },
48+
}),
49+
).toBeInstanceOf(Uint8Array);
50+
expect(api.requestManager.handlers.get(callbackKey)).toBeInstanceOf(BurstHandler);
51+
});
52+
53+
test('Requests are handled in bursts', async () => {
54+
mockPool.intercept({ path: callbackPath, method: 'POST' }).reply(200).delay(100).times(3);
55+
56+
// Return the current time on these results as their response does not indicate anything
57+
const [a, b, c] = await Promise.all([
58+
api
59+
.post('/interactions/1234567890123456789/totallyarealtoken/callback', {
60+
auth: false,
61+
body: { type: 4, data: { content: 'Reply1' } },
62+
})
63+
.then(() => performance.now()),
64+
api
65+
.post('/interactions/2345678901234567890/anotherveryrealtoken/callback', {
66+
auth: false,
67+
body: { type: 4, data: { content: 'Reply2' } },
68+
})
69+
.then(() => performance.now()),
70+
api
71+
.post('/interactions/3456789012345678901/nowaytheresanotherone/callback', {
72+
auth: false,
73+
body: { type: 4, data: { content: 'Reply3' } },
74+
})
75+
.then(() => performance.now()),
76+
]);
77+
78+
expect(b - a).toBeLessThan(10);
79+
expect(c - a).toBeLessThan(10);
80+
});
81+
82+
test('Handle 404', async () => {
83+
mockPool
84+
.intercept({ path: callbackPath, method: 'POST' })
85+
.reply(404, { message: 'Unknown interaction', code: 10_062 }, responseOptions);
86+
87+
const promise = api.post('/interactions/1234567890123456788/definitelynotarealinteraction/callback', {
88+
auth: false,
89+
body: { type: 4, data: { content: 'Malicious' } },
90+
});
91+
await expect(promise).rejects.toThrowError('Unknown interaction');
92+
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
93+
});
94+
95+
let unexpected429 = true;
96+
test('Handle unexpected 429', async () => {
97+
mockPool
98+
.intercept({
99+
path: callbackPath,
100+
method: 'POST',
101+
})
102+
.reply(() => {
103+
if (unexpected429) {
104+
unexpected429 = false;
105+
return {
106+
statusCode: 429,
107+
data: '',
108+
responseOptions: {
109+
headers: {
110+
'retry-after': '1',
111+
via: '1.1 google',
112+
},
113+
},
114+
};
115+
}
116+
117+
return {
118+
statusCode: 200,
119+
data: { test: true },
120+
responseOptions,
121+
};
122+
})
123+
.times(2);
124+
125+
const previous = performance.now();
126+
let firstResolvedTime: number;
127+
const unexpectedLimit = api
128+
.post('/interactions/1234567890123456789/totallyarealtoken/callback', {
129+
auth: false,
130+
body: { type: 4, data: { content: 'Reply' } },
131+
})
132+
.then((res) => {
133+
firstResolvedTime = performance.now();
134+
return res;
135+
});
136+
137+
expect(await unexpectedLimit).toStrictEqual({ test: true });
138+
expect(performance.now()).toBeGreaterThanOrEqual(previous + 1_000);
139+
});

packages/rest/__tests__/REST.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import { Buffer } from 'node:buffer';
1+
import { Buffer, File as NativeFile } from 'node:buffer';
22
import { URLSearchParams } from 'node:url';
33
import { DiscordSnowflake } from '@sapphire/snowflake';
44
import type { Snowflake } from 'discord-api-types/v10';
55
import { Routes } from 'discord-api-types/v10';
66
import type { FormData } from 'undici';
7-
import { File, MockAgent, setGlobalDispatcher } from 'undici';
7+
import { File as UndiciFile, MockAgent, setGlobalDispatcher } from 'undici';
88
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
99
import { beforeEach, afterEach, test, expect } from 'vitest';
1010
import { REST } from '../src/index.js';
1111
import { genPath } from './util.js';
1212

13+
const File = NativeFile ?? UndiciFile;
14+
1315
const newSnowflake: Snowflake = DiscordSnowflake.generate().toString();
1416

1517
const api = new REST().setToken('A-Very-Fake-Token');

packages/rest/src/lib/RequestManager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ import { lazy } from '@discordjs/util';
77
import { DiscordSnowflake } from '@sapphire/snowflake';
88
import { FormData, type RequestInit, type BodyInit, type Dispatcher, type Agent } from 'undici';
99
import type { RESTOptions, RestEvents, RequestOptions } from './REST.js';
10+
import { BurstHandler } from './handlers/BurstHandler.js';
1011
import type { IHandler } from './handlers/IHandler.js';
1112
import { SequentialHandler } from './handlers/SequentialHandler.js';
12-
import { DefaultRestOptions, DefaultUserAgent, OverwrittenMimeTypes, RESTEvents } from './utils/constants.js';
13+
import {
14+
BurstHandlerMajorIdKey,
15+
DefaultRestOptions,
16+
DefaultUserAgent,
17+
OverwrittenMimeTypes,
18+
RESTEvents,
19+
} from './utils/constants.js';
1320
import { resolveBody } from './utils/utils.js';
1421

1522
// Make this a lazy dynamic import as file-type is a pure ESM package
@@ -351,7 +358,10 @@ export class RequestManager extends EventEmitter {
351358
*/
352359
private createHandler(hash: string, majorParameter: string) {
353360
// Create the async request queue to handle requests
354-
const queue = new SequentialHandler(this, hash, majorParameter);
361+
const queue =
362+
majorParameter === BurstHandlerMajorIdKey
363+
? new BurstHandler(this, hash, majorParameter)
364+
: new SequentialHandler(this, hash, majorParameter);
355365
// Save the queue based on its id
356366
this.handlers.set(queue.id, queue);
357367

@@ -499,6 +509,14 @@ export class RequestManager extends EventEmitter {
499509
* @internal
500510
*/
501511
private static generateRouteData(endpoint: RouteLike, method: RequestMethod): RouteData {
512+
if (endpoint.startsWith('/interactions/') && endpoint.endsWith('/callback')) {
513+
return {
514+
majorParameter: BurstHandlerMajorIdKey,
515+
bucketRoute: '/interactions/:id/:token/callback',
516+
original: endpoint,
517+
};
518+
}
519+
502520
const majorIdMatch = /^\/(?:channels|guilds|webhooks)\/(\d{17,19})/.exec(endpoint);
503521

504522
// Get the major id for this route - global otherwise
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { setTimeout as sleep } from 'node:timers/promises';
2+
import type { Dispatcher } from 'undici';
3+
import type { RequestOptions } from '../REST.js';
4+
import type { HandlerRequestData, RequestManager, RouteData } from '../RequestManager.js';
5+
import { RESTEvents } from '../utils/constants.js';
6+
import { onRateLimit, parseHeader } from '../utils/utils.js';
7+
import type { IHandler } from './IHandler.js';
8+
import { handleErrors, incrementInvalidCount, makeNetworkRequest } from './Shared.js';
9+
10+
/**
11+
* The structure used to handle burst requests for a given bucket.
12+
* Burst requests have no ratelimit handling but allow for pre- and post-processing
13+
* of data in the same manner as sequentially queued requests.
14+
*
15+
* @remarks
16+
* This queue may still emit a rate limit error if an unexpected 429 is hit
17+
*/
18+
export class BurstHandler implements IHandler {
19+
/**
20+
* {@inheritdoc IHandler.id}
21+
*/
22+
public readonly id: string;
23+
24+
/**
25+
* {@inheritDoc IHandler.inactive}
26+
*/
27+
public inactive = false;
28+
29+
/**
30+
* @param manager - The request manager
31+
* @param hash - The hash that this RequestHandler handles
32+
* @param majorParameter - The major parameter for this handler
33+
*/
34+
public constructor(
35+
private readonly manager: RequestManager,
36+
private readonly hash: string,
37+
private readonly majorParameter: string,
38+
) {
39+
this.id = `${hash}:${majorParameter}`;
40+
}
41+
42+
/**
43+
* Emits a debug message
44+
*
45+
* @param message - The message to debug
46+
*/
47+
private debug(message: string) {
48+
this.manager.emit(RESTEvents.Debug, `[REST ${this.id}] ${message}`);
49+
}
50+
51+
/**
52+
* {@inheritDoc IHandler.queueRequest}
53+
*/
54+
public async queueRequest(
55+
routeId: RouteData,
56+
url: string,
57+
options: RequestOptions,
58+
requestData: HandlerRequestData,
59+
): Promise<Dispatcher.ResponseData> {
60+
return this.runRequest(routeId, url, options, requestData);
61+
}
62+
63+
/**
64+
* The method that actually makes the request to the API, and updates info about the bucket accordingly
65+
*
66+
* @param routeId - The generalized API route with literal ids for major parameters
67+
* @param url - The fully resolved URL to make the request to
68+
* @param options - The fetch options needed to make the request
69+
* @param requestData - Extra data from the user's request needed for errors and additional processing
70+
* @param retries - The number of retries this request has already attempted (recursion)
71+
*/
72+
private async runRequest(
73+
routeId: RouteData,
74+
url: string,
75+
options: RequestOptions,
76+
requestData: HandlerRequestData,
77+
retries = 0,
78+
): Promise<Dispatcher.ResponseData> {
79+
const method = options.method ?? 'get';
80+
81+
const res = await makeNetworkRequest(this.manager, routeId, url, options, requestData, retries);
82+
83+
// Retry requested
84+
if (res === null) {
85+
// eslint-disable-next-line no-param-reassign
86+
return this.runRequest(routeId, url, options, requestData, ++retries);
87+
}
88+
89+
const status = res.statusCode;
90+
let retryAfter = 0;
91+
const retry = parseHeader(res.headers['retry-after']);
92+
93+
// Amount of time in milliseconds until we should retry if rate limited (globally or otherwise)
94+
if (retry) retryAfter = Number(retry) * 1_000 + this.manager.options.offset;
95+
96+
// Count the invalid requests
97+
if (status === 401 || status === 403 || status === 429) {
98+
incrementInvalidCount(this.manager);
99+
}
100+
101+
if (status >= 200 && status < 300) {
102+
return res;
103+
} else if (status === 429) {
104+
// Unexpected ratelimit
105+
const isGlobal = res.headers['x-ratelimit-global'] !== undefined;
106+
await onRateLimit(this.manager, {
107+
timeToReset: retryAfter,
108+
limit: Number.POSITIVE_INFINITY,
109+
method,
110+
hash: this.hash,
111+
url,
112+
route: routeId.bucketRoute,
113+
majorParameter: this.majorParameter,
114+
global: isGlobal,
115+
});
116+
this.debug(
117+
[
118+
'Encountered unexpected 429 rate limit',
119+
` Global : ${isGlobal}`,
120+
` Method : ${method}`,
121+
` URL : ${url}`,
122+
` Bucket : ${routeId.bucketRoute}`,
123+
` Major parameter: ${routeId.majorParameter}`,
124+
` Hash : ${this.hash}`,
125+
` Limit : ${Number.POSITIVE_INFINITY}`,
126+
` Retry After : ${retryAfter}ms`,
127+
` Sublimit : None`,
128+
].join('\n'),
129+
);
130+
131+
// We are bypassing all other limits, but an encountered limit should be respected (it's probably a non-punished rate limit anyways)
132+
await sleep(retryAfter);
133+
134+
// Since this is not a server side issue, the next request should pass, so we don't bump the retries counter
135+
return this.runRequest(routeId, url, options, requestData, retries);
136+
} else {
137+
const handled = await handleErrors(this.manager, res, method, url, requestData, retries);
138+
if (handled === null) {
139+
// eslint-disable-next-line no-param-reassign
140+
return this.runRequest(routeId, url, options, requestData, ++retries);
141+
}
142+
143+
return handled;
144+
}
145+
}
146+
}

packages/rest/src/lib/handlers/IHandler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ export interface IHandler {
2626
requestData: HandlerRequestData,
2727
): Promise<Dispatcher.ResponseData>;
2828
}
29+
30+
export interface PolyFillAbortSignal {
31+
readonly aborted: boolean;
32+
addEventListener(type: 'abort', listener: () => void): void;
33+
removeEventListener(type: 'abort', listener: () => void): void;
34+
}

0 commit comments

Comments
 (0)