AbortSignal.timeout() и типобезопасная композиция AbortController в асинхронных цепочкахПри работе с асинхронными операциями в Node.js или браузере часто используют
AbortSignal.timeout() для ограничения времени выполнения. Это удобно, но таит подводные камни, особенно в цепочках promise или при композиции нескольких timeout-сигналов. Разработчики нередко забывают про race condition между завершением запроса и отменой, а также про невозможность отличить таймаут от ручного прерывания без явной настройки причины.Проблема: гонка состояний и необработанные ошибки
Когда
AbortSignal.timeout(5000) передаётся в fetch, а запрос завершается раньше таймаута, сигнал просто игнорируется — это нормально. Но если запрос падает с ошибкой до срабатывания таймаута, сам таймаут всё равно вызывает abort. Возникает ситуация: ошибка уже обработана в catch, а AbortError от таймаута остаётся необработанным. В консоли появляется unhandled rejection.Пример: если fetch упадёт с 500-м ответом на 3-й секунде, а таймаут срабатывает на 5-й, то второй reject — от таймаута — не будет пойман. Это классический race condition, который рвет цепочку error handling.
Отсутствие различимости таймаутов
AbortSignal.timeout() бросает DOMException с именем AbortError. Свой AbortController при вызове .abort() тоже создаёт такой же exception. В итоге в catch нельзя отличить, что пошло не так: таймаут или ручная отмена. Единственный способ — задать signal.reason при abort, но по умолчанию он undefined. Это делает диагностику сложной, особенно в production.try {
await fetch(url, { signal });
} catch (err) {
if (err.name === 'AbortError') {
// Что это? Таймаут? Ручной abort? Непонятно.
}
}Типобезопасная композиция: решение для цепочек
Для асинхронных операций, где нужно несколько таймаутов (например, сначала fetch, потом обработка данных),
AbortSignal.any() — не выход: он не сохраняет причину и не убирает race condition. Вместо этого используйте композицию контроллеров с явным контролем таймаутов.Пример простой утилиты для оборачивания promise с таймаутом:
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(new Error('Timeout')), ms);
return Promise.race([
promise,
new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(controller.signal.reason));
})
]).finally(() => clearTimeout(id));
}Здесь таймаут генерирует кастомную ошибку с понятным сообщением, которую можно отловить. Для цепочек — складывайте контроллеры в массив и управляйте ими вручную:
const acc = new AbortController();
const fetchPromise = fetch(url, { signal: acc.signal });
const timeoutId = setTimeout(() => acc.abort(new Error('Fetch timeout')), 5000);
fetchPromise.finally(() => clearTimeout(timeoutId));
Типичная ошибка при композиции
Попытка объединить
AbortSignal.timeout() с собственным контроллером через AbortSignal.any() приводит к потере контроля над причиной и времени отмены. Если таймаут от AbortSignal.timeout() сработает первым, ваш контроллер никогда не будет вызван, что приводит к утечке ресурсов (например, незавершённый HTTP-запрос).Вывод: Не используйте
AbortSignal.timeout() в production без явной обработки причины и контроля race condition — для асинхронных цепочек надёжнее собрать композицию вручную с кастомными ошибками и явным управлением таймаутами.