Files
discord.js/packages/rest/__tests__/RequestHandler.test.ts
ckohen cdaa0a36f5 refactor(rest): switch api to fetch-like and provide strategies (#9416)
BREAKING CHANGE: NodeJS v18+ is required when using node due to the use of global `fetch`
BREAKING CHANGE: The raw method of REST now returns a web compatible `Respone` object.
BREAKING CHANGE: The `parseResponse` utility method has been updated to operate on a web compatible `Response` object.
BREAKING CHANGE: Many underlying internals have changed, some of which were exported.
BREAKING CHANGE: `DefaultRestOptions` used to contain a default `agent`, which is now set to `null` instead.
2023-05-06 21:09:19 +02:00

578 lines
16 KiB
TypeScript

/* eslint-disable id-length */
/* eslint-disable promise/prefer-await-to-then */
import { performance } from 'node:perf_hooks';
import { setInterval, clearInterval } from 'node:timers';
import { MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor.js';
import { beforeEach, afterEach, test, expect, vitest } from 'vitest';
import { DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src/index.js';
import { genPath } from './util.js';
let mockAgent: MockAgent;
let mockPool: Interceptable;
const api = new REST({ timeout: 2_000, offset: 5 }).setToken('A-Very-Fake-Token');
const invalidAuthApi = new REST({ timeout: 2_000 }).setToken('Definitely-Not-A-Fake-Token');
const rateLimitErrorApi = new REST({ rejectOnRateLimit: ['/channels'] }).setToken('Obviously-Not-A-Fake-Token');
beforeEach(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect();
setGlobalDispatcher(mockAgent);
mockPool = mockAgent.get('https://discord.com');
api.setAgent(mockAgent);
invalidAuthApi.setAgent(mockAgent);
rateLimitErrorApi.setAgent(mockAgent);
});
afterEach(async () => {
await mockAgent.close();
});
// @discordjs/rest uses the `content-type` header to detect whether to parse
// the response as JSON or as an ArrayBuffer.
const responseOptions: MockInterceptor.MockResponseOptions = {
headers: {
'content-type': 'application/json',
},
};
let resetAfter = 0;
let sublimitResetAfter = 0;
let retryAfter = 0;
let sublimitRequests = 0;
let sublimitHits = 0;
let serverOutage = true;
let unexpected429 = true;
let unexpected429cf = true;
const sublimitIntervals: {
reset: NodeJS.Timer | null;
retry: NodeJS.Timer | null;
} = {
reset: null,
retry: null,
};
const sublimit = { body: { name: 'newname' } };
const noSublimit = { body: { bitrate: 40_000 } };
function startSublimitIntervals() {
if (!sublimitIntervals.reset) {
sublimitResetAfter = Date.now() + 250;
sublimitIntervals.reset = setInterval(() => {
sublimitRequests = 0;
sublimitResetAfter = Date.now() + 250;
}, 250);
}
if (!sublimitIntervals.retry) {
retryAfter = Date.now() + 1_000;
sublimitIntervals.retry = setInterval(() => {
sublimitHits = 0;
retryAfter = Date.now() + 1_000;
}, 1_000);
}
}
// This is tested first to ensure the count remains accurate
test('Significant Invalid Requests', async () => {
mockPool
.intercept({
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50_013 }, responseOptions)
.times(10);
const invalidListener = vitest.fn();
const invalidListener2 = vitest.fn();
api.on(RESTEvents.InvalidRequestWarning, invalidListener);
// Ensure listeners on REST do not get double added
api.on(RESTEvents.InvalidRequestWarning, invalidListener2);
api.off(RESTEvents.InvalidRequestWarning, invalidListener2);
const [a, b, c, d, e] = [
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
];
await expect(a).rejects.toThrowError('Missing Permissions');
await expect(b).rejects.toThrowError('Missing Permissions');
await expect(c).rejects.toThrowError('Missing Permissions');
await expect(d).rejects.toThrowError('Missing Permissions');
await expect(e).rejects.toThrowError('Missing Permissions');
expect(invalidListener).toHaveBeenCalledTimes(0);
// eslint-disable-next-line require-atomic-updates
api.requestManager.options.invalidRequestWarningInterval = 2;
const [f, g, h, i, j] = [
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
api.get('/badRequest'),
];
await expect(f).rejects.toThrowError('Missing Permissions');
await expect(g).rejects.toThrowError('Missing Permissions');
await expect(h).rejects.toThrowError('Missing Permissions');
await expect(i).rejects.toThrowError('Missing Permissions');
await expect(j).rejects.toThrowError('Missing Permissions');
expect(invalidListener).toHaveBeenCalledTimes(3);
api.off(RESTEvents.InvalidRequestWarning, invalidListener);
});
test('Handle standard rate limits', async () => {
mockPool
.intercept({
path: genPath('/standard'),
method: 'GET',
})
.reply(() => {
const response = Date.now() >= resetAfter ? 204 : 429;
resetAfter = Date.now() + 250;
if (response === 204) {
return {
statusCode: 204,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1_000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '1',
remaining: '0',
resetAfter: (resetAfter / 1_000).toString(),
bucket: '80c17d2f203122d936070c88c8d10f33',
retryAfter: (resetAfter - Date.now()).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1_000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
'retry-after': (resetAfter - Date.now()).toString(),
via: '1.1 google',
},
},
};
})
.times(3);
const [a, b, c] = [api.get('/standard'), api.get('/standard'), api.get('/standard')];
const uint8 = new Uint8Array();
expect(new Uint8Array((await a) as ArrayBuffer)).toStrictEqual(uint8);
const previous1 = performance.now();
expect(new Uint8Array((await b) as ArrayBuffer)).toStrictEqual(uint8);
const previous2 = performance.now();
expect(new Uint8Array((await c) as ArrayBuffer)).toStrictEqual(uint8);
const now = performance.now();
expect(previous2).toBeGreaterThanOrEqual(previous1 + 200);
expect(now).toBeGreaterThanOrEqual(previous2 + 200);
});
test('Handle sublimits', async () => {
mockPool
.intercept({
path: genPath('/channels/:id'),
method: 'PATCH',
})
.reply((from) => {
const body = JSON.parse(from.body as string) as Record<string, unknown>;
if ('name' in body || 'topic' in body) {
sublimitHits += 1;
sublimitRequests += 1;
const response = 2 - sublimitHits >= 0 && 10 - sublimitRequests >= 0 ? 200 : 429;
startSublimitIntervals();
if (response === 200) {
return {
statusCode: 200,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1_000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1_000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
'retry-after': ((retryAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
},
};
} else if (!('name' in body) && !('topic' in body)) {
sublimitRequests += 1;
const response = 10 - sublimitRequests >= 0 ? 200 : 429;
startSublimitIntervals();
if (response === 200) {
return {
statusCode: 200,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1_000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1_000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
'retry-after': ((sublimitResetAfter - Date.now()) / 1_000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
},
};
}
return {
statusCode: 420,
data: 'Oh no',
};
})
.persist();
// Return the current time on these results as their response does not indicate anything
// Queue all requests, don't wait, to allow retroactive check
const [aP, bP, cP, dP, eP] = [
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', sublimit).then(() => Date.now()), // Limit hits
api.patch('/channels/:id', noSublimit).then(() => Date.now()), // Ensure normal request passes
api.patch('/channels/:id', sublimit).then(() => Date.now()), // For retroactive check
];
const [a, b, c, d] = await Promise.all([aP, bP, cP, dP]);
const [f, g] = await Promise.all([
api.patch('/channels/:id', sublimit).then(() => Date.now()),
api.patch('/channels/:id', noSublimit).then(() => Date.now()),
]); // For additional sublimited checks
const e = await eP;
expect(a).toBeLessThanOrEqual(b);
expect(b).toBeLessThanOrEqual(c);
expect(d).toBeLessThanOrEqual(c);
expect(c).toBeLessThanOrEqual(e);
expect(d).toBeLessThanOrEqual(e);
expect(e).toBeLessThanOrEqual(f);
expect(e).toBeLessThanOrEqual(g);
expect(g).toBeLessThanOrEqual(f);
clearInterval(sublimitIntervals.reset!);
clearInterval(sublimitIntervals.retry!);
// Reject on RateLimit
const [aP2, bP2, cP2] = [
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
];
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(aP2).resolves;
await expect(bP2).rejects.toThrowError();
await expect(bP2).rejects.toBeInstanceOf(RateLimitError);
await expect(cP2).rejects.toThrowError();
await expect(cP2).rejects.toBeInstanceOf(RateLimitError);
});
test('Handle unexpected 429', async () => {
mockPool
.intercept({
path: genPath('/unexpected'),
method: 'GET',
})
.reply(() => {
if (unexpected429) {
unexpected429 = false;
return {
statusCode: 429,
data: '',
responseOptions: {
headers: {
'retry-after': '1',
via: '1.1 google',
},
},
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(3);
const previous = performance.now();
let firstResolvedTime: number;
let secondResolvedTime: number;
const unexpectedSublimit = api.get('/unexpected').then((res) => {
firstResolvedTime = performance.now();
return res;
});
const queuedSublimit = api.get('/unexpected').then((res) => {
secondResolvedTime = performance.now();
return res;
});
expect(await unexpectedSublimit).toStrictEqual({ test: true });
expect(await queuedSublimit).toStrictEqual({ test: true });
expect(performance.now()).toBeGreaterThanOrEqual(previous + 1_000);
// @ts-expect-error: This is intentional
expect(secondResolvedTime).toBeGreaterThan(firstResolvedTime);
});
test('Handle unexpected 429 cloudflare', async () => {
mockPool
.intercept({
path: genPath('/unexpected-cf'),
method: 'GET',
})
.reply(() => {
if (unexpected429cf) {
unexpected429cf = false;
return {
statusCode: 429,
data: '',
responseOptions: {
headers: {
'retry-after': '1',
},
},
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(2); // twice because it re-runs the request after first 429
const previous = Date.now();
expect(await api.get('/unexpected-cf')).toStrictEqual({ test: true });
expect(Date.now()).toBeGreaterThanOrEqual(previous + 1_000);
});
test('Handle global rate limits', async () => {
mockPool
.intercept({
path: genPath('/triggerGlobal'),
method: 'GET',
})
.reply(() => ({
data: { global: true },
statusCode: 200,
responseOptions,
}));
mockPool
.intercept({
path: genPath('/regularRequest'),
method: 'GET',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.get('/triggerGlobal')).toStrictEqual({ global: true });
expect(await api.get('/regularRequest')).toStrictEqual({ test: true });
});
test('Handle temp server outage', async () => {
mockPool
.intercept({
path: genPath('/temp'),
method: 'GET',
})
.reply(() => {
if (serverOutage) {
serverOutage = false;
return {
statusCode: 500,
data: '',
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(2);
expect(await api.get('/temp')).toStrictEqual({ test: true });
});
test('perm server outage', async () => {
mockPool
.intercept({
path: genPath('/outage'),
method: 'GET',
})
.reply(500, '', responseOptions)
.times(4);
const promise = api.get('/outage');
await expect(promise).rejects.toThrowError();
await expect(promise).rejects.toBeInstanceOf(HTTPError);
});
test('server responding too slow', async () => {
const api2 = new REST({ timeout: 1 }).setToken('A-Very-Really-Real-Token');
mockPool
.intercept({
path: genPath('/slow'),
method: 'GET',
})
.reply(200, '')
.delay(100)
.times(10);
const promise = api2.get('/slow');
await expect(promise).rejects.toThrowError('aborted');
}, 1_000);
test('Unauthorized', async () => {
mockPool
.intercept({
path: genPath('/unauthorized'),
method: 'GET',
})
.reply(401, { message: '401: Unauthorized', code: 0 }, responseOptions)
.times(2);
const setTokenSpy = vitest.spyOn(invalidAuthApi.requestManager, 'setToken');
// Ensure authless requests don't reset the token
const promiseWithoutTokenClear = invalidAuthApi.get('/unauthorized', { auth: false });
await expect(promiseWithoutTokenClear).rejects.toThrowError('401: Unauthorized');
await expect(promiseWithoutTokenClear).rejects.toBeInstanceOf(DiscordAPIError);
expect(setTokenSpy).not.toHaveBeenCalled();
// Ensure authed requests do reset the token
const promise = invalidAuthApi.get('/unauthorized');
await expect(promise).rejects.toThrowError('401: Unauthorized');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
expect(setTokenSpy).toHaveBeenCalledTimes(1);
});
test('Bad Request', async () => {
mockPool
.intercept({
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50_013 }, responseOptions);
const promise = api.get('/badRequest');
await expect(promise).rejects.toThrowError('Missing Permissions');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
});
test('malformedRequest', async () => {
// This test doesn't really make sense because
// there is no such thing as a 601 status code.
// So, what exactly is a malformed request?
mockPool
.intercept({
path: genPath('/malformedRequest'),
method: 'GET',
})
.reply(() => ({
statusCode: 405,
data: '',
}));
await expect(api.get('/malformedRequest')).rejects.toBeInstanceOf(DiscordAPIError);
});
test('abort', async () => {
mockPool
.intercept({
path: genPath('/abort'),
method: 'GET',
})
.reply(200, { message: 'Hello World' }, responseOptions)
.delay(100)
.times(3);
const controller = new AbortController();
const [aP2, bP2, cP2] = [
api.get('/abort', { signal: controller.signal }),
api.get('/abort', { signal: controller.signal }),
api.get('/abort', { signal: controller.signal }),
];
await expect(aP2).resolves.toStrictEqual({ message: 'Hello World' });
controller.abort();
// Abort mid-execution:
await expect(bP2).rejects.toThrowError('aborted');
// Abort scheduled:
await expect(cP2).rejects.toThrowError('Request aborted manually');
});