Більшість туторіалів з Node.js розглядають обробку помилок як другорядну річ. Ви огортаєте обробник маршруту в блок try/catch, логуєте стек викликів і повертаєте 500. Такий підхід живе завдяки тому, що на іншому кінці HTTP-запиту чекає реальна людина. Фонові завдання — це інша справа. У системі черг немає нетерплячого клієнта, якому потрібно відповісти, немає автоматичного оновлення браузера. Є лише воркер, корисне навантаження (payload) та лічильник повторних спроб, що мовчки зростає. Коли щось іде не так, це стається повільно, а потім — усе й одразу. Одна неправильно класифікована помилка може зупинити весь пайплайн або розбудити інженера о третій годині ночі.
Розрив у розумінні простий. Цикли запит-відповідь завершуються швидко і гучно. Збій у черзі — тихий. Воркер може опрацювати сотні завдань, перш ніж розірветься з'єднання з базою даних. Без чітких правил обробки воркер миттєво намагається повторити спробу, «засипає» базу даних, яка й так працює на межі, і падає. Оскільки ніхто не спостерігає за воркером безпосередньо, першою ознакою проблем часто стає каскадне накопичення завдань або переповнений логами диск. Вам потрібно більше, ніж просто блоки catch. Вам потрібна стратегія, яка обробляє різні типи збоїв по-різному та захищає решту системи від одного невдалого завдання.
Два види помилок
Почніть із того, що розділіть кожну помилку на одну з двох категорій.
Помилки, що підлягають повторній спробі (retryable errors), є тимчасовими. Тайм-аут мережі при зверненні до стороннього API, відповідь 429 rate-limit або тимчасове відставання репліки бази даних від основного вузла. Це симптоми навантаження, а не баги. Система може відновитися сама за тридцять секунд. Завдання, що підлягають повторній спробі, заслуговують на ще один шанс, але лише за контрольованих умов.
Постійні помилки (permanent errors) — це помилки в логіці чи даних. Невалідний JSON у пейлоаді, відсутність ID користувача, необхідний файл, якого немає в сховищі. Вони проваляться на сотній спробі точно так само, як і на першій. Їх повторення марно витрачає цикли процесора, займає слоти в черзі та створює токсичний зворотний тиск (back-pressure), що затримує здорові завдання. Єдине корисне місце для постійної помилки — це лог, сповіщення або черга недоставлених повідомлень (dead-letter queue). Їй не місце в циклі повторних спроб.
Створіть механізм прийняття рішень
Класифікуйте негайно. Не залишайте це рішення на відкуп фреймворку черг. Щойно ви перехопили помилку, визначте її долю.
На практиці це означає створення власних класів помилок або функцій-обгорток, які перевіряють причину збою перед тим, як передати її далі. Якщо драйвер бази даних видає помилку скидання з'єднання (connection reset), ваш обробник має позначити її як таку, що підлягає повторній спробі. Якщо валідатор пейлоаду видає помилку невідповідності схеми, позначте її як постійну. Багато процесорів завдань за замовчуванням намагаються повторити все підряд, що є найдорожчим рішенням, яке ви можете прийняти. Відхиляйте постійні завдання миттєво. Або видаляйте їх, або перенаправляйте в dead-letter queue, де вони не зможуть отруїти основний пайплайн. Ця звичка запобігає ефекту снігової кулі набагато надійніше, ніж будь-яка зміна інфраструктури.
Відступайте, але розумніше
Коли ви все ж робите повторну спробу, ніколи не робіть це миттєво. Якщо база даних недоступна, шквал воркерів, що атакують її щосекунди, виглядає як DoS-атака зсередини. Використовуйте експоненціальну затримку (exponential backoff). Зачекайте одну хвилину, потім п'ять, потім п'ятнадцять. Дайте вихідній системі простір для відновлення.
Але самої лише експоненціальної затримки недостатньо. Якщо тисяча завдань провалюється одночасно через перезапуск сервісу, їхні графіки повторних спроб збігаться. Вони одночасно вдарять по цьому сервісу, коли він знову з'явиться в мережі, що потенційно знову його покладе. Додайте джиттер (jitter): невелике випадкове зміщення до кожної затримки. Розпорошення повторних спроб у межах кількох секунд запобігає синхронізованим «навалам». Математика проста, але стабільність, яку вона забезпечує, — величезна.
Зберігайте докази
Dead-letter queue — це ваш аудиторський слід, а не смітник. Коли завдання вичерпує всі свої повторні спроби, не видаляйте його просто так. Перемістіть усе корисне навантаження разом із контекстом помилки та історією спроб у DLQ.
Це зберігає докази. Людина може перевірити завдання, виправити баг і за потреби запустити його вручну. Що ще важливіше, стежте за глибиною вашої DLQ. Раптовий сплеск кількості завдань у dead-letter queue часто є найпершою ознакою невдалого розгортання, помилки у зміні схеми або порушення умов роботи зовнішнього постачальника. Сприймайте зростання DLQ як провідний індикатор, а не такий, що виявляється постфактум. Якщо ваша DLQ заповнюється, щось у вихідній системі змінилося, і ваша команда має про це дізнатися до того, як черга розростеться.
Проектуйте з урахуванням повторних спроб
Проєктуйте кожне завдання так, ніби воно запуститься двічі, бо це цілком можливо. Воркер може завершитися помилкою на середині обробки, бути перепризначеним і запуститися знову. Якщо ваше завдання списує кошти з клієнта, надсилає електронний лист або збільшує кількість товарів на складі, наївне повторне виконання призведе до дублювання.
Вирішенням є ідемпотентність. Перш ніж виконувати побічний ефект, перевірте, чи він уже відбувся. Використовуйте унікальний ідентифікатор із корисного навантаження (payload) завдання як ключ ідемпотентності. Зберігайте цей ключ у короткостроковому кеші або в таблиці бази даних із обмеженням унікальності (uniqueness constraint). Якщо ключ уже існує, пропустіть виконання та поверніть успішний результат. Це перетворює повторні спроби з ризику на безпечну бездіяльність (no-op). Це потребує лише кількох додаткових рядків коду, але вбереже вас від необхідності пояснювати фінансовому відділу, чому виручка подвоїлася за одну ніч.
Захистіть процес
Необмежені відхилення промісів (promise rejections) та випадкові винятки (exceptions) можуть без попередження вбити процес Node.js. Для воркера це означає втрачені завдання та оркестратор, який намагається перезапустити контейнер.
Зареєструйте глобальні обробники для unhandledRejection та uncaughtException. Їхнє завдання — не рятувати застосунок, а виконати мінімально необхідне очищення і завершити роботу. Дозвольте Docker, Kubernetes або systemd перезапустити воркер із чистим станом пам'яті. Спроби продовжувати роботу після спрацювання глобального обробника призводять до витоків пам'яті та пошкодження стану. Швидка й чиста смерть безпечніша за повільний процес-зомбі, який некоректно обробляє завдання. Довіртеся своєму оркестратору, щоб він повернув вас до життя; не намагайтеся перехитрити пошкоджену середу виконання (runtime).
Поважайте сигнали
Воркери завершують роботу під час розгортання, масштабування або ротації вузлів. Якщо ваш процес гине в ту ж мить, коли отримує SIGTERM, ви перериваєте будь-яке завдання, що виконується в цей момент. Це завдання може ніколи не завершитися, а його лічильник повторних спроб може навіть не встигнути збільшитися.
Слухайте сигнали SIGTERM та SIGINT. Коли надходить сигнал, припиніть витягувати нові завдання з черги. Завершіть поточне завдання, якщо це можливо. Встановіть жорсткий таймаут, наприклад тридцять секунд, після якого ви завершите роботу за будь-яких обставин. Таке коректне завершення (graceful shutdown) поважає чергу та дозволяє уникнути хибних помилок. Ваш конвеєр розгортання має вважати воркер, який завершив роботу коректно, справним, тоді як аварійне завершення воркера має викликати сповіщення.
Головний висновок
Надійна обробка черг — це не про відловлювання кожної помилки. Це про прийняття свідомих рішень для кожного сценарію відмови. Терпляче повторюйте тимчасові помилки. Швидко відсікайте постійні. Захищайте свої воркери від «натисків» (stampedes), захищайте дані за допомогою ключів ідемпотентності та дозволяйте процесам, що завершуються, виходити коректно. Коли кожен збій має визначений шлях, третя година ночі стає просто черговим часом доби. Ваш конвеєр продовжує працювати, а ваша команда — спати.
