javascript

AbortController в Node.js: отмена чего угодно

  • вторник, 24 марта 2026 г. в 00:00:02
https://habr.com/ru/companies/otus/articles/1012728/

Привет, Хабр!

У Node.js исторически была проблема с отменой операций. Запустил HTTP‑запрос — жди, пока не ответит или не упадёт по таймауту. Читаешь огромный файл — читай до конца. Запустил пачку промисов — сиди, смотри, как они доедают ресурсы. Механизма сказать «стоп, хватит» в языке просто не было. Кто‑то мастерил свои костыли на флагах, кто‑то использовал библиотеки вроде p-cancelable, но единого стандарта не существовало.

AbortController эту проблему решает. Пришёл он из браузерного API (там его придумали для отмены fetch), но в Node.js прижился настолько, что теперь поддерживается почти везде: fetch, fs, stream, child_process, setTimeout, EventEmitter, встроенный тест‑раннер.

Разберём, как он устроен, и где вообще полезен.

Механика: контроллер и сигнал

Вся идея укладывается в два объекта.

AbortController — тот, кто отменяет. AbortSignal — тот, кто слушает отмену. Контроллер создаёт сигнал, вы передаёте сигнал в операцию, а когда нужно отменить — дёргаете abort() на контроллере.

const controller = new AbortController();
const signal = controller.signal;

// Подписываемся на отмену
signal.addEventListener('abort', () => {
    console.log('Отменено!');
    console.log(signal.reason); // причина отмены
});

console.log(signal.aborted); // false — пока не отменён

controller.abort('Пользователь нажал Отмена');
// → Отменено!
// → Пользователь нажал Отмена

console.log(signal.aborted); // true — уже отменён

Контроллер одноразовый. Один abort() — и всё. Повторно вызвать можно, но ничего не произойдёт — сигнал уже сработал. Если нужна новая операция с возможностью отмены — создавайте новый контроллер.

Аргумент abort() — это reason. Может быть строкой, ошибкой, чем угодно. Если не передать — по умолчанию будет DOMException с именем AbortError.

fetch: таймаут без библиотек

Самый частый кейс — ограничить время HTTP‑запроса. До AbortController это выглядело так: Promise.race([fetch(url), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))]). Без комментариев.

Теперь:

// Способ 1: AbortSignal.timeout() — самый простой
try {
    const response = await fetch('https://api.example.com/data', {
        signal: AbortSignal.timeout(5000) // 5 секунд и всё
    });
    const data = await response.json();
} catch (err) {
    if (err.name === 'TimeoutError') {
        console.log('Таймаут — сервер не ответил за 5 секунд');
    }
}

AbortSignal.timeout() — статический метод, который создаёт сигнал с автоматическим таймаутом. Не нужен контроллер, не нужен setTimeout, не нужен clearTimeout. Одна строка.

Но если нужна отмена по условию (а не только по времени), нужен ручной контроллер:

// Способ 2: ручной контроллер — отмена по кнопке/событию/условию
const controller = new AbortController();

// Таймаут вручную
const timerId = setTimeout(() => controller.abort('timeout'), 5000);

try {
    const response = await fetch('https://api.example.com/data', {
        signal: controller.signal
    });
    clearTimeout(timerId); // Ответ пришёл — таймер не нужен
    return await response.json();
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Запрос отменён:', controller.signal.reason);
    } else {
        throw err; // Сетевая ошибка или что-то другое
    }
}

AbortSignal.timeout() бросает TimeoutError, а ручной abort() без аргумента — AbortError. Если вам нужно различать «пользователь отменил» и «сервер не ответил» — используйте ручной контроллер для первого и timeout() для второго. Или передавайте разные reason в abort().

AbortSignal.any(): комбинируем условия отмены

Реальная задача: запрос должен отмениться, если прошло 5 секунд, ИЛИ если пользователь нажал «Отмена», ИЛИ если компонент размонтировался. Три причины, один fetch.

AbortSignal.any() объединяет несколько сигналов в один:

const userController = new AbortController();
const cleanupController = new AbortController();

// Отменится по ЛЮБОМУ из трёх условий
const signal = AbortSignal.any([
    AbortSignal.timeout(5000),          // таймаут
    userController.signal,               // пользователь нажал Отмена
    cleanupController.signal,            // компонент размонтировался
]);

try {
    const response = await fetch(url, { signal });
    const data = await response.json();
} catch (err) {
    // signal.reason покажет причину того сигнала, который сработал первым
    console.log('Отменено:', signal.reason);
}

// Где-то в обработчике кнопки:
userController.abort('User cancelled');

// Где-то при размонтировании:
cleanupController.abort('Component unmounted');

Сработает тот сигнал, который придёт первым. Остальные игнорируются — результирующий сигнал уже aborted.

Файловая система

fs/promises поддерживает signal в readFile, writeFile, open, watch:

import { readFile, writeFile } from 'node:fs/promises';

const controller = new AbortController();

// Где-то может прийти отмена
setTimeout(() => controller.abort(), 3000);

try {
    const data = await readFile('/path/to/huge-file.csv', {
        signal: controller.signal,
        encoding: 'utf-8',
    });
    console.log(`Прочитано ${data.length} символов`);
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Чтение файла отменено — не успели за 3 секунды');
    } else {
        throw err;
    }
}

Для writeFile — то же самое. Если отмена пришла до завершения записи, файл может быть записан частично. Учитывайте это: пишите во временный файл, потом переименовывайте.

Стримовый API (createReadStream, createWriteStream) не принимает signal напрямую. Там нужно закрывать стрим вручную или использовать pipeline() из stream/promises, который signal поддерживает:

import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';

await pipeline(
    createReadStream('input.csv'),
    createWriteStream('output.csv'),
    { signal: AbortSignal.timeout(10000) }
);

Промис‑таймеры

Промис‑версии таймеров из node:timers/promises тоже поддерживают сигнал:

import { setTimeout as delay } from 'node:timers/promises';

const controller = new AbortController();

try {
    // Ждём 10 секунд, но можем отменить раньше
    const result = await delay(10000, 'готово', {
        signal: controller.signal
    });
    console.log(result); // 'готово' — если дождались
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Не дождались, отменили');
    }
}

Удобно для поллинга, ретраев, любых «подождать, но с возможностью прервать».

EventEmitter: автоматическая отписка

Малоизвестная, но очень полезная штучка. on() на EventEmitter принимает signal для автоматической отписки:

import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();
const controller = new AbortController();

emitter.on('data', (chunk) => {
    console.log('Получено:', chunk);
}, { signal: controller.signal });

emitter.emit('data', 'первый');   // → Получено: первый
emitter.emit('data', 'второй');   // → Получено: второй

controller.abort();

emitter.emit('data', 'третий');   // → тишина, слушатель отписан

Ещё один кейс — events.on() (async итератор по событиям):

import { on } from 'node:events';

const controller = new AbortController();

// Асинхронный итератор по событиям — с возможностью остановки
setTimeout(() => controller.abort(), 5000);

try {
    for await (const [data] of on(emitter, 'data', { signal: controller.signal })) {
        console.log(data);
        if (data === 'stop') controller.abort();
    }
} catch (err) {
    if (err.name === 'AbortError') {
        console.log('Итерация остановлена');
    }
}

Отмена при обрыве HTTP‑соединения

Клиент может закрыть вкладку или оборвать соединение. Если вы в этот момент ждёте ответ от тяжёлого внешнего API или считаете отчёт — зачем продо��жать? Получатель ушёл.

// Express
app.get('/api/report', async (req, res) => {
    const controller = new AbortController();

    // Клиент закрыл соединение — отменяем всё downstream
    req.on('close', () => controller.abort('Client disconnected'));

    try {
        // Тяжёлый запрос к внешнему API
        const raw = await fetch('https://analytics.internal/heavy-report', {
            signal: controller.signal,
        });
        const report = await raw.json();

        // Тяжёлая обработка — тоже проверяем отмену
        controller.signal.throwIfAborted();

        const processed = processReport(report);
        res.json(processed);
    } catch (err) {
        if (err.name === 'AbortError') {
            // Клиент ушёл — ответ отправлять некому, просто выходим
            return;
        }
        res.status(500).json({ error: 'Internal error' });
    }
});

Паттерн экономит серверные ресурсы.

Свой код с поддержкой AbortSignal

Если пишете библиотеку или утилиту, добавить поддержку отмены несложно. Просто принимаете signal в options, проверяете throwIfAborted() перед каждым шагом, передаёте signal во все вложенные операции.

async function pollUntilReady(url, { signal, interval = 2000 } = {}) {
    while (true) {
        // Проверяем перед каждой итерацией
        signal?.throwIfAborted();

        try {
            const res = await fetch(url, { signal });
            const data = await res.json();

            if (data.status === 'ready') {
                return data;
            }
        } catch (err) {
            // Если отмена — пробрасываем наверх
            if (err.name === 'AbortError') throw err;
            // Если сетевая ошибка — пробуем ещё раз
            console.log('Retry after error:', err.message);
        }

        // Пауза между попытками — тоже отменяемая
        await new Promise((resolve, reject) => {
            const timer = globalThis.setTimeout(resolve, interval);
            signal?.addEventListener('abort', () => {
                clearTimeout(timer);
                reject(signal.reason);
            }, { once: true });
        });
    }
}

// Использование: поллинг с таймаутом 30 секунд
const result = await pollUntilReady('https://api.example.com/job/123', {
    signal: AbortSignal.timeout(30000),
    interval: 3000,
});

signal.throwIfAborted() — бросает исключение, если сигнал уже сработал. Вызывайте в начале каждого шага, чтобы не делать лишнюю работу.

Отменяемый sleep — настолько частый паттерн, что имеет смысл вынести в утилиту:

function abortableSleep(ms, { signal } = {}) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(resolve, ms);
        signal?.addEventListener('abort', () => {
            clearTimeout(timer);
            reject(signal.reason);
        }, { once: true });
    });
}

Некоторые ошибки

  • Переиспользование контроллера. Один контроллер — одна логическая операция. Отменили — создали новый. Если повесить один контроллер на цикл запросов и вызвать abort() — отменятся все разом, включая будущие.

  • Забыли обработать AbortError. Без try/catch отмена уронит процесс. Всегда проверяйте err.name === 'AbortError' и решайте — это штатная ситуация или нет.

  • AbortController на синхронном коде. JSON‑парсинг, сортировка массива, валидация — отменять нечего. AbortController для async‑операций.

  • Утечка таймеров. Если создали setTimeout для ручного abort — не забудьте clearTimeout при успехе. Иначе таймер сработает уже после завершения операции.

Если хочется не просто писать серверный код на JavaScript, а уверенно разбираться в архитектуре Node.js-приложений, здесь нужна не подборка приёмов, а системная практика. На курсе «Node.js разработчик» как раз дают TypeScript, базы данных, тестирование и ключевые инструменты бэкенд-разработки в связке.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 26 марта в 20:00. «NestJS и архитектура масштабируемых серверных приложений на Node.js». Записаться

  • 9 апреля в 20:00. «Работа в реальном времени на Node.js и TypeScript: создаём WebSocket-чат и разбираем архитектуру серверной части». Записаться

  • 22 апреля в 20:00. «Bun + искусственный интеллект: создаём быстрый сервер нового поколения на JavaScript». Записаться