{ "index": 80, "slug": "editorial-2025-10-mechanism-teaching-engineering", "title": "Promise.all: почему первая ошибка не останавливает остальные операции", "excerpt": "Promise.all управляет итогом набора промисов, но не владеет внешней работой. Разбираем порядок результатов, ранний reject, отдельную отмену и безопасную проверку на небольшом примере.", "contentHtml": "
Сервис собирает профиль пользователя из трёх запросов: настройки, лимиты и историю операций. Один запрос завершается ошибкой. Обработчик сразу попадает в catch и возвращает ответ об ошибке. Через секунду другой запрос всё ещё пишет в лог, держит соединение или меняет локальное состояние.
Симптом — разработчик видит отклонённый Promise.all и говорит: «набор остановился». Цена ошибки — лишняя нагрузка, гонка за общим состоянием и неверная очистка ресурсов. Код может повторить операцию, пока первый запуск ещё работает. Расследование усложняется: aggregate уже отклонён, а позднее событие живёт в другом promise.
Тезис простой: Promise.all сообщает исход группы. Он не является командой отмены для входных promise и не знает, какая внешняя операция их породила. Ранний reject останавливает ожидание успешного aggregate, но не доказывает остановку работы. Для остановки нужен отдельный контракт: владелец сигнала, способ передать его операции и подтверждение результата.
Первый объект — входной promise. Он представляет один будущий исход: значение или ошибку. Второй — aggregate promise, который возвращает Promise.all(iterable). Он собирает значения по позиции входного iterable и принимает решение о собственном исходе. Третий — внешняя операция: HTTP-запрос, чтение файла, запрос к базе или вычисление. Она может существовать за пределами promise-модели.
Связь направлена только в одну сторону. Вход сообщает aggregate, что он fulfilled или rejected. Aggregate сообщает вызывающему коду свой исход. Такая связь не содержит команды «остановись» для другого входа. Даже после reject aggregate другой вход может позже fulfilled или rejected.
\nconst left = Promise.reject(new Error('parse failed'));\nconst right = new Promise((resolve) => {\n setTimeout(() => resolve('right is done'), 100);\n});\n\ntry {\n await Promise.all([left, right]);\n} catch (error) {\n console.log('aggregate rejected:', error.message);\n}\n\n// Это не команда отмены для right.\nЗдесь Promise.all отклоняется из-за left. Ожидание текущей функции заканчивается. Но таймер получил отдельное право завершить right. Никакой код не передал ему сигнал отмены. Поэтому позднее значение появится, даже если вызывающий код больше его не читает.
Это учебный пример с фиксированными promise и таймером. Он не измеряет задержку сети, поведение браузера или расход ресурсов в production. Он проверяет только границу между исходом aggregate и исходом другого входа.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
catch сработал, но поздний лог продолжился | Другой вход завершает собственную работу | Добавить trace для каждого входа | Не называть aggregate отменой; найти владельца |
| Значения пришли в неожиданном порядке | Ожидалась скорость, а не позиция iterable | Сравнить индексы и порядок событий | Читать результат по позиции или хранить ключ |
| Нужно увидеть каждую ошибку, но видна одна | Promise.all завершает aggregate на первом reject | Проверить требование к полному отчёту | Рассмотреть Promise.allSettled |
| После ошибки запросы продолжаются | Операции не получили общий сигнал | Проверить API и обработчик сигнала | Передать AbortSignal или другой cancel contract |
| Остановка считается доказанной по reject | Смешаны исход результата и управление ресурсом | Назвать реальное действие прерывания | Разделить зависимую логику и протокол остановки |
При успешном исходе массив значений сохраняет порядок входного iterable, а не порядок завершения. Быстрый второй promise всё равно окажется во второй позиции. Это удобно для сопоставления, пока массив не меняется между запуском и чтением.
\nПри reject aggregate получает ошибку входа, который первым сообщил отклонение в рамках алгоритма наблюдения. Это не отчёт обо всех ошибках и не журнал времени завершения. Promise.allSettled решает другую задачу: ждёт завершения всех входов и возвращает статус каждого. Он тоже не добавляет отмену.
Для операции, которая умеет принимать сигнал, отмену можно связать с обработкой aggregate. В браузерном или серверном коде это часто AbortController и его signal. Контроллер создаёт вызывающий код. Операции получают сигнал. При ошибке вызывающий код вызывает abort(). Конкретный API должен обработать сигнал по своему контракту.
async function loadPages(urls) {\n const controller = new AbortController();\n const signal = controller.signal;\n\n try {\n return await Promise.all(\n urls.map((url) => fetch(url, { signal }).then((response) => {\n if (!response.ok) throw new Error('HTTP ' + response.status);\n return response.json();\n })),\n );\n } catch (error) {\n controller.abort();\n throw error;\n }\n}\nФрагмент ограничен операциями fetch, которые получили сигнал. Он не отменяет синхронную функцию, уже записанную транзакцию или promise, который игнорирует signal. abort() означает запрос на остановку поддерживаемой операции, а не откат каждого побочного эффекта.
Запишите события каждого уровня. Первый promise отклоняет aggregate. Затем ручной владелец второго promise вызывает сохранённый resolve. Ожидаемая последовательность показывает два факта: aggregate rejected, а другой вход позже fulfilled.
const trace = [];\nlet finishRight;\n\nconst left = Promise.reject(new Error('left failed'));\nconst right = new Promise((resolve) => {\n finishRight = () => {\n trace.push('right fulfilled');\n resolve('ok');\n };\n});\n\nawait Promise.all([left, right]).catch(() => trace.push('aggregate rejected'));\nfinishRight();\nawait right;\n\nconsole.log(trace);\n// ['aggregate rejected', 'right fulfilled']\nТрассировка не доказывает, что реальный запрос продолжился: в ней нет запроса. Она доказывает более узкое утверждение о promise, которым мы управляем вручную. Для сетевой отмены нужен отдельный тест API, который принимает сигнал, и проверка его наблюдаемого завершения.
\nPromise.all, Promise.allSettled или другой способ композиции.Promise.all не обещает параллельное выполнение в смысле потоков. JavaScript может передать управление между асинхронными продолжениями, а конкретный API сам определяет выполнение. Нельзя выводить пропускную способность, порядок сетевых пакетов или освобождение ресурса из одного вызова combinator.
Нельзя считать abort() универсальным откатом. Он не возвращает отправленные данные, не отменяет побочный эффект на сервере и не исправляет уже завершённую запись. Он также не заменяет дедупликацию, идемпотентность и таймаут.
Нельзя выбирать allSettled только потому, что первая ошибка неудобна. Если следующий шаг требует всех значений, ранний reject не даёт начать неполный расчёт. Если нужен отчёт о каждом независимом входе, подходит all-settled-семантика. Решение зависит от зависимости между работами.
Разбор готов, когда команда отвечает на четыре вопроса: какой promise отклоняет aggregate; какой вход может завершиться позже; какое действие останавливает внешнюю работу; какое наблюдение подтверждает остановку. Учебный код готов, если trace показывает независимые исходы. Код с реальными операциями готов, если тест проверяет поддержку сигнала и отрицательный путь, где один вход отвергнут, а другой ещё выполняется.
\nЕсли на вопрос об остановке отвечают «Promise.all», модель не готова. Добавьте владельца отмены или честно зафиксируйте, что операция не поддерживает остановку.
AbortController для отмены поддерживаемых fetch-запросов.