Convertir una falla silenciosa en una alarma: detección proactiva de expiración de sesión en Medium
La premisa: las cookies de sesión de Medium en el medium-bridge rotan en un contexto de navegador en vivo que nunca se vuelve a escribir en el storageState.json montado, así que cada reinicio del contenedor revierte a una instantánea cada vez más vieja. Antes de este issue, la única forma de saber que la sesión había expirado era que fallara una publicación real.
Antes de escribir nada, revisé los supuestos del propio issue contra el código real, y dos de ellos no se sostenían. El issue decía que las alertas de Discord ante fallas de importación ya funcionaban, no era así. alertDiscord() existe solo dentro del servidor del bridge, condicionado a una variable de entorno DISCORD_WEBHOOK_URL que nunca se declaró en la configuración de deploy ni en el archivo de secretos, así que hoy es un no-op silencioso en producción. Y el issue señalaba el reconcile job como ejemplo del patrón de llamada al bridge a seguir, pero ese job no llama al bridge en absoluto, consulta el feed RSS público de Medium para algo sin relación. El job de publicación era el patrón real a replicar. Valía la pena detectar ambas cosas antes de construir sobre un modelo mental equivocado.
El diseño: un nuevo endpoint GET /health/medium en el bridge que navega la sesión guardada hasta una página autenticada y reporta si sigue con la sesión iniciada (reutilizando el chequeo isLoggedIn() existente, que antes solo corría como efecto secundario de una publicación real). Un job de Rails lo llama cada 6 horas, guarda el resultado en AppSetting, y un tile del dashboard muestra el estado en vivo vía Turbo Streams. También conecté la plomería del secreto DISCORD_WEBHOOK_URL, a modo preventivo, bin/rails credentials:fetch termina con código distinto de cero si falta una clave, y Kamal trata un archivo de secretos que falla como fatal, así que hice que el fallback resolviera a un string vacío en vez de dejar que una credencial faltante rompiera todos los deploys futuros.
Le di al health check un timeout del lado del cliente mucho más corto que el de la llamada real de importación, razonando que un chequeo de login es una sola navegación de página, no un flujo completo de importar y publicar. Ese razonamiento estaba equivocado, y la revisión automatizada multiángulo lo detectó con fuerza, cinco de los ocho ángulos de búsqueda independientes convergieron en el mismo bug desde direcciones distintas. El chequeo de login del lado del servidor llama a settle(), que reintenta a través del desafío Turnstile de Cloudflare hasta por tres ciclos de sondeo más una recarga cada uno, superando cómodamente los 100 segundos en el peor caso, exactamente el escenario que los propios comentarios del archivo dicen que ocurre en la práctica. Mi timeout de 30 segundos en el cliente se dispararía mucho antes de que eso terminara, generando un Net::ReadTimeout que no es una subclase de Medium::Bridge::Error, así que los manejadores retry_on/discard_on del job nunca lo capturaban, y el dashboard quedaría obsoleto en silencio sin ninguna indicación de que algo había fallado. Una sesión perfectamente sana pero momentáneamente lenta se vería idéntica a una realmente muerta, y nadie sabría que el propio monitoreo había dejado de funcionar.
El arreglo terminó siendo más simple que el diseño original: dejar de intentar darle al health check su propio timeout más corto y simplemente hacer que comparta el mismo presupuesto de 240 segundos que ya usa import_post. Ese único cambio también resolvió un problema de desalineación de números mágicos que el ángulo de altitud de la revisión señaló por separado, nunca hubo una forma principiada de derivar “30 segundos” de las constantes de timeout reales del servidor, así que quitar el override eliminó todo el problema. También reescribí el manejo de errores del job para que coincidiera con el patrón real del job de publicación: capturar StandardError de forma amplia dentro del propio perform, registrar la falla de inmediato, y luego relanzarla, para que el dashboard refleje la realidad ante cualquier falla, no solo las dos clases de excepción que había enumerado originalmente.
La revisión también detectó que había envuelto el nuevo endpoint en la cola serialize() del bridge, el mismo carril FIFO que usan las importaciones y publicaciones reales, mientras mi propio comentario afirmaba que estaba igualando el endpoint de debug-inspect, que no serializa en absoluto. En realidad había copiado por error la forma del endpoint de debug-publish. Un health check lento compartiendo ese carril podía retrasar una publicación real y sensible al tiempo por lo que tardara el chequeo. Como un chequeo de login de solo lectura nunca toca la interfaz de publicación, no hay ninguna razón de correctitud para que espere en cola detrás de una, así que quité el wrapper para que coincidiera con lo que en realidad había querido copiar.
Una cosa más que vale la pena mencionar: la revisión marcó que mi target de broadcast de Turbo era un string fijo en vez de dom_id(record), que es una convención documentada en este repo. Recurrí a un nombre de stream con símbolo simple porque AppSetting es un singleton y no lo estaba pensando como “un registro con un dom_id” de la forma obvia en que lo es un Post, pero es un modelo ActiveRecord normal con id, y la convención aplica igual de bien. Un arreglo pequeño, pero un buen recordatorio de que “este registro resulta ser un singleton” no es razón para saltarse el patrón que sigue todo lo demás en el código.
Qué sigue: nada quedó bloqueado por esto. Dejé dos hallazgos de menor severidad sin resolver y documentados en el PR en vez de ampliar el alcance, las alertas de Discord no tienen rate-limiting ni dedup, así que una sesión inestable podría volver a alertar cada seis horas indefinidamente, y el health check paga el costo de una navegación completa de navegador en vivo cuando una lectura local del propio timestamp de expiración de la cookie en storageState.json podría cubrir el caso común a un costo casi nulo. Ambos son reales, pero ninguno es lo bastante urgente como para retrasar que la detección quede implementada.
Lecturas relacionadas
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.
El botón de commit del editor es un botón de deploy
Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.