
Una conexión caída no debería tumbar toda tu app — pero la nuestra lo hacía
Cuando nuestro backend se quedó sin conexión en mitad de una reunión, no solo pausó la transcripción — tumbó la app entera. La causa era un único evento sin gestionar, y el arreglo eran diez líneas. Este es el grupo de lanzamientos que hizo que GeekBye siguiera con la sesión iniciada y siguiera conectado a través de las cosas que antes lo mataban.
Algunos bugs interrumpen una función. Los peores tumban todo lo que hay a su alrededor. En una build temprana de GeekBye, si nuestro backend se quedaba sin conexión mientras estabas en mitad de una reunión, la transcripción no solo se detenía — la app entera se cerraba. Una conexión caída, y toda la ventana desaparecía.
El grupo de lanzamientos que arregló esto — de v1.6.8 a v1.6.11 — es una clase magistral del trabajo poco glamuroso de "sigue con la sesión iniciada, sigue conectado". El arreglo característico eran diez líneas. Aquí está lo que eran esas diez líneas, y todo lo demás que se publicó junto a ellas.
Diez líneas entre una desconexión y un cierre
El cierre vivía en el código de audio. Cuando una sesión de transcripción desmontaba sus WebSockets, llamaba a removeAllListeners() sobre ellos — una limpieza razonable. Pero aquí está el detalle de Node.js que convierte la limpieza en catástrofe: un evento error sin listener no se ignora. Se relanza como una excepción no capturada.
Así que imagina la secuencia cuando el backend se queda sin conexión: el socket tiene un evento error en cola listo para dispararse. La limpieza ejecuta removeAllListeners(), quitando el manejador que lo habría capturado. Un momento después el error en cola se dispara — hacia el vacío. Node ve un evento error sin nadie escuchando, y hace lo único que su contrato permite: lo lanza como una excepción no capturada, que cierra el proceso. El backend del usuario tuvo un hipo de dos segundos y su app se esfumó.
El arreglo (v1.6.8) es casi anticlimático. Justo después de quitar los listeners, vuelve a enganchar un manejador de error no-op deliberado:
this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events
Esa función vacía es todo el objetivo. Le da al error pendiente un sitio donde aterrizar — un sumidero — para que Node tenga un listener y nunca lo relance. El mismo commit también limpió los sockets a medio abrir cuando un intento de conexión falla a mitad de camino, para que una conexión chapucera no pueda dejar un socket vivo colgando con un error esperando a dispararse. El comentario del commit lo dice claro: "Sin esto, los errores pendientes se convierten en excepciones no capturadas y cierran la app." Diez líneas convirtieron un cierre total en una desconexión silenciosa y recuperable.
La lección vale más que el arreglo: cada vez que llamas a removeAllListeners() sobre un socket o stream que quizá todavía estés desmontando, vuelve a enganchar antes un sumidero de error error no-op. Un evento error sin gestionar es un cierre, no una línea de log.
Seguir con la sesión iniciada: refresca antes, y refresca en el rebote
La mitad de "sigue conectado" tenía una gemela de "sigue con la sesión iniciada". Antes de este grupo, el token de auth de GeekBye simplemente expiraba — al cabo de un rato te devolvían sin ceremonias a la pantalla de login, en mitad del flujo de trabajo, por ninguna razón que pudieras ver.
v1.6.8 arregló esto desde ambas direcciones:
- Proactivo: un temporizador refresca el token antes de que expire, programado en la expiración menos un margen inteligente (una fracción de la vida del token, con suelo y techo). Se te refresca antes de que llegaras a notarlo.
- Reactivo: si una petición vuelve con
401de todos modos — desfase de reloj, un temporizador perdido, un portátil frío — el cliente refresca una vez y reintenta la petición original exactamente una vez. Un único flag de guarda limita ese reintento para que un 401 persistente nunca pueda girar en un bucle infinito.
La parte sutil pero crítica: ambos caminos están detrás de un mutex. Si diez peticiones reciben un 401 en el mismo instante, no disparan diez refrescos — todas esperan al único refresco en curso y luego reintentan. Y cuando un refresco genuinamente no se puede salvar, la app expira la sesión con gracia — un aviso claro de diez segundos "tu sesión expiró, por favor inicia sesión de nuevo" — en lugar de una patada silenciosa a la pantalla de login.
Reconexión: añádela en el cliente, luego muévela al backend
v1.6.9 hizo que una conexión de transcripción caída fuera recuperable en vez de fatal. Cuando el backend informaba de que la conexión de voz superior se había caído, el cliente dejaba de mostrar un error fatal y en su lugar reconectaba — con una UX deliberadamente tranquila: un único aviso ámbar "Reconectando…" (no uno por intento), un aviso verde "Reconectado" cuando volvía, y el error terminal "por favor reinicia" solo después de agotar cada intento.
Luego algo que me gusta de este grupo: un lanzamiento después, en v1.6.10, borramos esa lógica de reconexión del lado del cliente — unas 113 líneas — y movimos la reconexión al backend, que ahora emite mensajes de estado tranquilos de "reconectando" que el cliente simplemente refleja. Construimos la recuperación en el cliente, decidimos que el backend era el propietario correcto, y la movimos. Eso no es titubear; es el ciclo de vida honesto de un problema difícil. La lógica de fiabilidad repartida entre ambos extremos de una conexión es un mal olor — elige un propietario. (El backend acabó siendo el propietario definitivo de esto, que es la historia de reconexión que se cuenta en por qué tu notetaker con IA se detiene con mal Wi-Fi.)
El mismo lanzamiento hizo humanos los límites de tiempo de grabación: en lugar de un error crudo, un aviso de advertencia que deja continuar la grabación, y un mensaje claro "Límite de Grabación Alcanzado" cuando efectivamente se detiene — la frase amable mostrada, el prefijo interno eliminado.
Un reintento para gobernarlos a todos
Para v1.6.11, la misma lógica de reintento-con-backoff se había copiado y pegado en tres sitios distintos — auth, actualizaciones, desmontaje de sesión. Así que se consolidó en un único módulo con una sola regla compartida sobre qué merece siquiera un reintento: los fallos de red transitorios (conexión reiniciada, timeout, hipo de DNS, socket colgado) se reintentan con backoff exponencial; un error real del servidor falla rápido, porque reintentar un 4xx genuino solo malgasta el tiempo del usuario. Un clasificador, unos cuantos presets con nombre, tres llamantes migrados a él.
Lo que este grupo enseñó
- Un evento
errorsin gestionar es un cierre, no una línea de log. Node relanza los eventos error que no tienen listener. Quita los listeners de un socket que quizá todavía estés desmontando, y debes volver a enganchar un sumidero de error no-op — o una conexión caída se lleva la app entera con ella. - Refresca las credenciales antes de la expiración y reactivamente ante un 401 — con un mutex en ambos. El proactivo te mantiene con la sesión iniciada; el reactivo captura los casos límite; el mutex significa que N llamantes simultáneos causan exactamente un refresco, y un tope de reintentos significa que un token malo no puede dar vueltas para siempre.
- Reintenta solo ante errores transitorios, y centraliza el clasificador. "¿Merece esto un reintento?" es una única decisión que pertenece a un único sitio. Reintentar un error real es solo un fallo más lento.
- Decide quién es el dueño de la reconexión. La añadimos en el cliente, luego la movimos al backend. La lógica de recuperación viviendo en ambos extremos es un generador de bugs; dale un único hogar.
Este es el tercer capítulo de la historia de fiabilidad que se convierte en GeekBye v2. Para el capítulo anterior, mira borramos 5,000 líneas de código de audio y las transcripciones empezaron a aparecer dos veces (v1.6.0); para donde acabó asentándose la reconexión, por qué tu notetaker con IA se detiene con mal Wi-Fi; y para todo el arco, la anatomía de publicar software hasta la perfección.