Why NextWhy Next
Torna all'elenco
💻 SviluppoLettura di 15 min

«Basta disattivarlo in staging» - l'alert 404 rumoroso che nascondeva il vero bug

Ogni giorno, in staging, arrivava lo stesso alert per un 404 di Google Play. Disattivare la fonte l'avrebbe fatto tacere, ma zittire l'alert e correggere la modalità di fallimento sono due problemi diversi. Il 404 era un errore permanente, inutile da ritentare, eppure veniva mescolato ai 5xx e ritentato comunque.

In sintesi

Quando un job in coda con retry riceve un 404 da un'API esterna, spesso quel 404 è una condizione permanente che il retry non può risolvere. Ma se il client lo raggruppa nella stessa eccezione dei 5xx e dei 429, brucia budget di retry e riempie gli alert. La soluzione è separare gli errori permanenti (4xx) da quelli transitori (5xx, 429, timeout) proprio al confine del client, dove il codice di risposta viene letto per la prima volta. E davanti a un alert rumoroso, «disattivare la fonte» e «correggere la modalità di fallimento» restano due problemi distinti: il primo cancella questo singolo alert, il secondo cancella ogni futura ricomparsa di quel fallimento.

In questa pagina

Ogni giorno, alla stessa ora, arrivava lo stesso alert. Un ERROR per il fallimento del job di riconciliazione (reconcile) delle sottoscrizioni in staging. Il cron girava alle 3:17, e tre minuti dopo scattava l'alert. Lo stack trace iniziava sempre con la stessa riga: Google Play Voided Purchases API returned 404. Tre tentativi di retry, tutti e tre falliti con lo stesso 404, il job finiva in DLQ e sul nostro Slack si accumulava un'altra notifica rossa.

Non era un incidente. Non si era rotto nulla. Era solo rumoroso. E decidendo come gestire questo rumore, ho reimparato che «disattivare» e «correggere» sono due problemi diversi.

Perché quel 404

La Voided Purchases API è l'endpoint di Google Play che restituisce l'elenco degli acquisti rimborsati o annullati. La usiamo per riconciliare ogni giorno i casi in cui qualcuno ottiene un rimborso di nascosto continuando a usare l'app. Ma quando questo endpoint restituisce 404, non significa "nessun dato". Su voidedpurchases.list, un 404 significa che l'applicazione (il package) non è stata trovata, oppure che il service account non ha accesso a quel package.

Il service account di staging non ha accesso alla console Play per il package di produzione. E non ha nemmeno motivo di averlo: staging non è l'ambiente dove passa il traffico reale di rimborso dello store. Chiamare quell'API da staging produce quindi un 404. Oggi, domani, e per sempre finché la configurazione non cambia. Non è un guasto transitorio che va e viene: è una condizione permanente generata dall'ambiente stesso.

Ed è qui che si biforca il ragionamento. Un guasto transitorio giustifica il retry: se la rete ha uno scatto o il server remoto risponde con un 5xx per un attimo, richiamarlo pochi secondi dopo ha senso. Ma una condizione permanente non migliora con il retry. Un permesso che manca al service account di staging non spunterà fuori tre secondi dopo. Ritentare questo 404 tre volte era puro spreco, e l'alert ERROR che ne seguiva era puro rumore.

Perché il codice ritentava

La causa stava nel modo in cui il client classificava le risposte. listVoidedPurchases raggruppava ogni risposta diversa da 200 in un'unica eccezione.

if (!res.ok) {
  throw AppHttpException.serviceUnavailable(
    `Google Play Voided Purchases API returned ${res.status}`,
  );
}

serviceUnavailable è l'errore che la nostra coda legge come "da ritentare". Quindi che sia 404, 500 o 429, ogni errore uscito da qui veniva ritentato tre volte e finiva in un alert. Il problema è che questi tre stati hanno significati completamente diversi. 500 e 429 dicono "non funziona ora, ma potrebbe funzionare tra poco", mentre 404 dice "in questa condizione non funzionerà mai". Nel momento in cui li si raggruppa in un'unica eccezione, un errore per cui il retry è inutile finisce comunque sulla pipeline di retry.

La parte curiosa è che la risposta corretta era già nello stesso file. getSubscriptionPurchase, dello stesso client, distingueva già il 404 separatamente.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "purchase not found");
}

Un metodo rispettava il significato del 404, l'altro lo appiattiva. Il precedente c'era già nel codice: era il nuovo metodo a non seguirlo. Questa asimmetria nasce di solito quando i metodi vengono scritti in giorni diversi, con attenzioni diverse.

La correzione: separare al confine

La correzione ha toccato due livelli, ed entrambi seguono un pattern già esistente.

Prima di tutto, il 404 è stato separato al confine del client. Il 404 ora viene lanciato come GooglePlayApiError(404), con il significato di "non trovato", mentre tutto il resto - 5xx, 429, timeout - resta serviceUnavailable. Solo ciò che ha davvero bisogno di essere ritentato rimane candidato al retry.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx, 429, timeout = candidati reali al retry */);
}

Poi si è deciso come il servizio di riconciliazione dovesse gestire questo 404. Il servizio aveva già un flusso ("se manca la chiave del service account, salta silenziosamente", isConfigured === false). Il 404 è, nella sostanza, la stessa situazione: un'API che in questo ambiente non si può chiamare. Quindi è stato fatto rientrare nello stesso punto.

try {
  const voided = await client.listVoidedPurchases();
  // ... prosegue la riconciliazione
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - non accessibile in questo ambiente, salto");
    return { skippedAppNotFound: true };
  }
  throw e; // se non è 404, rilancia e il retry normale procede
}

Il catch inghiotte solo il 404, lascia un log di warn e restituisce le statistiche di skip. Non essendo rilanciato, non genera retry, né DLQ, né alert ERROR. Al contrario, ogni errore diverso da 404 viene rilanciato normalmente e la logica di retry originale continua a funzionare come prima. Solo il rumore diventa silenzioso; il problema vero resta rumoroso.

A questo si sono aggiunti i test: verificare che il client lanci GooglePlayApiError sul 404, che la riconciliazione salti il 404, e che ogni errore diverso da 404 venga comunque rilanciato. Quest'ultimo caso è cruciale: se nel silenziare il 404 si finisce per inghiottire per errore anche un 500, si perde di vista un incidente vero.

"Basta disattivarlo in staging, no?"

Dopo aver aperto la PR, è arrivata una domanda sensata: perché non evitare del tutto di far girare questo job in staging? L'intuizione è corretta: in staging non c'è traffico reale di rimborso, quindi non c'è nemmeno motivo di chiamare quell'API. Quindi ho verificato.

Disattivarlo non era così semplice come sembrava. L'interruttore naturale per spegnere questa funzionalità in staging è la presenza o assenza della chiave del service account (isConfigured), ma quella chiave non era dedicata solo a questo job. Verifica delle ricevute di acquisto in-app, gestione dei webhook delle notifiche in tempo reale di Google (RTDN), processore di riconciliazione delle sottoscrizioni: tutta la verifica delle sottoscrizioni dipendeva dalla stessa chiave. Per testare pagamenti e sottoscrizioni in staging, quella chiave deve esserci. Togliere la chiave per spegnere questo job avrebbe spento, insieme a esso, proprio ciò che serve testare.

Allora si potrebbe creare un toggle dedicato solo a questo job. Ma un toggle del genere non esisteva. Bisognava introdurre ex novo un interruttore tipo GOOGLE_VOIDED_RECONCILE_ENABLED. In altre parole, anche "disattivarlo solo in staging" è, alla fine, un intervento su codice e variabili d'ambiente. Non esisteva, fin dall'inizio, un modo per disattivarlo gratis.

Disattivare e correggere sono due problemi diversi

Ma il punto vero non era la difficoltà. Anche se creare il toggle fosse stato semplice, non avrebbe sostituito la correzione del 404, perché i due approcci risolvono problemi diversi.

  • Escludere il 404 dai candidati al retry è una difesa di fondo. In qualsiasi ambiente - staging, una produzione mal configurata, o un futuro in cui qualcuno dimentica di attivare il toggle - impedisce che un 404 su cui il retry è inutile degeneri in una valanga di alert.
  • Disattivare il job in staging è un'ottimizzazione operativa. È la scelta di dire "in questo ambiente non c'è motivo di chiamarlo, quindi salto del tutto la chiamata", ed è un bel complemento da aggiungere sopra la prima.

L'ordine conta. Aggiungere un toggle mantenendo la difesa sul 404 va benissimo. Ma se si aggiunge solo il toggle e si toglie la difesa sul 404, il giorno in cui una configurazione andrà storta in produzione l'alert tornerà a esplodere, e stavolta non si potrà liquidarlo con un "tanto è staging, ignoriamo". Disattivare la fonte cancella l'alert che si vede ora; correggere la modalità di fallimento cancella ogni futura ricomparsa di quello stesso alert.

Davanti a un alert rumoroso, la mano corre subito verso la fonte: spegniamo questo job, cancelliamo questa regola di alert, escludiamolo da questo ambiente. È un impulso naturale, e a volte è anche quello giusto. Ma prima di disattivare va posta una domanda: sto zittendo l'alert, o sto correggendo la modalità di fallimento che l'alert ha appena segnalato? Il primo rende silenzioso questo alert; il secondo impedisce che quel fallimento torni sotto un'altra forma.

Cosa chiedersi al confine

Questa storia non riguarda solo l'API di Google Play. Ogni codice che chiama un servizio esterno e mette i suoi fallimenti in coda per il retry si trova prima o poi allo stesso bivio. Prima di ritoccare la policy di retry nella configurazione della coda, bisogna chiedersi al confine del client: questo errore migliora con il retry?

Per 5xx, 429 e timeout, in genere sì: non funziona ora, ma potrebbe funzionare tra poco. Per i 4xx, in genere no: 404, 403 e 400 restituiscono la stessa risposta finché la condizione di partenza non cambia. Se si raggruppano questi due casi in un'unica eccezione, un fallimento per cui il retry è inutile finisce comunque a bruciare budget di retry e a riempire gli alert. Il punto in cui separarli non è la coda, ma il confine del client dove il codice di risposta viene letto per la prima volta.

E prima di scrivere la correzione, vale la pena dare un'occhiata al resto della codebase. Nel nostro caso, il metodo accanto nello stesso file gestiva già il 404 nel modo giusto: non c'era bisogno di inventare nulla. Càpita più spesso di quanto si pensi che la risposta sia già nel codice, e sia solo il codice nuovo a non averla seguita.

Domande frequenti

Il mio job in background continua a ritentare su un 404 e l'alert di fallimento si ripete. Come lo risolvo?

Per prima cosa bisogna capire se quel 404 è transitorio o permanente. Se la risorsa semplicemente non esiste in quell'ambiente, o il client non ha i permessi per accedervi, il retry darà sempre lo stesso risultato. In questi casi conviene intercettare il 404 come tipo di errore separato proprio nel punto in cui il client legge per primo il codice di risposta, così il job non lo rilancia ma lo salta con un log di warn (graceful skip). In questo modo non si generano né retry, né DLQ, né alert ERROR.

Come si distingue un errore transitorio da uno permanente?

In generale 5xx, 429 e i timeout sono transitori e vanno ritentati, perché anche se non funziona ora potrebbe funzionare tra poco. Al contrario, i 4xx come 404, 403 e 400 restituiscono la stessa risposta finché la richiesta o la condizione di partenza non cambiano, quindi ritentare è inutile. Questa distinzione va fatta al confine del client dove il codice di risposta viene letto per la prima volta, non nella policy di retry della coda.

Articoli correlati