
Zerwane połączenie nie powinno wywalać całej aplikacji — a nasze wywalało
Gdy nasz backend padł w trakcie spotkania, nie tylko wstrzymał transkrypcję — wywalił całą aplikację. Przyczyną było jedno nieobsłużone zdarzenie, a naprawa miała dziesięć linii. To klaster wydań, dzięki któremu GeekBye zostaje zalogowany i połączony przez rzeczy, które kiedyś go zabijały.
Niektóre błędy przerywają funkcję. Najgorsze walą wszystko wokół niej. We wczesnym buildzie GeekBye, jeśli nasz backend padł, gdy byłeś w trakcie spotkania, transkrypcja nie tylko się zatrzymywała — wywalała się cała aplikacja. Jedno zerwane połączenie i całe okno znikało.
Klaster wydań, który to naprawił — od v1.6.8 do v1.6.11 — to majstersztyk nieefektownej pracy w duchu „zostań zalogowany, zostań połączony". Sztandarowa naprawa miała dziesięć linii. Oto czym były te dziesięć linii i wszystko inne, co dostarczyliśmy razem z nimi.
Dziesięć linii między rozłączeniem a wywaleniem
Awaria żyła w kodzie audio. Gdy sesja transkrypcji burzyła swoje WebSockety, wywoływała na nich removeAllListeners() — rozsądne sprzątanie. Ale oto szczegół Node.js, który zmienia sprzątanie w katastrofę: zdarzenie error bez nasłuchiwacza nie jest ignorowane. Jest ponownie rzucane jako nieprzechwycony wyjątek.
Wyobraź więc sobie sekwencję, gdy backend pada: gniazdo ma zdarzenie error ustawione w kolejce do wystrzelenia. Sprzątanie uruchamia removeAllListeners(), zdejmując obsługę, która by je przechwyciła. Chwilę później zakolejkowany błąd wystrzeliwuje — w pustkę. Node widzi zdarzenie error, którego nikt nie nasłuchuje, i robi jedyną rzecz, na jaką pozwala jego kontrakt: rzuca je jako nieprzechwycony wyjątek, co wywala proces. Backend użytkownika czknął na dwie sekundy, a jego aplikacja zniknęła.
Naprawa (v1.6.8) jest niemal antyklimaktyczna. Tuż po zdjęciu nasłuchiwaczy podepnij ponownie celową pustą obsługę error:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
Ta pusta funkcja to cały sens. Daje oczekującemu błędowi gdzie wylądować — pochłaniacz — więc Node ma nasłuchiwacza i nigdy nie rzuca ponownie. Ten sam commit posprzątał też półotwarte gniazda, gdy próba połączenia zawodzi w połowie, tak by spartaczone połączenie nie mogło zostawić żywego gniazda dyndającego z czekającym błędem. Komentarz commita mówi to wprost: „Bez tego oczekujące błędy stają się nieprzechwyconymi wyjątkami i wywalają aplikację." Dziesięć linii zamieniło pełne wywalenie w ciche, odzyskiwalne rozłączenie.
Lekcja jest warta więcej niż naprawa: za każdym razem, gdy wywołujesz removeAllListeners() na gnieździe lub strumieniu, który być może wciąż burzysz, najpierw podepnij ponownie pusty pochłaniacz error. Nieobsłużone zdarzenie error to wywalenie, nie linijka logu.
Pozostać zalogowanym: odśwież z wyprzedzeniem i odśwież przy odbiciu
Połowa „zostań połączony" miała bliźniaczą połowę „zostań zalogowany". Przed tym klastrem token uwierzytelniający GeekBye po prostu wygasał — po chwili byłeś bez ceregieli wyrzucany z powrotem na ekran logowania, w trakcie pracy, bez widocznego powodu.
v1.6.8 naprawił to z obu stron:
- Proaktywnie: timer odświeża token zanim wygaśnie, zaplanowany na moment wygaśnięcia minus sprytny bufor (ułamek żywotności tokenu, ograniczony od dołu i od góry). Jesteś odświeżany, zanim byś w ogóle zauważył.
- Reaktywnie: jeśli żądanie i tak wraca z
401— przesunięcie zegara, przegapiony timer, wyziębiony laptop — klient odświeża raz i ponawia oryginalne żądanie dokładnie raz. Pojedyncza flaga strażnicza ogranicza to ponowienie, tak by uporczywy 401 nigdy nie mógł rozkręcić się w nieskończoną pętlę.
Subtelna, ale krytyczna część: obie ścieżki stoją za muteksem. Jeśli dziesięć żądań trafia na 401 w tej samej chwili, nie odpalają dziesięciu odświeżeń — wszystkie czekają na to jedno odświeżenie w locie, a potem ponawiają. A gdy odświeżenia naprawdę nie da się uratować, aplikacja wygasza sesję płynnie — jasne dziesięciosekundowe powiadomienie „twoja sesja wygasła, zaloguj się ponownie" — zamiast cichego kopniaka na ekran logowania.
Ponowne łączenie: dodaj je na kliencie, potem przenieś do backendu
v1.6.9 sprawił, że zerwane połączenie transkrypcji stało się odzyskiwalne zamiast fatalnego. Gdy backend zgłaszał, że nadrzędne połączenie z rozpoznawaniem mowy zostało zerwane, klient przestawał wyświetlać błąd fatalny i zamiast tego łączył się ponownie — z celowo spokojnym UX: jedno bursztynowe powiadomienie „Łączenie ponowne…" (nie jedno na próbę), zielone powiadomienie „Połączono ponownie", gdy wróciło, i terminalny błąd „proszę zrestartować" dopiero po wyczerpaniu każdej próby.
Potem coś, co lubię w tym klastrze: jedno wydanie później, w v1.6.10, usunęliśmy tę logikę ponownego łączenia po stronie klienta — około 113 linii — i przenieśliśmy ponowne łączenie do backendu, który teraz emituje ciche komunikaty statusu „łączenie ponowne", które klient po prostu odzwierciedla. Zbudowaliśmy odzyskiwanie na kliencie, uznaliśmy backend za właściwego właściciela i przenieśliśmy je. To nie jest chwiejność; to uczciwy cykl życia trudnego problemu. Logika niezawodności rozbita na oba końce połączenia to zapach — wybierz właściciela. (Backend ostatecznie stał się definitywnym właścicielem tego, co jest historią ponownego łączenia opowiedzianą w dlaczego twój notatnik AI zatrzymuje się przy słabym Wi-Fi.)
To samo wydanie uczyniło limity czasu nagrywania humanitarnymi: zamiast surowego błędu, powiadomienie ostrzegawcze, które pozwala nagrywaniu trwać dalej, i jasny komunikat „Osiągnięto limit nagrywania", gdy naprawdę się zatrzymuje — przyjazne zdanie wysunięte na wierzch, wewnętrzny prefiks usunięty.
Jedno ponowienie, by wszystkimi rządzić
Do v1.6.11 ta sama logika ponawiania z backoffem została skopiowana i wklejona w trzy różne miejsca — uwierzytelnianie, aktualizacje, burzenie sesji. Więc została skonsolidowana w jeden moduł z jedną wspólną regułą o tym, co w ogóle warto ponawiać: przejściowe awarie sieci (reset połączenia, timeout, czkawka DNS, zerwanie gniazda) są ponawiane z wykładniczym backoffem; prawdziwy błąd serwera zawodzi szybko, bo ponawianie autentycznego 4xx tylko marnuje czas użytkownika. Jeden klasyfikator, kilka nazwanych presetów, trzech wywołujących przeniesionych na niego.
Czego nauczył ten klaster
- Nieobsłużone zdarzenie
errorto wywalenie, nie linijka logu. Node ponownie rzuca zdarzenia error, które nie mają nasłuchiwacza. Zdejmij nasłuchiwacze z gniazda, które być może wciąż burzysz, a musisz ponownie podpiąć pusty pochłaniacz error — inaczej jedno zerwane połączenie zabierze ze sobą całą aplikację. - Odświeżaj poświadczenia przed wygaśnięciem i reaktywnie przy 401 — z muteksem na obu. Proaktywne trzyma cię zalogowanym; reaktywne łapie przypadki brzegowe; muteks oznacza, że N jednoczesnych wywołujących powoduje dokładnie jedno odświeżenie, a limit ponowień oznacza, że zły token nie może zapętlić się na zawsze.
- Ponawiaj tylko przy błędach przejściowych i scentralizuj klasyfikator. „Czy to warto ponawiać?" to jedna decyzja, która należy do jednego miejsca. Ponawianie prawdziwego błędu to tylko wolniejsza porażka.
- Zdecyduj, kto jest właścicielem ponownego łączenia. Dodaliśmy je na kliencie, potem przenieśliśmy do backendu. Logika odzyskiwania żyjąca na obu końcach to generator błędów; daj jej jeden dom.
To trzeci rozdział opowieści o niezawodności, która staje się GeekBye v2. Po poprzedni rozdział zobacz usunęliśmy 5000 linii kodu audio, a transkrypcje zaczęły pojawiać się podwójnie (v1.6.0); po to, gdzie ponowne łączenie ostatecznie osiadło, dlaczego twój notatnik AI zatrzymuje się przy słabym Wi-Fi; a po cały łuk, anatomię dostarczania oprogramowania do perfekcji.