Обобщённая обёртка retry с бэкоффом, сохраняющая сигнатуру обёрнутой функции
Реализуйте withRetry, которая оборачивает любую асинхронную функцию и повторяет её с экспоненциальным бэкоффом.
Требования: параметры и разрешённый тип обёртки обязаны в точности совпадать с обёрнутой функцией — вызов обёртки принимает те же аргументы и выводит тот же результат. Пробуйте до attempts раз, засыпая на baseMs * 2 ** (i - 1) перед попыткой номер i, и пробрасывайте последнюю ошибку, если все попытки провалились. Никакого any, никаких as.
function withRetry<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
attempts: number,
baseMs: number,
): (...args: A) => Promise<R> {
// ваш код здесь
}
Допишите реализацию.
Сделайте обёртку обобщённой по кортежу параметров и результату: withRetry<A extends unknown[], R>(fn: (...args: A) => Promise<R>, …): (...args: A) => Promise<R>. Вывод типов заполняет A и R по месту вызова, поэтому обёртка сохраняет точную сигнатуру без any и без приведений. Тело крутит цикл attempts раз, ждёт вызов в try, засыпает на baseMs * 2 ** (i - 1) при провале и пробрасывает последнюю ошибку.
- ✗Скатываться к
any[]/Functionвместо обобщённого кортежа параметровA extends unknown[] - ✗Проглатывать итоговый провал — возвращать
undefinedвместо проброса последней ошибки - ✗Засыпать перед первой попыткой, из-за чего самый первый вызов задерживается зря
- →Как добавить предикат
shouldRetry(err: unknown): boolean, не ослабив типы? - →Почему
A extends unknown[]здесь безопаснее как ограничение, чемA extends any[]?
Решение
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function withRetry<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
attempts: number,
baseMs: number,
): (...args: A) => Promise<R> {
return async (...args: A): Promise<R> => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
if (i > 0) await sleep(baseMs * 2 ** (i - 1)); // задержки нет перед первой попыткой
try {
return await fn(...args);
} catch (err) {
lastError = err;
}
}
throw lastError; // все попытки провалились — отдаём последнюю ошибку
};
}
Почему сигнатура сохраняется
Два параметра типа несут всю форму функции:
A extends unknown[]— кортеж параметров. TypeScript выводит его изfnкак настоящий кортеж, поэтому(...args: A)у обёртки принимает ровно те же аргументы, что и оригинал.R— разрешённый тип. Обёртка возвращаетPromise<R>, а неPromise<unknown>.
declare function fetchUser(id: string, force: boolean): Promise<{ name: string }>;
const safeFetchUser = withRetry(fetchUser, 3, 100);
// ^? (id: string, force: boolean) => Promise<{ name: string }>
await safeFetchUser("u1", true); // ✅
await safeFetchUser(1); // ❌ ошибка типов — как и у оригинала
⚠️ A extends any[] тоже скомпилируется, но any в ограничении разрешает передать в fn(...args) что угодно, если позже кто-то тронет тело. unknown[] даёт ту же выводимость кортежа и не открывает дыру.
⚠️ Соблазн написать withRetry<F extends (...a: never[]) => Promise<unknown>>(fn: F): F кажется короче, но вернуть значение типа F из тела нельзя без приведения — новая стрелочная функция не является тем же самым F. Разложение на A и R — это как раз способ обойтись без as.