Когда вы разрабатываете на Node.js, обработка ошибок поначалу кажется почти слишком простой. Вы оборачиваете роут в try-catch, отправляете статус 500, и клиент сам решает, что делать дальше. Такая модель отлично работает для HTTP, но она рассыпается в тот момент, когда вы переходите к фоновым задачам. В системе очередей нет ожидающего клиента. Есть только воркер, полезная нагрузка (payload) и счетчик повторных попыток, который где-то в Redis, RabbitMQ или SQS неумолимо растет. Если вы будете обрабатывать ошибки так же, как неудачные веб-запросы, вы не просто потеряете одну транзакцию. Вы остановите весь конвейер, израсходуете вычислительные ресурсы или будете постоянно ронять воркеры на одном и том же «отравленном» сообщении.
Подход HTTP не работает в фоновых задачах
В цикле «запрос-ответ» петля обратной связи мгновенна. Пользователь нажимает кнопку, сервер выдает ошибку, и пользователь видит экран с уведомлением о сбое. Очистка ресурсов проходит просто. Воркер очереди живет в изоляции. Он забирает задачу, работает над ней несколько секунд или минут, а затем подтверждает успех. Если в процессе что-то идет не так, очередь не знает почему. Она знает только то, что подтверждение (acknowledgment) так и не пришло. В зависимости от вашей конфигурации, она будет повторять попытку, возможно, бесконечно. Одна некорректная полезная нагрузка может перебрасываться между воркерами сотни раз, тратя ресурсы CPU и скрываясь за легитимными задачами, которые действительно нуждаются в обработке.
Два вида ошибок
Первое правило отказоустойчивых очередей — перестать относиться ко всем ошибкам одинаково. Вам нужно мгновенно разделять ошибки на две группы.
Повторяемые ошибки (Retryable failures) являются временными. Представьте себе таймауты сети, ограничения частоты запросов (rate limits) стороннего API или сброс соединения с базой данных из-за кратковременного исчерпания пула соединений. Это симптомы живой системы, работающей под нагрузкой. Они могут завершиться успешно при следующей попытке через две минуты.
Постоянные ошибки (Permanent failures) — это «ядовитые пилюли» (poison pills). К ним относятся некорректные полезные нагрузки, ошибки валидации схемы или отсутствие обязательного поля из-за того, что вышестоящий сервис изменил свой контракт. Повторять такие попытки — пустая трата ресурсов. Они будут завершаться ошибкой точно так же и на сотой попытке.
Если ваш блок catch не может отличить одно от другого, ваша очередь работает вслепую.
Паттерн 1: Классифицируйте ошибки в блоке catch
Блок catch вашего воркера должен быть самым продуманным участком кода в файле. Когда возникает ошибка, немедленно изучите её. Это код ошибки ECONNRESET или таймаут? Поставьте задачу в очередь на повтор. Это SyntaxError, отказ валидации Joi или нарушение ограничения внешнего ключа? Сразу перемещайте её в очередь недоставленных сообщений (dead-letter queue, DLQ) и не учитывайте её в лимите повторных попыток.
Большинство библиотек очередей для Node.js, включая BullMQ и Bee Queue, позволяют определять кастомные стратегии задержки (backoff) и хуки для ошибок. Используйте их. Постоянная ошибка никогда не должна просто «засыпать» и пробовать снова три раза по умолчанию. Она должна быть эвакуирована из основной очереди, чтобы остальные задачи могли выполняться. DLQ сохраняет точную полезную нагрузку и контекст ошибки, что позволяет вам запустить задачу позже, когда вы исправите баг или обновите схему.
Паттерн 2: Экспоненциальная задержка с джиттером (Exponential Backoff with Jitter)
Мгновенные повторные попытки — это агрессивно. Если нижестоящая база данных и так задыхается под нагрузкой, повторные запросы каждые две секунды от пятидесяти воркеров окончательно её добьют. Вам нужно отступить и дать системе пространство для восстановления.
Используйте экспоненциальную задержку (exponential backoff). При первой ошибке подождите одну секунду. При второй — две. Затем четыре, затем восемь, вплоть до разумного предела, например, пяти минут. Но одной только задержки недостаточно. Если каждая упавшая задача будет использовать один и тот же интервал, они все одновременно «столкнутся», когда время ожидания истечет. Эта синхронизированная волна, иногда называемая эффектом «грохочущего стада» (thundering herd), может парализовать восстанавливающийся сервис.
Добавьте джиттер (jitter). Возьмите рассчитанную задержку и добавьте к ней случайный процент, скажем, от десяти до двадцати. Четыре секунды превратятся в 4,2 или 4,7. Эта простая случайность размывает пик повторных попыток и защищает вашу инфраструктуру от ударных волн.
Паттерн 3: Проектируйте с учетом идемпотентности
Здесь инфраструктура очередей встречается с бизнес-логикой. Представьте задачу, которая списывает деньги с клиента через платежный шлюз. Воркер успешно отправляет запрос на списание, но соединение разрывается до того, как он успеет записать результат в вашу базу данных или подтвердить выполнение задачи. Очередь видит ошибку. Она делает повторную попытку. С клиента списывают деньги дважды.
В Node.js предотвращайте это, делая каждый побочный эффект идемпотентным. Генерируйте ключ идемпотентности на основе ID задачи или специфического бизнес-идентификатора. Прежде чем проводить списание, отправлять письмо или корректировать остатки на складе, проверьте, не была ли эта работа уже выполнена. Передавайте этот ключ через всю цепочку — в вашу базу данных и в любые сторонние API, которые его принимают. Структурируйте свои задачи так, чтобы запуск одной и той же полезной нагрузки десять раз давал тот же результат, что и один запуск. Эта простая привычка устраняет целый класс багов, связанных с финансами и целостностью данных.
Паттерн 4: Относитесь к своей Dead-Letter Queue как к дашборду
DLQ — это не кладбище, куда отправляются «плохие» задачи, чтобы о них забыли. Это операционный инструмент, и он должен быть одним из наиболее тщательно отслеживаемых элементов вашей системы.
Настройте алерты, которые срабатывают при увеличении глубины DLQ. Даже одно сообщение в DLQ часто означает, что ваша логика валидации нарушена, изменилась схема upstream-сервиса или downstream-сервис присылает мусор, который вы больше не распознаете. Это именно те сигналы, которые вы хотите поймать до того, как начнут жаловаться клиенты. Создайте дашборд, который позволит вам изучать сырую полезную нагрузку, stack trace и временную метку. Подготовьте runbook: изучите ошибку, исправьте код, а затем повторно отправьте сообщения в правильном порядке. Если ваша реализация очереди это поддерживает, настройте оповещения не только на абсолютное количество сообщений, но и на скорость их роста, так как один неудачный деплой может за считанные минуты переполнить DLQ.
Паттерн 5: Если сомневаетесь — обрушивайте процесс
Node.js работает на одном цикле событий (event loop) внутри изолята V8. Необработанное отклонение промиса (unhandled promise rejection) или...
