Pular para o conteúdo
Development

Transformando uma falha silenciosa em alarme: detecção proativa de expiração de sessão no Medium

Por Victor Da Luz•
railsrubymonitoringdev-logblog-manager

A premissa: os cookies de sessão do Medium usados pela medium-bridge giram num contexto de navegador ativo que nunca é escrito de volta no storageState.json montado, então todo restart do container volta pra um snapshot envelhecido. Antes desse trabalho, a única forma de descobrir que a sessão tinha ficado velha era uma publicação real falhando.

Antes de escrever qualquer coisa, checei as próprias premissas do issue contra o código de fato, e duas delas não se sustentaram. O issue dizia que o alerta no Discord pra falhas de importação já funcionava, não funcionava. alertDiscord() existe só dentro do servidor da bridge, protegido por uma variável de ambiente DISCORD_WEBHOOK_URL que nunca tinha sido declarada na config de deploy nem no arquivo de secrets, então é um no-op silencioso em produção hoje. E o issue apontava o job de reconciliação como exemplo do padrão de chamar a bridge a ser seguido, ele não chama a bridge de jeito nenhum, ele bate no feed RSS público do Medium por outro motivo qualquer. O job de publicação era o padrão de verdade a espelhar. Valeu a pena pegar os dois antes de construir em cima de um modelo mental errado.

O design: um novo endpoint GET /health/medium na bridge que navega a sessão salva até uma página autenticada e reporta se ainda está logada (reaproveitando o isLoggedIn() que existia, que antes só rodava como efeito colateral de uma publicação real). Um job do Rails chama isso a cada 6 horas, registra o resultado no AppSetting, e um tile no dashboard mostra o estado ao vivo via Turbo Streams. Também conectei a fiação do secret DISCORD_WEBHOOK_URL, por precaução, bin/rails credentials:fetch sai com código diferente de zero pra uma chave ausente, e o Kamal trata um arquivo de secrets falhando como fatal, então fiz o fallback resolver pra uma string vazia em vez de deixar uma credencial ausente quebrar todo deploy futuro.

Dei ao health check um timeout do lado do cliente bem mais curto que a chamada real de importação, com o raciocínio de que um check de login é uma navegação de página só, não um fluxo completo de importar e publicar. Esse raciocínio estava errado, e a revisão automatizada de múltiplos ângulos pegou isso com força, cinco dos oito ângulos independentes de busca convergiram pro mesmo bug por caminhos diferentes. O check de login do lado do servidor chama settle(), que tenta de novo através do desafio Turnstile da Cloudflare por até três ciclos de polling mais um reload cada, passando confortavelmente de 100 segundos no pior caso, exatamente o cenário que os próprios comentários do arquivo dizem que acontece na prática. Meu timeout de 30 segundos do lado do cliente dispararia bem antes disso terminar, levantando um Net::ReadTimeout que não é uma subclasse de Medium::Bridge::Error, então os handlers retry_on/discard_on do job nunca pegavam isso, e o dashboard ficaria obsoleto silenciosamente, sem indicação nenhuma de que algo tinha falhado. Uma sessão perfeitamente saudável mas momentaneamente lenta pareceria idêntica a uma genuinamente morta, e ninguém saberia que o próprio monitoramento tinha parado de funcionar.

A correção acabou mais simples que o design original: parar de tentar dar ao health check um timeout próprio mais curto e simplesmente deixar ele compartilhar o mesmo orçamento de 240 segundos que o import_post já usa. Essa mudança única também resolveu um problema separado de deriva de número mágico que o ângulo de altitude da revisão apontou, nunca existiu uma forma de princípio pra derivar “30 segundos” das constantes de timeout reais do servidor, então remover a substituição removeu o problema inteiro. Também reescrevi o tratamento de erro do job pra combinar com o padrão real do job de publicação: capturar StandardError de forma ampla dentro do próprio perform, registrar a falha imediatamente, depois relançar, pra que o dashboard reflita a realidade em qualquer falha, não só nas duas classes de exceção que eu tinha enumerado originalmente.

A revisão também pegou que eu tinha envolvido o endpoint novo na fila serialize() da bridge, a mesma pista FIFO que importações e publicações reais usam, enquanto meu próprio comentário afirmava que eu estava seguindo o mesmo padrão do endpoint de debug-inspect, que não serializa nada. Eu tinha na verdade copiado a forma do endpoint de debug-publish por engano. Um health check lento compartilhando essa pista poderia atrasar uma publicação real e sensível ao tempo por quanto tempo o check levasse. Já que um check de login somente leitura nunca toca a UI de publicação, não há motivo de correção pra ele entrar na fila; removi o wrapper pra bater com o que eu de fato tinha a intenção de copiar.

Mais uma coisa que vale nomear: a revisão sinalizou que meu alvo de broadcast do Turbo era uma string fixa em vez de dom_id(record), que é uma convenção documentada nesse repositório. Eu tinha recorrido a um nome de stream símbolo simples porque AppSetting é um singleton e eu não estava pensando nele como “um registro com um dom_id” do jeito que um Post obviamente é, mas é um model ActiveRecord normal com um id, e a convenção se aplica igualzinho. Correção pequena, mas um bom lembrete de que “esse registro por acaso é um singleton” não é motivo pra pular o padrão que todo o resto do código segue.

O que vem a seguir: nada bloqueado por isso. Deixei dois achados de severidade menor sem resolver e documentados no PR em vez de expandir o escopo, os alertas do Discord não têm rate-limiting nem dedup, então uma sessão instável poderia repaginar a cada seis horas indefinidamente, e o health check paga o preço de uma navegação completa num navegador real quando uma leitura local do próprio timestamp de expiração do cookie no storageState.json já cobriria o caso comum por um custo quase zero. Os dois são reais, nenhum é urgente o bastante pra segurar a detecção de fato entrando no ar.

Leitura relacionada

Você também pode achar útil

RackNerd

RackNerd VPS

Hospedagem VPS econômica para serviços leves que funcionam continuamente.

Como afiliado da RackNerd, ganho com compras qualificadas.

Saiba mais
Airalo

eSIM Airalo

eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.

Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.

Saiba mais
Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais