
Een verbroken verbinding hoort niet je hele app te laten crashen — de onze deed dat wel
Toen onze backend midden in een meeting offline ging, pauzeerde niet alleen de transcriptie — de hele app crashte. De oorzaak was één onbehandeld event, en de fix was tien regels. Dit is het releasecluster dat GeekBye ingelogd én verbonden liet blijven door de dingen die dat vroeger de kop kostten.
Sommige bugs onderbreken een functie. De ergste halen alles eromheen neer. In een vroege GeekBye-build gebeurde het volgende als onze backend offline ging terwijl je midden in een meeting zat: de transcriptie stopte niet alleen — de hele app crashte. Eén verbroken verbinding, en het hele venster was weg.
Het cluster releases dat dit oploste — v1.6.8 tot en met v1.6.11 — is een meesterlijk voorbeeld van het onglamoureuze werk van "blijf ingelogd, blijf verbonden." De kenmerkende fix was tien regels. Hier is wat die tien regels waren, en al het andere dat er tegelijk mee uitkwam.
Tien regels tussen een verbroken verbinding en een crash
De crash zat in de audiocode. Wanneer een transcriptiesessie zijn WebSockets afbrak, riep hij er removeAllListeners() op aan — een redelijke opruiming. Maar hier is het Node.js-detail dat opruimen in een ramp verandert: een error-event zonder luisteraar wordt niet genegeerd. Het wordt opnieuw gegooid als een onopgevangen uitzondering.
Stel je dus de volgorde voor wanneer de backend offline gaat: de socket heeft een error-event in de wachtrij om af te vuren. De opruiming draait removeAllListeners() en strippt de handler die het had opgevangen. Een moment later vuurt de gewachtrijde error af — in het niets. Node ziet een error-event waar niemand naar luistert en doet het enige dat zijn contract toestaat: het gooit het als een onopgevangen uitzondering, wat het proces laat crashen. De backend van de gebruiker haperde twee seconden en hun app verdween.
De fix (v1.6.8) is bijna anticlimactisch. Meteen na het strippen van de luisteraars koppel je opzettelijk een no-op error-handler terug:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
Die lege functie is het hele punt. Ze geeft de openstaande error ergens om te landen — een opvang — zodat Node een luisteraar heeft en nooit opnieuw gooit. Dezelfde commit ruimde ook half-open sockets op wanneer een verbindingspoging halverwege mislukt, zodat een mislukte verbinding geen levende socket kan achterlaten die bungelt met een error die klaarstaat om af te vuren. De commit-opmerking zegt het onomwonden: "Zonder dit worden openstaande errors onopgevangen uitzonderingen en laten ze de app crashen." Tien regels veranderden een volledige crash in een stille, herstelbare verbroken verbinding.
De les is meer waard dan de fix: telkens wanneer je removeAllListeners() aanroept op een socket of stream die je mogelijk nog aan het afbreken bent, koppel dan eerst een no-op error-opvang terug. Een onbehandeld error-event is een crash, geen logregel.
Ingelogd blijven: vooraf vernieuwen, en vernieuwen bij de terugkaats
De "blijf verbonden"-helft had een "blijf ingelogd"-tweelingbroer. Vóór dit cluster verliep GeekBye's auth-token gewoon — na een tijdje werd je onceremonieel teruggedumpt naar het inlogscherm, midden in je workflow, zonder zichtbare reden.
v1.6.8 loste dit uit beide richtingen op:
- Proactief: een timer vernieuwt het token voordat het verloopt, ingepland op verval minus een slimme buffer (een fractie van de levensduur van het token, met een onder- en bovengrens). Je bent vernieuwd voordat je het ooit zou merken.
- Reactief: als een verzoek toch als
401terugkomt — klokafwijking, een gemiste timer, een koude laptop — vernieuwt de client eenmaal en probeert het oorspronkelijke verzoek precies één keer opnieuw. Eén bewakingsvlag begrenst die retry zodat een aanhoudende 401 nooit in een oneindige lus kan uitmonden.
Het subtiele-maar-cruciale deel: beide paden zitten achter een mutex. Als tien verzoeken op hetzelfde moment een 401 raken, vuren ze geen tien vernieuwingen af — ze wachten allemaal op de ene vernieuwing die loopt en proberen het daarna opnieuw. En wanneer een vernieuwing echt niet kan worden gered, laat de app de sessie soepel verlopen — een duidelijke toast van tien seconden "je sessie is verlopen, log opnieuw in" — in plaats van een stille schop naar het inlogscherm.
Herverbinding: voeg het toe op de client, verplaats het daarna naar de backend
v1.6.9 maakte een verbroken transcriptieverbinding herstelbaar in plaats van fataal. Wanneer de backend meldde dat de bovenliggende spraakverbinding was verbroken, stopte de client met het tonen van een fatale fout en herverbond in plaats daarvan — met bewust kalme UX: één amberkleurige "Bezig met herverbinden…"-toast (niet één per poging), een groene "Herverbonden"-toast wanneer het terugkwam, en de terminale "start opnieuw"-fout alleen nadat elke poging was uitgeput.
Dan iets wat ik leuk vind aan dit cluster: één release later, in v1.6.10, schrapten we die client-side herverbindingslogica — zo'n 113 regels — en verplaatsten we de herverbinding naar de backend, die nu stille "bezig met herverbinden"-statusberichten uitzendt die de client gewoon weerspiegelt. We bouwden herstel op de client, besloten dat de backend de juiste eigenaar was, en verplaatsten het. Dat is geen weifelen; dat is de eerlijke levenscyclus van een lastig probleem. Betrouwbaarheidslogica gesplitst over beide kanten van een verbinding heeft een luchtje — kies een eigenaar. (De backend werd uiteindelijk de definitieve eigenaar hiervan, wat het herverbindingsverhaal is dat wordt verteld in waarom je AI-notulist stopt bij slechte wifi.)
Dezelfde release maakte opnametijdlimieten menselijk: in plaats van een kale fout, een waarschuwingstoast die de opname laat doorgaan, en een duidelijke "Opnamelimiet bereikt"-boodschap wanneer het daadwerkelijk stopt — de vriendelijke zin naar voren gebracht, het interne voorvoegsel weggehaald.
Eén retry om ze allemaal te overheersen
Tegen v1.6.11 was dezelfde retry-met-backoff-logica in drie verschillende plekken gekopieerd — auth, updates, sessieafbraak. Dus werd het geconsolideerd in één module met één gedeelde regel over wat überhaupt de moeite van het opnieuw proberen waard is: tijdelijke netwerkfouten (verbinding gereset, timeout, DNS-hapering, socket hang-up) worden opnieuw geprobeerd met exponentiële backoff; een echte serverfout faalt snel, want het opnieuw proberen van een echte 4xx verspilt alleen de tijd van de gebruiker. Eén classificatie, een paar benoemde presets, drie aanroepers erop overgezet.
Wat dit cluster leerde
- Een onbehandeld
error-event is een crash, geen logregel. Node gooit error-events die geen luisteraar hebben opnieuw. Strip de luisteraars op een socket die je mogelijk nog aan het afbreken bent, en je moet een no-op error-opvang terugkoppelen — of één verbroken verbinding neemt de hele app mee. - Vernieuw credentials vóór het verlopen en reactief bij een 401 — met een mutex op beide. Proactief houdt je ingelogd; reactief vangt de randgevallen; de mutex betekent dat N gelijktijdige aanroepers precies één vernieuwing veroorzaken, en een retry-grens betekent dat een slecht token niet eeuwig kan blijven lussen.
- Probeer alleen opnieuw bij tijdelijke fouten, en centraliseer de classificatie. "Is dit de moeite van het opnieuw proberen waard?" is één beslissing die op één plek thuishoort. Een echte fout opnieuw proberen is gewoon een tragere mislukking.
- Beslis wie de herverbinding bezit. We voegden het toe op de client en verplaatsten het daarna naar de backend. Herstellogica die op beide kanten leeft is een buggenerator; geef het één thuis.
Dit is het derde hoofdstuk van het betrouwbaarheidsverhaal dat GeekBye v2 wordt. Voor het vorige hoofdstuk, zie we schrapten 5.000 regels audiocode en transcripten begonnen dubbel te verschijnen (v1.6.0); voor waar de herverbinding uiteindelijk aankwam, waarom je AI-notulist stopt bij slechte wifi; en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.