Większość samouczków Node.js traktuje obsługę błędów jako kwestię drugorzędną. Owijasz handler trasy w blok try/catch, logujesz stack trace i zwracasz 500. Takie podejście przetrwało, ponieważ po drugiej stronie żądania HTTP czeka żywy człowiek. Zadania w tle są inne. W systemie kolejek nie ma niecierpliwego klienta, któremu trzeba odpowiedzieć, ani automatycznego odświeżania przeglądarki. Jest tylko worker, payload i licznik prób ponowień, który po cichu tyka w górę. Gdy coś idzie nie tak, idzie to powoli, a potem nagle wszystko naraz. Jeden błędnie sklasyfikowany błąd może zatrzymać cały pipeline lub wezwać inżyniera o trzeciej nad ranem.
Rozdźwięk jest prosty. Cykle żądanie-odpowiedź (request-response) zawodzą szybko i głośno. Awaria kolejki jest cicha. Worker może przetworzyć setki zadań, zanim zerwie się połączenie z bazą danych. Bez jasnych zasad obsługi, worker natychmiast podejmuje ponowienie próby, bombarduje już i tak przeciążoną bazę danych i ulega awarii. Ponieważ nikt nie obserwuje workera bezpośrednio, pierwszym sygnałem problemu jest często kaskadowe zatory lub pełny dysk z plikami logów. Potrzebujesz czegoś więcej niż bloków catch. Potrzebujesz strategii, która traktuje różne awarie w różny sposób i chroni resztę systemu przed pojedynczym wadliwym zadaniem.
Dwa rodzaje awarii
Zacznij od podzielenia każdego błędu do jednej z dwóch kategorii.
Błędy możliwe do ponowienia (retryable errors) są przejściowe. Przekroczenie czasu oczekiwania na połączenie (timeout) z zewnętrznym API, odpowiedź 429 (rate-limit) lub tymczasowe opóźnienie repliki bazy danych względem głównej instancji. To objawy przeciążenia, a nie błędy w kodzie. System może sam się naprawić w ciągu trzydziestu sekund. Zadania typu retryable zasługują na kolejną próbę, ale tylko w kontrolowanych warunkach.
Błędy trwałe (permanent errors) to pomyłki. Nieprawidłowy JSON w payloadzie, brakujący identyfikator użytkownika, wymagany plik, którego nie ma w pamięci masowej. One zawiodą przy setnej próbie dokładnie tak samo, jak zawiodły przy pierwszej. Ich ponawianie marnuje cykle procesora, zajmuje miejsca w kolejce i tworzy toksyczne zjawisko back-pressure, które opóźnia poprawne zadania. Jedynym użytecznym miejscem dla trwałej awarii jest log, alert lub kolejka błędów (dead-letter queue). Nie powinna ona trafiać do pętli ponowień.
Zbuduj silnik decyzyjny
Klasyfikuj natychmiast. Nie zostawiaj tej decyzji frameworkowi kolejkowemu. W momencie złapania błędu zdecyduj o jego losie.
W praktyce oznacza to tworzenie własnych klas błędów lub funkcji opakowujących (wrapper functions), które analizują awarię przed przekazaniem jej dalej (bubbling up). Jeśli sterownik bazy danych wyrzuci błąd resetu połączenia, Twój handler powinien oznaczyć go jako retryable. Jeśli walidator payloadu wyrzuci błąd niezgodności schematu, oznacz go jako permanent. Wiele procesorów zadań domyślnie ponawia każdą próbę, co jest najdroższym możliwym wyborem. Odrzucaj trwałe zadania natychmiast. Porzuć je lub przekieruj do dead-letter queue, gdzie nie będą zatruwać głównego pipeline'u. Ten jeden nawyk zapobiega efektowi kuli śnieżnej skuteczniej niż jakakolwiek zmiana w infrastrukturze.
Wycofuj się, ale mądrzej
Kiedy już ponawiasz próbę, nigdy nie rób tego natychmiast. Jeśli baza danych nie działa, fala workerów bombardujących ją co sekundę wygląda jak atak DoS (denial-of-service) od wewnątrz. Stosuj wykładniczy backoff (exponential backoff). Poczekaj minutę, potem pięć, potem piętnaście. Daj systemowi nadrzędnemu przestrzeń na regenerację.
Jednak sam exponential backoff to za mało. Jeśli tysiąc zadań zawiedzie w tym samym czasie z powodu restartu usługi, ich harmonogramy ponowień się zsynchronizują. Uderzą w tę usługę jednocześnie, gdy tylko wróci do online, potencjalnie ją ponownie wyłączając. Dodaj jitter: niewielki, losowy przeskok (offset) do każdego opóźnienia. Rozproszenie ponowień w czasie kilku sekund zapobiega zsynchronizowanym „stado” (stampedes). Matematyka jest prosta, ale zyskana dzięki niej stabilność jest ogromna.
Zachowaj dowody
Dead-letter queue to Twój ślad audytowy, a nie kosz na śmieci. Gdy zadanie wyczerpie swoją ostatnią próbę ponowienia, nie usuwaj go po prostu. Przenieś cały payload wraz z kontekstem błędu i historią prób do DLQ.
Pozwala to zachować dowody. Człowiek może sprawdzić zadanie, naprawić błąd i w razie potrzeby uruchomić je ponownie ręcznie. Co ważniejsze, monitoruj głębokość swojej kolejki DLQ. Nagły wzrost liczby zadań w dead-letter queue jest często najwcześniejszym sygnałem błędnego wdrożenia, nieudanej zmiany schematu lub naruszenia umowy przez zewnętrznego dostawcę. Traktuj wzrost DLQ jako wskaźnik wyprzedzający (leading indicator), a nie opóźniony (trailing indicator). Jeśli Twoja kolejka DLQ się zapełnia, coś w systemie nadrzędnym uległo zmianie i Twój zespół musi o tym wiedzieć, zanim zaległości (backlog) się rozprzestrzenią.
Projektuj z myślą o ponowieniach
Projektuj każde zadanie tak, jakby miało zostać uruchomione dwukrotnie, ponieważ może tak być. Worker może zawieść w połowie przetwarzania, zostać ponownie zaplanowany i wykonać się ponownie. Jeśli Twoje zadanie obciąża klienta, wysyła e-mail lub zwiększa stan magazynowy, naiwna próba ponowienia stworzy duplikaty.
Rozwiązaniem jest idempotencja. Zanim wywołasz efekt uboczny, sprawdź, czy już się on nie wydarzył. Użyj unikalnego identyfikatora z payloadu zadania jako klucza idempotencji. Przechowuj ten klucz w krótkotrwałym cache'u lub w tabeli bazy danych z ograniczeniem unikalności. Jeśli klucz istnieje, pomiń pracę i zwróć sukces. To zmienia ponawianie prób z ryzyka w nieszkodliwe no-op. Wymaga to kilku dodatkowych linii kodu, ale oszczędzi Ci tłumaczenia się przed działem finansowym, dlaczego przychody podwoiły się z dnia na dzień.
Chroń proces
Nieobsłużone odrzucenia obietnic i przypadkowe wyjątki mogą bez ostrzeżenia zabić proces Node.js. W przypadku workera oznacza to porzucone zadania i orkiestrator gorączkowo próbujący zrestartować kontener.
Zarejestruj globalne handlery dla unhandledRejection i uncaughtException. Ich zadaniem nie jest ratowanie aplikacji. Mają one wykonać niezbędne, minimalne czyszczenie, a następnie zakończyć działanie. Pozwól, aby Docker, Kubernetes lub systemd zrestartował workera z czystym stanem pamięci. Próba kontynuowania pracy po wywołaniu globalnego handlera sprzyja wyciekom pamięci i uszkodzeniu stanu. Szybka, czysta śmierć jest bezpieczniejsza niż powolny proces-zombie, który błędnie przetwarza zadania. Zaufaj swojemu orkiestratorowi, że przywróci Cię do działania; nie próbuj przechytrzyć uszkodzonego środowiska uruchomieniowego.
Szanuj sygnały
Workery są wyłączane podczas wdrożeń, skalowania i rotacji węzłów. Jeśli Twój proces zginie w momencie otrzymania sygnału SIGTERM, przerwiesz każde zadanie, które jest w toku. To zadanie może nigdy się nie zakończyć, a jego licznik ponowień może nawet nie zostać jeszcze zwiększony.
Nasłuchuj sygnałów SIGTERM i SIGINT. Gdy nadejdzie sygnał, przestań pobierać nowe zadania z kolejki. Jeśli możesz, dokończ bieżące zadanie. Ustaw sztywny limit czasu, na przykład trzydzieści sekund, po którym wyjdziesz bez względu na wszystko. Takie łagodne zamknięcie (graceful shutdown) szanuje kolejkę i zapobiega fałszywym błędom. Twój potok wdrożeniowy powinien traktować workera, który zakończył pracę czysto, jako zdrowy, podczas gdy awaria workera powinna wyzwalać alert.
Najważniejsza lekcja
Niezawodne obsługiwanie kolejek nie polega na wyłapywaniu każdego błędu. Polega na podejmowaniu świadomych decyzji dla każdego trybu awarii. Cierpliwie ponawiaj próby w przypadku błędów przejściowych. Szybko porzucaj te trwałe. Chroń swoje workery przed nagłymi skokami obciążenia (stampedes), zabezpiecz dane kluczami idempotencji i pozwól umierającym procesom kończyć pracę w sposób uporządkowany. Gdy każda awaria ma zdefiniowaną ścieżkę, trzecia rano staje się po prostu kolejną godziną. Twój potok działa dalej, a Twój zespół może spać.
