|
| 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 | +} |
0 commit comments