Why NextWhy Next
Retour à la liste
💻 Dev15 min de lecture

« On n'a qu'à le désactiver en staging » - le vrai bug que cachait une alerte bruyante

Une alerte Google Play 404 qui tombait chaque jour en staging. Désactiver la source la fait taire, mais ce n'est pas la même chose que corriger le mode de défaillance. L'histoire d'un 404 - une erreur permanente qu'il ne sert à rien de réessayer - qui se retrouvait mélangé aux 5xx dans le même circuit de réessai.

L'essentiel

Quand une API externe renvoie un 404 dans un job réessayé via une file d'attente, ce 404 est souvent une condition permanente qui ne s'améliore pas avec des réessais. Mais si le client regroupe le 404 avec les 5xx et 429 sous une même exception, il consomme le budget de réessai et remplit les alertes. La solution consiste à séparer les erreurs permanentes (4xx) des erreurs temporaires (5xx, 429, délais d'expiration) à la frontière du client, là où le code de réponse est vu en premier. Et face à une alerte bruyante, « désactiver la source » et « corriger le mode de défaillance » restent deux problèmes distincts : le premier efface cette alerte-là, le second efface tous les cas où cet échec pourrait revenir.

Sur cette page

Tous les jours, à la même heure, la même alerte tombait. Une ERROR signalant l'échec du job de réconciliation des abonnements en staging. Le cron tournait à 3 h 17 du matin, et l'alerte arrivait trois minutes plus tard. La stack trace commençait toujours par la même ligne : Google Play Voided Purchases API returned 404. Trois réessais, trois échecs identiques sur ce même 404, le job atterrissait dans la DLQ, et notre Slack accumulait une alerte rouge de plus.

Ce n'était pas un incident. Rien n'était cassé. C'était juste bruyant. Et en décidant quoi faire de cette alerte bruyante, j'ai réappris que « désactiver » et « corriger » sont deux problèmes distincts.

Pourquoi ce 404

L'API Voided Purchases est un endpoint Google Play qui renvoie la liste des achats remboursés ou annulés. On s'en sert pour détecter, chaque jour par réconciliation, les utilisateurs qui se font rembourser en douce tout en continuant d'utiliser l'application. Sauf que si cet endpoint renvoie un 404, cela ne veut pas dire « aucune donnée ». Sur voidedpurchases.list, un 404 signifie que l'application (le package) est introuvable, ou que le compte de service n'a pas accès à ce package.

Le compte de service de staging n'a pas d'accès à la Play Console pour le package de production. Et il n'y a aucune raison qu'il l'ait : staging n'est pas l'environnement où transite le vrai trafic de remboursement du store. Résultat, appeler cette API depuis staging renvoie un 404. Aujourd'hui, demain, et indéfiniment tant que la configuration ne change pas. Ce n'est pas une panne passagère qui se résorbe toute seule, c'est une condition permanente créée par l'environnement lui-même.

C'est là que tout se joue. Face à une panne passagère, le réessai est la bonne réponse : si le réseau a un accroc ou si le serveur distant renvoie un 5xx ponctuel, il suffit de rappeler quelques secondes plus tard. Mais une condition permanente ne s'améliore pas avec des réessais. Une permission qui manque au compte de service de staging n'apparaîtra pas comme par magie trois secondes plus tard. Réessayer ce 404 trois fois était donc du gaspillage pur, et l'alerte ERROR qui suivait n'était que du bruit pur.

Pourquoi le code réessayait

La cause se trouvait dans la façon dont le client classait les réponses. listVoidedPurchases regroupait toute réponse différente de 200 sous une seule et même exception.

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

serviceUnavailable est l'erreur que notre file lit comme « à réessayer ». Du coup, que ce soit un 404, un 500 ou un 429, toute erreur sortant de là est réessayée trois fois et finit systématiquement en alerte. Le problème, c'est que ces trois états n'ont rien à voir entre eux. 500 et 429 signifient « ça ne marche pas maintenant, mais ça pourrait marcher bientôt », tandis que 404 signifie « ça ne marchera jamais dans ces conditions ». Dès qu'on les regroupe sous une seule exception, une erreur pour laquelle réessayer ne sert à rien se retrouve embarquée dans le pipeline de réessai.

Ce qui est amusant, c'est que la bonne réponse existait déjà dans le même fichier. getSubscriptionPurchase, sur ce même client, isolait déjà le 404 depuis longtemps.

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

Une méthode respectait le sens du 404, l'autre l'écrasait. Le précédent était là, dans le code, seulement la nouvelle méthode ne l'avait pas suivi. Ce genre d'asymétrie apparaît en général quand les méthodes sont écrites des jours différents, avec des préoccupations différentes en tête.

La correction : séparer à la frontière

La correction s'est faite sur deux couches, chacune suivant un pattern déjà présent.

D'abord, on a séparé le 404 à la frontière du client. Le 404 est désormais levé comme GooglePlayApiError(404), qui porte le sens « introuvable », tandis que le reste - 5xx, 429, délais d'expiration - reste sous serviceUnavailable. Seul ce qui a réellement besoin d'être réessayé le reste.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx·429·délai d'expiration = les vrais candidats au réessai */);
}

Ensuite, on a décidé comment le service de réconciliation devait traiter ce 404. Le service avait déjà un flux - isConfigured === false - qui skippait silencieusement en l'absence de clé de compte de service. Sur le fond, le 404 est exactement la même situation : c'est une API qu'on ne peut pas appeler dans cet environnement. On l'a donc rattaché au même endroit.

try {
  const voided = await client.listVoidedPurchases();
  // ... traitement de la réconciliation
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("voided purchases 404 - accès impossible dans cet environnement, on passe");
    return { skippedAppNotFound: true };
  }
  throw e; // si ce n'est pas un 404, on relance tel quel pour un réessai normal
}

Dans le catch, seul le 404 est avalé : on log un warn, puis on renvoie les statistiques de skip. Comme il n'y a pas de relance, il n'y a ni réessai, ni DLQ, ni alerte ERROR. À l'inverse, toute erreur qui n'est pas un 404 est relancée telle quelle, et la logique de réessai d'origine fonctionne normalement. Seul ce qui était bruyant se tait ; les vrais problèmes restent tout aussi bruyants.

On y a ajouté des tests : est-ce que le client lève bien un GooglePlayApiError sur un 404, est-ce que la réconciliation skippe bien ce 404, et est-ce que les erreurs autres que 404 sont toujours relancées. Ce dernier cas est le plus important. Si, en faisant taire le 404, on avale par erreur un 500 aussi, on perd de vue les vrais incidents.

« On n'a qu'à le désactiver en staging, non ? »

Après avoir ouvert la PR, on m'a posé une bonne question : pourquoi ne pas simplement ne pas faire tourner ce job en staging ? L'intuition est juste - staging n'a pas de vrai trafic de remboursement, donc il n'y a aucune raison d'appeler l'API. Alors j'ai vérifié.

Désactiver s'est révélé moins simple que prévu. Le switch naturel pour couper cette fonctionnalité en staging, c'est la présence ou l'absence de la clé de compte de service (isConfigured) - sauf que cette clé n'est pas dédiée à ce seul job. Vérification des reçus d'achat in-app, traitement des webhooks RTDN (Real-Time Developer Notifications) de Google, processeur de réconciliation des abonnements : toute la validation des abonnements dépend de cette même clé. Pour tester paiements et abonnements en staging, il faut cette clé. La retirer pour désactiver le job éteindrait aussi tout ce qu'on a justement besoin de tester.

Alors pourquoi ne pas créer un toggle dédié à ce seul job ? Un tel toggle n'existait pas. Il aurait fallu introduire un nouveau switch, du genre GOOGLE_VOIDED_RECONCILE_ENABLED. Autrement dit, même « ne le désactiver qu'en staging » revient à toucher du code et des variables d'environnement. Il n'y avait, dès le départ, aucun moyen de le désactiver gratuitement.

Désactiver et corriger sont deux problèmes différents

Mais la vraie question n'était pas la difficulté. Même si créer ce toggle avait été facile, cela n'aurait pas remplacé la correction du 404. Parce que ces deux approches résolvent deux problèmes différents.

  • Retirer le 404 des candidats au réessai est une défense de fond. Quel que soit l'environnement - staging, une production mal configurée, ou un futur où quelqu'un aura oublié le toggle - cela empêche un 404 qu'il ne sert à rien de réessayer de dégénérer en avalanche d'alertes.
  • Désactiver le job en staging est une optimisation opérationnelle. C'est un bon choix à ajouter par-dessus, du style « pas de raison d'appeler dans cet environnement, donc on saute l'appel lui-même ».

L'ordre compte. Garder la défense sur le 404 et ajouter le toggle par-dessus, très bien. Mais si on met seulement le toggle sans la défense sur le 404, le jour où la production sera mal configurée et renverra un 404, l'alerte explosera à nouveau - et cette fois, impossible de la balayer d'un « c'est juste staging, on ignore ». Désactiver la source efface l'alerte qu'on voit là, maintenant ; corriger le mode de défaillance efface tous les cas où cette alerte pourrait revenir.

Face à une alerte bruyante, le premier réflexe est de foncer vers la source : désactivons ce job, supprimons cette règle d'alerte, retirons-le de cet environnement. C'est une pulsion naturelle, et parfois c'est la bonne. Mais avant de désactiver, il faut se poser une question : est-ce que je suis en train d'éteindre l'alerte, ou de corriger le mode de défaillance qu'elle signalait ? La première option fait taire cette alerte-là ; la seconde empêche cet échec de revenir sous une autre forme.

Ce qu'il faut se demander à la frontière

Cette histoire ne concerne pas que l'API Google Play. Tout code qui appelle un service externe et met ses échecs en file d'attente pour les réessayer se retrouve devant le même embranchement. Avant de retoucher la politique de réessai dans la configuration de la file, il faut d'abord se poser la question à la frontière du client.

Est-ce que cette erreur s'améliore avec un réessai ? En général, oui pour 5xx, 429 et les délais d'expiration : ça ne marche pas maintenant, mais ça pourrait marcher bientôt. En général, non pour les 4xx : 404, 403, 400 renvoient la même réponse tant que la condition sous-jacente ne change pas. Regrouper les deux sous une seule exception, c'est laisser un échec qu'il ne sert à rien de réessayer consommer le budget de réessai et remplir les alertes. La séparation ne se fait pas dans la file, mais à la frontière du client, là où le code d'état est vu pour la première fois.

Et avant de corriger, n'oublions pas de jeter un œil au codebase existant. Dans notre cas, la méthode voisine dans le même fichier traitait déjà correctement le 404. Il n'y avait rien à réinventer. Il arrive plus souvent qu'on ne le pense que la réponse existe déjà dans le code, et que seul le nouveau code ne l'ait pas suivie.

Questions fréquentes

Mon job en arrière-plan réessaie sans arrêt sur un 404 et les alertes d'échec se répètent. Que faire ?

Il faut d'abord déterminer si ce 404 est temporaire ou permanent. Si le 404 vient du fait que la ressource n'existe tout simplement pas dans cet environnement, ou qu'on n'y a pas accès, réessayer donnera toujours le même résultat. Dans ce cas, la bonne approche est d'isoler le 404 en un type d'erreur distinct, à l'endroit où le client voit le code de réponse pour la première fois, afin que le job ne le relance pas mais le skippe proprement (graceful skip) avec un log de niveau warn. Ainsi, ni réessai, ni DLQ, ni alerte ERROR ne se déclenchent.

Comment distinguer une erreur temporaire d'une erreur permanente ?

En règle générale, 5xx, 429 et les délais d'expiration sont temporaires et doivent être réessayés, car ce qui ne marche pas maintenant pourrait fonctionner bientôt. À l'inverse, les 4xx comme 404, 403, 400 renvoient la même réponse tant que la requête ou la condition sous-jacente ne change pas - les réessayer ne sert donc à rien. Cette distinction doit se faire à la frontière du client, là où le code de réponse est vu en premier, et non dans la politique de réessai de la file.

Articles similaires