Promise.race([fetch(), timeout]) часто маскирует проблему: ждать перестали, но работа могла не остановиться. В SPA, SSR, Node.js-сервисах, SDK и API clients это приводит к висящим запросам, таймерам и retry/backoff ниже по стеку.Собирайте отмену вокруг AbortSignal
AbortSignal.timeout(ms) сам отменится по таймауту.AbortSignal.any([...signals]) отменится, когда отменится любой входной signal.Так в один контракт попадают:
* caller отменил операцию
* истек timeout
* клиент закрыл соединение
* сервис уходит в graceful shutdown
const shutdown = new AbortController();
async function loadUser(id: string, opts: { signal?: AbortSignal } = {}) {
const signal = AbortSignal.any([
AbortSignal.timeout(1500),
shutdown.signal,
...(opts.signal ? [opts.signal] : []),
]);
signal.throwIfAborted();
const res = await fetch(`https://api.example.com/users/${id}`, { signal });
await sleep(100, signal); // backoff тоже отменяем
const data = await res.json();
signal.throwIfAborted();
return data;
}
Типичная ошибка
Не останавливайтесь на
fetch. Один и тот же signal стоит передавать в retry, polling, очереди, sleep/timer helpers и свои async-функции. Иначе верхний слой "отменился", а нижний продолжает держать ресурсы.Практические нюансы
*
AbortSignal одноразовый: если aborted === true, нужен новый controller или timeout*
AbortSignal.any() - это fan-in, а не fan-out: он не отменяет исходные контроллеры* смотрите на
reason: timeout часто дает TimeoutError, abort - AbortError или ваш reason* при обертках над
setTimeout, stream, listener или socket чистите ресурсы при abortВывод:
Отмена должна быть частью контракта async-функции, а не локальным
Promise.race на краю системы.
