É só desligar no staging, não é? - o alerta barulhento que escondia o bug de verdade
Todo dia, o mesmo alerta de 404 do Google Play aparecia no staging. Desligar a fonte deixa tudo quieto, mas isso e corrigir o modo de falha são duas coisas diferentes. O 404 é um erro permanente que não melhora com retry, e mesmo assim estava sendo agrupado com os 5xx e retentado sem parar.
Resumo
Quando um job que roda em fila com retry recebe um 404 de uma API externa, esse 404 costuma ser uma condição permanente que não melhora só porque você tenta de novo. O problema é que, se o cliente agrupa o 404 na mesma exceção que os 5xx e 429, o job queima o orçamento de retry e enche a fila de alertas. A solução é separar erro permanente (4xx) de erro transitório (5xx, 429, timeout) bem na borda do cliente, onde o código de resposta é lido pela primeira vez. E, diante de um alerta barulhento, desligar a fonte e corrigir o modo de falha são problemas diferentes: o primeiro apaga este alerta específico, o segundo elimina todos os casos em que essa falha voltaria a aparecer.
Nesta página
Todo dia, no mesmo horário, chegava o mesmo alerta: um ERROR dizendo que o job de reconciliação de assinaturas tinha falhado no staging. O cron rodava às 3h17 da madrugada, e três minutos depois o alerta aparecia. O stack trace sempre começava com a mesma linha: Google Play Voided Purchases API returned 404. Três tentativas de retry, as três morrendo no mesmo 404, o job caindo no DLQ, e mais um alerta vermelho empilhado no nosso Slack.
Isso não era um incidente. Nada estava quebrado. Era só barulho. E, no processo de decidir o que fazer com um alerta barulhento, aprendi de novo que "desligar" e "corrigir" são problemas diferentes.
Por que o 404 acontecia
A Voided Purchases API é o endpoint do Google Play que devolve a lista de compras estornadas ou canceladas. Usamos essa lista para reconciliar diariamente os casos em que alguém consegue o reembolso e continua usando o app de graça. O detalhe é que, nesse endpoint, um 404 não significa "não há dados". Em voidedpurchases.list, o 404 quer dizer que a aplicação (o pacote) não foi encontrada, ou que a conta de serviço não tem permissão de acesso àquele pacote.
A conta de serviço do staging não tem acesso, no Play Console, ao pacote de produção. E não há motivo para ter: o staging não é o ambiente por onde passa o tráfego real de estornos da loja. Então, chamar essa API a partir do staging sempre resulta em 404 - hoje, amanhã, e para sempre, a menos que a configuração mude. Isso não é uma falha passageira que aparece e some; é uma condição permanente criada pelo próprio ambiente.
É aqui que a diferença importa. Para uma falha transitória, o retry faz sentido: se a rede pisca ou o servidor remoto devolve um 5xx passageiro, basta chamar de novo alguns segundos depois. Mas uma condição permanente não melhora com retry. A permissão que a conta de serviço do staging não tem agora não vai aparecer do nada três segundos depois. Ou seja, tentar esse 404 três vezes era desperdício puro, e o alerta de ERROR no final disso era ruído puro.
Por que o código estava retentando
A causa estava na forma como o cliente classificava as respostas. listVoidedPurchases jogava toda resposta que não fosse 200 numa única exceção genérica.
if (!res.ok) {
throw AppHttpException.serviceUnavailable(
`Google Play Voided Purchases API returned ${res.status}`,
);
}
serviceUnavailable é o erro que nossa fila interpreta como "alvo de retry". Então, seja 404, 500 ou 429, qualquer erro que saísse dali entrava no mesmo ciclo: três tentativas e um alerta no final. O problema é que esses três status têm significados completamente diferentes. 500 e 429 dizem "não dá agora, mas pode dar em breve"; 404 diz "nessa condição, nunca vai dar". No momento em que tudo cai na mesma exceção, um erro que não ganha nada com retry sobe de qualquer jeito no pipeline de retry.
O curioso é que a resposta certa já estava no mesmo arquivo. getSubscriptionPurchase, do mesmo cliente, já tratava o 404 separadamente havia tempo.
if (res.status === 404) {
throw new GooglePlayApiError(404, "purchase not found");
}
Um método respeitava o significado do 404; o outro o achatava junto com tudo. O precedente já existia no código - só que o método novo não seguiu o exemplo. Essa assimetria costuma surgir quando os métodos são escritos em dias diferentes, com preocupações diferentes em mente.
O conserto: separar na borda
A correção aconteceu em duas camadas, e as duas seguiram um padrão que já existia.
Primeiro, separamos o 404 na borda do cliente. Ele passou a ser lançado como GooglePlayApiError(404), com o significado de "não encontrado", enquanto o resto - 5xx, 429, timeout - continuou como serviceUnavailable. Só o que realmente precisa de retry ficou como alvo de retry.
if (res.status === 404) {
throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
throw AppHttpException.serviceUnavailable(/* 5xx, 429, timeout = alvo real de retry */);
}
Depois, definimos como o serviço de reconciliação deveria lidar com esse 404. O serviço já tinha um fluxo (isConfigured === false) que ignorava silenciosamente a rotina quando faltava a chave da conta de serviço. O 404 é, na essência, a mesma situação: uma API que não pode ser chamada naquele ambiente. Então colocamos o 404 no mesmo lugar.
try {
const voided = await client.listVoidedPurchases();
// ... segue a reconciliação
} catch (e) {
if (e instanceof GooglePlayApiError && e.status === 404) {
this.logger.warn("voided purchases 404 - acesso indisponível neste ambiente, pulando");
return { skippedAppNotFound: true };
}
throw e; // se não for 404, relança normalmente para o retry seguir seu curso
}
No catch, só o 404 é engolido: registra um warn e devolve a estatística de skip. Como não relança, não há retry, não há DLQ, não há alerta de ERROR. Por outro lado, qualquer erro que não seja 404 é relançado normalmente, e a lógica de retry original continua funcionando como sempre. Só o que era barulho fica quieto; o problema de verdade continua soando alto.
Junto com isso, veio o teste: o cliente lança GooglePlayApiError no 404? A reconciliação pula o 404? E, o mais importante, um erro que não seja 404 ainda é relançado? Esse último caso é o que mais importa. Se, ao silenciar o 404, você acidentalmente engolir um 500 também, deixa de enxergar um incidente de verdade.
"Mas não é só desligar isso no staging?"
Depois de abrir o PR, veio uma pergunta boa: por que não simplesmente parar de rodar esse job no staging? A intuição faz sentido - se não há tráfego real de estorno no staging, não há motivo nenhum para chamar essa API. Então fui verificar.
Desligar não era tão simples quanto parecia. A chave natural para desativar essa funcionalidade no staging seria a presença ou ausência da chave da conta de serviço (isConfigured), só que essa chave não servia só para esse job. Validação de recibo de compra no app, processamento do webhook de notificações em tempo real do Google (RTDN), o próprio processador de reconciliação de assinaturas - toda a validação de assinatura dependia da mesma chave. Para testar pagamento e assinatura no staging, essa chave precisa existir. Tirar a chave para desligar o job apagaria, junto, exatamente o que precisa ser testado.
Então bastaria criar um toggle exclusivo só para esse job? Esse toggle não existia. Teria que entrar uma flag nova, algo como GOOGLE_VOIDED_RECONCILE_ENABLED. Ou seja, mesmo "desligar só no staging" acaba virando trabalho de código e variável de ambiente. Não havia jeito de desligar de graça, desde o início.
Desligar e corrigir são problemas diferentes
Mas o ponto de verdade não era a dificuldade. Mesmo que criar o toggle fosse fácil, isso não substituiria a correção do 404 - porque as duas abordagens resolvem problemas diferentes.
- Tirar o 404 do caminho de retry é uma defesa estrutural. Em qualquer ambiente - staging, produção com uma configuração errada, ou um futuro em que alguém esquece de ativar o toggle -, impede que um 404 que não melhora com retry se transforme numa enxurrada de alertas.
- Desligar o job no staging é uma otimização operacional. É uma boa escolha adicional: "esse ambiente não tem motivo para chamar essa API, então nem chamamos".
A ordem importa. Manter a defesa contra o 404 e ainda somar o toggle por cima é ótimo. Mas colocar só o toggle e deixar de fazer a defesa contra o 404 significa que, no dia em que a produção tiver uma configuração errada e o 404 aparecer lá, o alerta volta a explodir - e aí não dá para descartar dizendo "é staging, ignora". Desligar a fonte apaga o alerta que está visível agora; corrigir o modo de falha elimina todos os casos em que esse alerta voltaria a aparecer.
Quando um alerta barulhento aparece, a mão vai direto para a fonte: desligar esse job, apagar essa regra de alerta, tirar isso desse ambiente. É um impulso natural, e às vezes é o caminho certo. Mas, antes de desligar, vale se perguntar uma vez: estou desligando o alerta, ou estou corrigindo o modo de falha que o alerta apontou? O primeiro só silencia este alerta; o segundo impede que essa falha volte disfarçada de outra forma.
O que perguntar na borda
Essa história não é exclusiva da API do Google Play. Qualquer código que chama algo externo e coloca a falha numa fila com retry chega na mesma bifurcação. Antes de mexer na política de retry da fila, vale perguntar primeiro na borda do cliente.
Esse erro melhora com retry? 5xx, 429 e timeout, em geral, sim - o que não funciona agora pode funcionar em breve. 4xx, em geral, não: 404, 403 e 400 dão a mesma resposta enquanto a condição não mudar. Quando esses dois grupos são agrupados numa única exceção, uma falha que não ganha nada com retry acaba queimando o orçamento de retry e enchendo os alertas. O lugar certo para separar não é a fila, é a borda do cliente, onde o código de resposta é lido pela primeira vez.
E, antes de corrigir, não custa dar uma passada de olho pelo resto do código-base. No nosso caso, o método vizinho no mesmo arquivo já tratava o 404 corretamente. Não precisamos inventar nada novo. Descobrir que a resposta já estava no código, e que só o código novo não a seguiu, acontece com mais frequência do que se imagina.
Perguntas frequentes
Meu job em background fica retentando em loop por causa de um 404 e o alerta de falha não para de repetir. O que eu faço?
Primeiro é preciso descobrir se esse 404 é transitório ou permanente. Se o recurso simplesmente não existe naquele ambiente, ou o serviço não tem permissão de acesso, o retry sempre vai dar no mesmo resultado. Nesse caso, o certo é fazer o cliente separar o 404 num tipo de erro próprio, já no ponto em que o código de resposta é lido pela primeira vez, para que o job não relance a exceção e simplesmente registre um warn e siga em frente (graceful skip). Assim você elimina o retry, o DLQ e o alerta de ERROR.
Como diferenciar um erro transitório de um erro permanente?
Em geral, 5xx, 429 e timeout são transitórios e valem retry, porque o que não funciona agora pode funcionar em instantes. Já os 4xx, como 404, 403 e 400, dão a mesma resposta enquanto a requisição ou a condição não mudar, então tentar de novo não adianta. Essa distinção deve ser feita na borda do cliente, onde o código de resposta é lido pela primeira vez, e não na política de retry da fila.
Artigos relacionados
- 💻 Dev
O desconto que você não estava usando - por que Spot e Graviton são baratos
A fatura da AWS estourou o orçamento por dois meses seguidos. Ao procurar recursos para cortar, descobri que a verdadeira alavanca não era 'quanto usar', mas 'como comprar'. Por que Spot e Graviton são baratos, e por que existe um custo que parece desperdício mas não pode ser desligado (RDS Proxy) - a história do 'porquê' por trás de cada número da fatura.
- 💻 Dev
Os hooks de shutdown rodavam duas vezes - quando o SIGTERM tem dois donos
A cada deploy, um erro P2028 do Prisma caía no Sentry. Fui corrigir a ordem de shutdown e descobri que o enableShutdownHooks do NestJS e a biblioteca de graceful shutdown estavam, os dois, capturando o SIGTERM - fazendo os hooks de shutdown rodarem duas vezes a cada desligamento.
- 💻 Dev
Criando um blog MDX do zero com Next.js - como este blog foi feito
O processo de criar um blog que roda só com arquivos markdown, sem CMS, usando o App Router do Next.js 16. Estrutura de pastas, configuração do MDX e SEO, tudo organizado com o fluxo de código real.