a7e668ced8
- Add fetch plugin API (definePlugin, plugins) with type-level option flow. - Migrate to eslint flat config and composite tsconfig.
93 lines
3.0 KiB
TypeScript
93 lines
3.0 KiB
TypeScript
import type { FetchContext, ResolvedFetchOptions } from '../types';
|
|
import { isFunction, isNumber, retry } from '@robonen/stdlib';
|
|
import { definePlugin } from '../plugin';
|
|
import { isPayloadMethod } from '../utils';
|
|
|
|
/** HTTP status codes that trigger automatic retry by default */
|
|
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
|
408, // Request Timeout
|
|
409, // Conflict
|
|
425, // Too Early (Experimental)
|
|
429, // Too Many Requests
|
|
500, // Internal Server Error
|
|
502, // Bad Gateway
|
|
503, // Service Unavailable
|
|
504, // Gateway Timeout
|
|
]);
|
|
|
|
const ABORT_ERROR_NAME = 'AbortError';
|
|
|
|
/**
|
|
* Compute the number of retries for a given request. Module-scope so the
|
|
* function site stays monomorphic (always sees `ResolvedFetchOptions` / string).
|
|
*/
|
|
function computeMaxRetries(options: ResolvedFetchOptions, method: string): number {
|
|
const retryOpt = options.retry;
|
|
if (retryOpt === false) return 0;
|
|
if (isNumber(retryOpt)) return retryOpt;
|
|
return isPayloadMethod(method) ? 0 : 1;
|
|
}
|
|
|
|
/** True when the current response status is in the effective retry-status allowlist. */
|
|
function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boolean {
|
|
const list = options.retryStatusCodes;
|
|
return list !== undefined
|
|
? list.includes(status)
|
|
: DEFAULT_RETRY_STATUS_CODES.has(status);
|
|
}
|
|
|
|
/**
|
|
* @name retryPlugin
|
|
* @category Fetch
|
|
* @description Retries failed attempts based on status code, respecting
|
|
* `retry` / `retryDelay` / `retryStatusCodes` request options.
|
|
*
|
|
* Auto-registered by `createFetch`; disable per-request via `retry: false`.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
export function retryPlugin() {
|
|
return definePlugin({
|
|
name: 'retry',
|
|
execute: async (context, next) => {
|
|
const options = context.options;
|
|
const maxRetries = computeMaxRetries(options, options.method ?? 'GET');
|
|
|
|
// Fast path — no retries requested; avoid the stdlib retry wrapper
|
|
if (maxRetries === 0) {
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
const retryDelay = options.retryDelay;
|
|
const delay = isFunction(retryDelay)
|
|
? () => (retryDelay as (ctx: FetchContext) => number)(context)
|
|
: (retryDelay ?? 0);
|
|
|
|
await retry(
|
|
async ({ stop }) => {
|
|
try {
|
|
await next();
|
|
}
|
|
catch (error) {
|
|
// User-initiated abort must never be retried. `AbortSignal.timeout`
|
|
// aborts with a `TimeoutError`, so a plain `AbortError` is always
|
|
// caller-driven and should stop the retry loop immediately.
|
|
const err = context.error;
|
|
if (err !== undefined && err.name === ABORT_ERROR_NAME) {
|
|
stop(error);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
{
|
|
// stdlib retry counts total attempts; fetch `retry` means retries only
|
|
times: maxRetries + 1,
|
|
delay,
|
|
shouldRetry: () => shouldRetryStatus(options, context.response?.status ?? 500),
|
|
},
|
|
);
|
|
},
|
|
});
|
|
}
|