feat(rest): callbacks for timeout and retry backoff (#11067)

* feat(rest): callbacks for timeout and retry backoff

* test: add tests for callback utils

* test: fix typo

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* fix(retryBackoff): efficient math

* docs: minor tweaks

* docs: captalisation

---------

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
This commit is contained in:
ckohen
2025-09-10 02:23:28 -07:00
committed by GitHub
parent 5d5a6945e4
commit f1bcff46b6
8 changed files with 263 additions and 6 deletions

View File

@@ -141,7 +141,7 @@ export class BurstHandler implements IHandler {
// Since this is not a server side issue, the next request should pass, so we don't bump the retries counter
return this.runRequest(routeId, url, options, requestData, retries);
} else {
const handled = await handleErrors(this.manager, res, method, url, requestData, retries);
const handled = await handleErrors(this.manager, res, method, url, requestData, retries, routeId);
if (handled === null) {
// eslint-disable-next-line no-param-reassign
return this.runRequest(routeId, url, options, requestData, ++retries);

View File

@@ -420,7 +420,7 @@ export class SequentialHandler implements IHandler {
// Since this is not a server side issue, the next request should pass, so we don't bump the retries counter
return this.runRequest(routeId, url, options, requestData, retries);
} else {
const handled = await handleErrors(this.manager, res, method, url, requestData, retries);
const handled = await handleErrors(this.manager, res, method, url, requestData, retries, routeId);
if (handled === null) {
// eslint-disable-next-line no-param-reassign
return this.runRequest(routeId, url, options, requestData, ++retries);

View File

@@ -5,7 +5,7 @@ import { DiscordAPIError } from '../errors/DiscordAPIError.js';
import { HTTPError } from '../errors/HTTPError.js';
import { RESTEvents } from '../utils/constants.js';
import type { ResponseLike, HandlerRequestData, RouteData } from '../utils/types.js';
import { parseResponse, shouldRetry } from '../utils/utils.js';
import { normalizeRetryBackoff, normalizeTimeout, parseResponse, shouldRetry, sleep } from '../utils/utils.js';
let authFalseWarningEmitted = false;
@@ -65,7 +65,10 @@ export async function makeNetworkRequest(
retries: number,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), manager.options.timeout);
const timeout = setTimeout(
() => controller.abort(),
normalizeTimeout(manager.options.timeout, routeId.bucketRoute, requestData.body),
);
if (requestData.signal) {
// If the user signal was aborted, abort the controller, else abort the local signal.
// The reason why we don't re-use the user's signal, is because users may use the same signal for multiple
@@ -81,6 +84,21 @@ export async function makeNetworkRequest(
if (!(error instanceof Error)) throw error;
// Retry the specified number of times if needed
if (shouldRetry(error) && retries !== manager.options.retries) {
const backoff = normalizeRetryBackoff(
manager.options.retryBackoff,
routeId.bucketRoute,
null,
retries,
requestData.body,
);
if (backoff === null) {
throw error;
}
if (backoff > 0) {
await sleep(backoff);
}
// Retry is handled by the handler upon receiving null
return null;
}
@@ -117,6 +135,7 @@ export async function makeNetworkRequest(
* @param url - The fully resolved url to make the request to
* @param requestData - Extra data from the user's request needed for errors and additional processing
* @param retries - The number of retries this request has already attempted (recursion occurs on the handler)
* @param routeId - The generalized API route with literal ids for major parameters
* @returns The response if the status code is not handled or null to request a retry
*/
export async function handleErrors(
@@ -126,11 +145,27 @@ export async function handleErrors(
url: string,
requestData: HandlerRequestData,
retries: number,
routeId: RouteData,
) {
const status = res.status;
if (status >= 500 && status < 600) {
// Retry the specified number of times for possible server side issues
if (retries !== manager.options.retries) {
const backoff = normalizeRetryBackoff(
manager.options.retryBackoff,
routeId.bucketRoute,
status,
retries,
requestData.body,
);
if (backoff === null) {
throw new HTTPError(status, res.statusText, method, url, requestData);
}
if (backoff > 0) {
await sleep(backoff);
}
return null;
}

View File

@@ -25,6 +25,7 @@ export const DefaultRestOptions = {
offset: 50,
rejectOnRateLimit: null,
retries: 3,
retryBackoff: 0,
timeout: 15_000,
userAgentAppendix: DefaultUserAgentAppendix,
version: APIVersion,

View File

@@ -114,12 +114,18 @@ export interface RESTOptions {
* @defaultValue `3`
*/
retries: number;
/**
* The time to exponentially add before retrying a 5xx or aborted request
*
* @defaultValue `0`
*/
retryBackoff: GetRetryBackoffFunction | number;
/**
* The time to wait in milliseconds before a request is aborted
*
* @defaultValue `15_000`
*/
timeout: number;
timeout: GetTimeoutFunction | number;
/**
* Extra information to add to the user agent
*
@@ -203,6 +209,30 @@ export type RateLimitQueueFilter = (rateLimitData: RateLimitData) => Awaitable<b
*/
export type GetRateLimitOffsetFunction = (route: string) => number;
/**
* A function that determines the backoff for a retry for a given request.
*
* @param route - The route that has encountered a server-side error
* @param statusCode - The status code received or `null` if aborted
* @param retryCount - The number of retries that have been attempted so far. The first call will be `0`
* @param requestBody - The body that was sent with the request
* @returns The delay for the current request or `null` to throw an error instead of retrying
*/
export type GetRetryBackoffFunction = (
route: string,
statusCode: number | null,
retryCount: number,
requestBody: unknown,
) => number | null;
/**
* A function that determines the timeout for a given request.
*
* @param route - The route that is being processed
* @param body - The body that will be sent with the request
*/
export type GetTimeoutFunction = (route: string, body: unknown) => number;
export interface APIRequest {
/**
* The data that was used to form the body of this request

View File

@@ -2,7 +2,13 @@ import type { RESTPatchAPIChannelJSONBody, Snowflake } from 'discord-api-types/v
import type { REST } from '../REST.js';
import { RateLimitError } from '../errors/RateLimitError.js';
import { RequestMethod } from './types.js';
import type { GetRateLimitOffsetFunction, RateLimitData, ResponseLike } from './types.js';
import type {
GetRateLimitOffsetFunction,
GetRetryBackoffFunction,
GetTimeoutFunction,
RateLimitData,
ResponseLike,
} from './types.js';
function serializeSearchParam(value: unknown): string | null {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
@@ -157,3 +163,39 @@ export function normalizeRateLimitOffset(offset: GetRateLimitOffsetFunction | nu
const result = offset(route);
return Math.max(0, result);
}
/**
* Normalizes the retry backoff used to add delay to retrying 5xx and aborted requests.
* Applies a Math.max(0, N) to prevent negative backoffs, also deals with callbacks.
*
* @internal
*/
export function normalizeRetryBackoff(
retryBackoff: GetRetryBackoffFunction | number,
route: string,
statusCode: number | null,
retryCount: number,
requestBody: unknown,
): number | null {
if (typeof retryBackoff === 'number') {
return Math.max(0, retryBackoff) * (1 << retryCount);
}
// No need to Math.max as we'll only set the sleep timer if the value is > 0 (and not equal)
return retryBackoff(route, statusCode, retryCount, requestBody);
}
/**
* Normalizes the timeout for aborting requests. Applies a Math.max(0, N) to prevent negative timeouts,
* also deals with callbacks.
*
* @internal
*/
export function normalizeTimeout(timeout: GetTimeoutFunction | number, route: string, requestBody: unknown): number {
if (typeof timeout === 'number') {
return Math.max(0, timeout);
}
const result = timeout(route, requestBody);
return Math.max(0, result);
}