{ "index": 80, "slug": "editorial-2025-10-mechanism-teaching-engineering", "title": "Promise.all: почему первая ошибка не останавливает остальные операции", "excerpt": "Promise.all управляет итогом набора промисов, но не владеет внешней работой. Разбираем порядок результатов, ранний reject, отдельную отмену и безопасную проверку на небольшом примере.", "contentHtml": "

Сервис собирает профиль пользователя из трёх запросов: настройки, лимиты и историю операций. Один запрос завершается ошибкой. Обработчик сразу попадает в catch и возвращает ответ об ошибке. Через секунду другой запрос всё ещё пишет в лог, держит соединение или меняет локальное состояние.

\n

Симптом — разработчик видит отклонённый Promise.all и говорит: «набор остановился». Цена ошибки — лишняя нагрузка, гонка за общим состоянием и неверная очистка ресурсов. Код может повторить операцию, пока первый запуск ещё работает. Расследование усложняется: aggregate уже отклонён, а позднее событие живёт в другом promise.

\n

Тезис простой: Promise.all сообщает исход группы. Он не является командой отмены для входных promise и не знает, какая внешняя операция их породила. Ранний reject останавливает ожидание успешного aggregate, но не доказывает остановку работы. Для остановки нужен отдельный контракт: владелец сигнала, способ передать его операции и подтверждение результата.

\n

Механизм: три разных объекта

\n

Первый объект — входной promise. Он представляет один будущий исход: значение или ошибку. Второй — aggregate promise, который возвращает Promise.all(iterable). Он собирает значения по позиции входного iterable и принимает решение о собственном исходе. Третий — внешняя операция: HTTP-запрос, чтение файла, запрос к базе или вычисление. Она может существовать за пределами promise-модели.

\n

Связь направлена только в одну сторону. Вход сообщает aggregate, что он fulfilled или rejected. Aggregate сообщает вызывающему коду свой исход. Такая связь не содержит команды «остановись» для другого входа. Даже после reject aggregate другой вход может позже fulfilled или rejected.

\n
const 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. Никакой код не передал ему сигнал отмены. Поэтому позднее значение появится, даже если вызывающий код больше его не читает.

\n

Это учебный пример с фиксированными promise и таймером. Он не измеряет задержку сети, поведение браузера или расход ресурсов в production. Он проверяет только границу между исходом aggregate и исходом другого входа.

\n
Три уровня Promise.all: входной promise, aggregate promise и внешняя операция
Aggregate фиксирует свой исход. Из этого факта нельзя автоматически вывести отмену входа или внешней операции.
\n

Что можно вывести из исхода

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
catch сработал, но поздний лог продолжилсяДругой вход завершает собственную работуДобавить trace для каждого входаНе называть aggregate отменой; найти владельца
Значения пришли в неожиданном порядкеОжидалась скорость, а не позиция iterableСравнить индексы и порядок событийЧитать результат по позиции или хранить ключ
Нужно увидеть каждую ошибку, но видна однаPromise.all завершает aggregate на первом rejectПроверить требование к полному отчётуРассмотреть Promise.allSettled
После ошибки запросы продолжаютсяОперации не получили общий сигналПроверить API и обработчик сигналаПередать AbortSignal или другой cancel contract
Остановка считается доказанной по rejectСмешаны исход результата и управление ресурсомНазвать реальное действие прерыванияРазделить зависимую логику и протокол остановки
\n

Порядок результатов и первая ошибка

\n

При успешном исходе массив значений сохраняет порядок входного iterable, а не порядок завершения. Быстрый второй promise всё равно окажется во второй позиции. Это удобно для сопоставления, пока массив не меняется между запуском и чтением.

\n

При reject aggregate получает ошибку входа, который первым сообщил отклонение в рамках алгоритма наблюдения. Это не отчёт обо всех ошибках и не журнал времени завершения. Promise.allSettled решает другую задачу: ждёт завершения всех входов и возвращает статус каждого. Он тоже не добавляет отмену.

\n

Отмена живёт рядом, но отдельно

\n

Для операции, которая умеет принимать сигнал, отмену можно связать с обработкой aggregate. В браузерном или серверном коде это часто AbortController и его signal. Контроллер создаёт вызывающий код. Операции получают сигнал. При ошибке вызывающий код вызывает abort(). Конкретный API должен обработать сигнал по своему контракту.

\n
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() означает запрос на остановку поддерживаемой операции, а не откат каждого побочного эффекта.

\n

Проверка на маленькой трассировке

\n

Запишите события каждого уровня. Первый promise отклоняет aggregate. Затем ручной владелец второго promise вызывает сохранённый resolve. Ожидаемая последовательность показывает два факта: aggregate rejected, а другой вход позже fulfilled.

\n
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, который принимает сигнал, и проверка его наблюдаемого завершения.

\n

Порядок действий

\n
  1. Назовите каждый вход и внешнюю операцию, которая его создаёт.
  2. Запишите нужный результат: все значения, первый успешный, все статусы или раннее завершение зависимой логики.
  3. Снимите отдельный trace для входов и aggregate.
  4. Проверьте отрицательный путь: после первого reject другой вход может завершиться позже.
  5. Если нужна остановка, найдите API отмены и его владельца. Передайте сигнал до запуска.
  6. Проверьте, что обработчик сигнала меняет состояние нужной операции.
  7. Только затем выбирайте Promise.all, Promise.allSettled или другой способ композиции.
\n

Ограничения модели

\n

Promise.all не обещает параллельное выполнение в смысле потоков. JavaScript может передать управление между асинхронными продолжениями, а конкретный API сам определяет выполнение. Нельзя выводить пропускную способность, порядок сетевых пакетов или освобождение ресурса из одного вызова combinator.

\n

Нельзя считать abort() универсальным откатом. Он не возвращает отправленные данные, не отменяет побочный эффект на сервере и не исправляет уже завершённую запись. Он также не заменяет дедупликацию, идемпотентность и таймаут.

\n

Нельзя выбирать allSettled только потому, что первая ошибка неудобна. Если следующий шаг требует всех значений, ранний reject не даёт начать неполный расчёт. Если нужен отчёт о каждом независимом входе, подходит all-settled-семантика. Решение зависит от зависимости между работами.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда команда отвечает на четыре вопроса: какой promise отклоняет aggregate; какой вход может завершиться позже; какое действие останавливает внешнюю работу; какое наблюдение подтверждает остановку. Учебный код готов, если trace показывает независимые исходы. Код с реальными операциями готов, если тест проверяет поддержку сигнала и отрицательный путь, где один вход отвергнут, а другой ещё выполняется.

\n

Если на вопрос об остановке отвечают «Promise.all», модель не готова. Добавьте владельца отмены или честно зафиксируйте, что операция не поддерживает остановку.

\n

Проверяемые источники

" }