Retries, timeouts и heartbeat внешней операции
Workflow уже ждёт редактора, но после approve ему нужно вызвать внешнюю публикацию. Gateway может быть временно недоступен, а ответ может потеряться. Теперь сделаем retry и timeout явными. Основной результат урока — описать пределы ожидания и различить повторяемую техническую ошибку от предметного отказа, который новая попытка сама не исправит.
Полный Workflow находится в 06-retries.ts, Activities — в общем activities.ts. Автор не выполнял ошибочные запросы, heartbeat или повторы. Числа выбраны для короткой локальной демонстрации и не являются измеренными параметрами реальной редакционной системы.
Timeout попытки и timeout всей операции
Параметры proxyActivities:
const { reserveSlot, publish, releaseSlot } = proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
scheduleToCloseTimeout: '1 minute',
heartbeatTimeout: '8 seconds',
retry: {
initialInterval: '1 second',
backoffCoefficient: 2,
maximumInterval: '10 seconds',
maximumAttempts: 5,
},
});
Start-to-close ограничивает отдельную начатую попытку. Schedule-to-close задаёт общий предел от планирования Activity до окончательного завершения с учётом ожидания и повторов. Поэтому пять попыток не гарантируются безусловно: общий минутный предел может наступить раньше, особенно если очередь долго ждала исполнителя.
Эти настройки относятся к каждой Activity, а не ко всему Workflow. ReserveSlot и publish имеют собственные вызовы и собственные временные границы. Окно решения редактора также остаётся отдельным предметным таймером. Не нужно объявлять весь Workflow неуспешным через десять секунд только потому, что HTTP-операции следует отвечать быстро.
Schedule-to-start timeout существует для ожидания взятия задачи Worker, но в нашем наборе не используется как автоматический способ «лечить очередь». Если исполнителя нет, полезнее установить причину отсутствия poller и наблюдать задержку. Короткий timeout очереди не создаёт новый Worker и способен лишь превратить ожидание в отказ.
Правила видов timeout доступны в документации Activity Timeouts. Для нашей схемы достаточно двух пределов выполнения и понятной retry-политики; heartbeat отдельно описывает взаимодействие активной попытки с Service.
Повтор с увеличением интервала
Initial interval начинается с секунды, backoff coefficient равен двум, maximum interval ограничивает дальнейший рост десятью секундами. Идея — не обращаться к временно недоступному сервису непрерывно. Предел attempts и общий срок задают конечную границу: заявка не должна выглядеть бесконечно ожидающей публикации без отдельного объяснения.
Для веб-разработчика важно не считать retry доказательством безопасности. Если повторяемая операция создаёт новый пост каждый раз, пять попыток могут создать пять публикаций. Политика говорит, когда повторить, но не отвечает, что именно произойдёт при повторе. В следующем уроке разберём стабильный ключ и внешний контракт идемпотентности.
В Activities обычная ошибка сети или HTTP 500 становится ошибкой попытки. Gateway status 400 или 409 переводится в ApplicationFailure.nonRetryable с типом EditorialConflict. Такой отказ обозначает проблему операции, например повторное использование ключа для других данных или публикацию без брони.
Если gateway возвращает конфликт, многократное ожидание не изменит содержание запроса. Нужны исправление данных, другой жизненный цикл заявки или ручное решение. Если сервис временно перегружен, повтор может быть полезен. Эти категории должны определяться контрактом внешнего API, а не предположением «все 4xx всегда одинаковые».
HTTP-пределы внутри Activity
Полный effect в activities.ts использует fetch с объединённым AbortSignal:
signal: AbortSignal.any([
cancellationSignal(),
AbortSignal.timeout(5000),
]),
Пятисекундный предел относится к HTTP-запросу Node. Он меньше start-to-close timeout в десять секунд, оставляя время обработать результат и сообщить завершение. Если ограничить только Activity со стороны Service, локальный I/O может продолжать работу после признанного timeout, пока код не умеет его прервать.
Activity timeout и AbortSignal решают разные части задачи. Первый ограничивает ожидание результата в модели Temporal, второй воздействует на текущий HTTP-запрос. Ни один из них не отменяет автоматически уже совершённый внешний эффект. Если gateway успел записать публикацию, прекращение соединения не возвращает его состояние назад.
CancellationSignal связывает запрос отмены Activity с I/O. Но отмена имеет свой протокол доставки; нельзя считать её мгновенной по факту вызова cancel у клиента. Поэтому Activity также отправляет heartbeat, а Workflow позже выберет подходящую cancellation policy. В этом уроке рассматриваем только необходимые компоненты.
Для длинной операции heartbeat должен происходить периодически и содержать полезное состояние продвижения. Наш короткий HTTP-вызов посылает heartbeat в начале и имеет собственный предел пять секунд. При heartbeat timeout восемь секунд такого короткого I/O обычно достаточно для иллюстрации, но этот приём нельзя перенести без изменения на часовой импорт.
Ошибка попытки и итоговая ошибка процесса
Предел retry выбирают вместе с требованиями пользователя. Минута ожидания может быть приемлемой для фоновой публикации, но слишком длинной для обычного интерактивного ответа. Здесь Client лишь запускает заявку, поэтому длительное восстановление не удерживает исходный пользовательский HTTP-запрос.
Промежуточная неудача Activity не обязана немедленно завершить Workflow. Service может запланировать повтор согласно политике. Workflow получает результат или окончательную ActivityFailure после исчерпания возможностей. Поэтому пользовательское сообщение «ошибка публикации» лучше связывать с предметным итогом, а не с первой строкой сетевой ошибки в журнале.
При расследовании смотрят исходную причину, номер попытки и retry state. ActivityFailure может содержать ApplicationFailure с предметным типом. Это помогает отличить исчерпание попыток от non-retryable отказа. Не нужно превращать все ошибки в пустое catch { return published }: такой код скрывает неподтверждённый внешний эффект.
Пока стадия 06 не описывает полную компенсацию после любой окончательной ошибки. Если публикация не удалась после брони, Workflow завершится ошибкой, а бронь может остаться. Это известное промежуточное состояние; в девятом уроке добавим предметную cleanup-модель. Retry не является её заменой.
Для самостоятельного наблюдения можно запустить Worker стадии 06 без доступного gateway и начать новую заявку. Ожидаются неудачные попытки reserveSlot и затем окончательный отказ по настроенным пределам. Здесь такой сценарий не выполнялся; нет утверждения о точном времени или полученном журнале. После опыта возвращают обычный gateway перед продолжением.
Теперь пределы операций выражены явно, а повторяемые и предметные ошибки различаются. Следующий урок разбирает идемпотентную публикацию после потери ответа, где retry становится безопасным благодаря внешнему контракту.