feat(fetch): plugin system + eslint/tsconfig migration
- Add fetch plugin API (definePlugin, plugins) with type-level option flow. - Migrate to eslint flat config and composite tsconfig.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { retryPlugin } from './retry';
|
||||
export { timeoutPlugin } from './timeout';
|
||||
@@ -0,0 +1,92 @@
|
||||
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),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { definePlugin } from '../plugin';
|
||||
|
||||
/**
|
||||
* Caller's original `signal`, captured once per request so each retry attempt
|
||||
* recombines a *fresh* timeout signal with it instead of reusing an already
|
||||
* aborted one. Keyed on the FetchContext to keep its hidden class stable.
|
||||
*/
|
||||
const baseSignals = new WeakMap<object, AbortSignal | undefined>();
|
||||
|
||||
/**
|
||||
* @name timeoutPlugin
|
||||
* @category Fetch
|
||||
* @description Composes an `AbortSignal.timeout(ms)` with any caller-supplied signal
|
||||
* when `options.timeout` is set.
|
||||
*
|
||||
* Implemented as an `execute` middleware (inner to `retry`) so every retry attempt
|
||||
* gets a brand-new timeout signal — a single timeout no longer poisons all
|
||||
* subsequent attempts. The timeout therefore applies per attempt, not to the whole
|
||||
* retry sequence.
|
||||
*
|
||||
* Auto-registered by `createFetch`; no-op when `timeout` is unset.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
export function timeoutPlugin() {
|
||||
return definePlugin({
|
||||
name: 'timeout',
|
||||
execute: async (context, next) => {
|
||||
const options = context.options;
|
||||
const timeout = options.timeout;
|
||||
if (timeout === undefined) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix the caller's signal once; reuse it across retry attempts.
|
||||
let base: AbortSignal | undefined;
|
||||
if (baseSignals.has(context)) {
|
||||
base = baseSignals.get(context);
|
||||
}
|
||||
else {
|
||||
base = options.signal as AbortSignal | undefined;
|
||||
baseSignals.set(context, base);
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||
options.signal = base === undefined
|
||||
? timeoutSignal
|
||||
: AbortSignal.any([timeoutSignal, base]);
|
||||
|
||||
await next();
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user