Why NextWhy Next
Zur Übersicht
💻 Entwicklung15 Min. Lesezeit

Schalt's doch einfach in Staging ab - der laute Alarm, der den eigentlichen Bug verdeckte

In Staging kam jeden Tag derselbe Google-Play-404-Alarm. Schaltet man die Quelle ab, wird es still - aber das ist etwas anderes, als den Fehlermodus tatsächlich zu beheben. Ein 404 ist ein dauerhafter Fehler, den kein Retry behebt, und trotzdem wurde er mit 5xx in einen Topf geworfen und erneut versucht.

Das Wichtigste

Wenn ein über eine Queue erneut versuchter Job von einer externen API einen 404 bekommt, ist dieser 404 in vielen Fällen ein dauerhafter Zustand, der sich durch Wiederholen nicht ändert. Wirft der Client den 404 aber zusammen mit 5xx und 429 als eine einzige Exception, verbrennt das Retry-Budget und füllt die Alarme. Die Lösung: An der Client-Grenze, wo der Response-Code zuerst gesehen wird, dauerhafte Fehler (4xx) von vorübergehenden Fehlern (5xx, 429, Timeout) trennen. Und vor einem lauten Alarm gilt: 'die Quelle abschalten' und 'den Fehlermodus beheben' sind zwei verschiedene Probleme. Ersteres löscht nur diesen einen Alarm, Letzteres löscht jeden Fall, in dem der Fehler zurückkehren könnte.

Auf dieser Seite

Jeden Tag zur selben Zeit kam derselbe Alarm. Ein ERROR, weil der Subscription-Reconcile-Job in Staging fehlgeschlagen war. Um 3:17 Uhr lief der Cronjob, drei Minuten später kam der Alarm. Der Stacktrace begann immer mit derselben Zeile: Google Play Voided Purchases API returned 404. Drei Retries, alle drei sterben am selben 404, der Job fällt in die DLQ, und in unserem Slack sammelt sich ein roter Alarm mehr an.

Das war kein Incident. Es war nichts kaputt. Es war nur laut. Und beim Entscheiden, wie man mit diesem lauten Alarm umgeht, habe ich wieder gelernt, dass "abschalten" und "beheben" zwei verschiedene Probleme sind.

Warum überhaupt 404

Die Voided Purchases API ist ein Google-Play-Endpoint, der zurückgibt, welche Käufe erstattet oder storniert wurden. Wir gleichen das täglich ab, um Fälle herauszufiltern, in denen jemand heimlich eine Rückerstattung bekommen hat, die App aber weiter nutzt. Wenn dieser Endpoint einen 404 wirft, heißt das aber nicht "keine Daten vorhanden". Bei voidedpurchases.list bedeutet 404, dass die betreffende Anwendung (das Package) nicht gefunden werden kann oder dass das Service-Konto keinen Zugriff auf dieses Package hat.

Das Service-Konto in Staging hat keinen Play-Console-Zugriff auf das Produktions-Package. Und das muss es auch nicht - in Staging fließt gar kein echter Store-Rückerstattungstraffic. Ruft man diese API also aus Staging auf, kommt ein 404. Heute, morgen, für immer, solange sich die Konfiguration nicht ändert. Das ist keine kurz aussetzende, vorübergehende Störung, sondern ein dauerhafter Zustand, den die Umgebung selbst erzeugt.

Genau hier liegt der entscheidende Unterschied. Bei einer vorübergehenden Störung ist Retry richtig: Flackert das Netzwerk oder spuckt der Server kurz einen 5xx aus, reicht es, ein paar Sekunden später erneut aufzurufen. Ein dauerhafter Zustand aber wird durch Retry nicht besser. Eine Berechtigung, die dem Staging-Service-Konto fehlt, entsteht nicht plötzlich drei Sekunden später. Diesen 404 dreimal zu wiederholen war also reine Verschwendung, und der ERROR-Alarm am Ende davon war reines Rauschen.

Warum der Code überhaupt retryt hat

Die Ursache lag darin, wie der Client die Response klassifiziert. listVoidedPurchases warf jede Response, die nicht 200 war, in einen einzigen Topf.

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

serviceUnavailable ist der Fehlertyp, den unsere Queue als "retry-fähig" liest. Also werden 404, 500 und 429 gleichermaßen dreimal wiederholt und enden alle im Alarm. Das Problem: Diese drei Zustände bedeuten etwas völlig anderes. 500 und 429 heißen "geht gerade nicht, kann aber gleich gehen", 404 heißt "geht unter dieser Bedingung nie". Sobald man sie in eine Exception zusammenwirft, landet ein Fehler, bei dem Retry ohnehin nichts bringt, in der Retry-Pipeline.

Interessant war: Die richtige Lösung existierte bereits in derselben Datei. getSubscriptionPurchase desselben Clients hatte 404 längst separat behandelt.

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

Eine Methode respektierte die Bedeutung von 404, die andere verwischte sie. Der Präzedenzfall lag bereits im Code - die neue Methode folgte ihm nur nicht. So eine Asymmetrie entsteht meist, wenn Methoden an verschiedenen Tagen und mit unterschiedlichem Fokus geschrieben werden.

Die Behebung: An der Grenze trennen

Der Fix fand auf zwei Ebenen statt, und beide folgten einem bereits vorhandenen Muster.

Zuerst wurde 404 an der Client-Grenze abgetrennt. 404 wird jetzt als GooglePlayApiError(404) geworfen, was "nicht gefunden" bedeutet, während die übrigen Fälle - 5xx, 429, Timeout - weiterhin als serviceUnavailable behandelt werden. So bleibt nur das im Retry-Pool, was wirklich einen Retry braucht.

if (res.status === 404) {
  throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
  throw AppHttpException.serviceUnavailable(/* 5xx·429·Timeout = echte Retry-Kandidaten */);
}

Danach wurde festgelegt, wie der Reconcile-Service diesen 404 aufnehmen soll. Der Service hatte bereits einen Ablauf, der bei fehlendem Service-Account-Key still übersprungen wird (isConfigured === false). Ein 404 ist im Kern derselbe Fall: eine API, die in dieser Umgebung nicht aufgerufen werden kann. Also wurde er an dieselbe Stelle gehängt.

try {
  const voided = await client.listVoidedPurchases();
  // ... Reconcile läuft weiter
} catch (e) {
  if (e instanceof GooglePlayApiError && e.status === 404) {
    this.logger.warn("Voided Purchases 404 - in dieser Umgebung nicht erreichbar, wird übersprungen");
    return { skippedAppNotFound: true };
  }
  throw e; // ist es kein 404, wird ganz normal weiter geworfen und retryt
}

Im catch wird nur der 404 geschluckt, ein warn-Log geschrieben und eine Skip-Statistik zurückgegeben. Da nichts erneut geworfen wird, gibt es weder Retry noch DLQ noch ERROR-Alarm. Jeder andere Fehler dagegen wird ganz normal weitergeworfen, sodass die ursprüngliche Retry-Logik weiterläuft wie zuvor. Nur das Laute wird still, das eigentliche Problem bleibt weiterhin laut.

Dazu kamen Tests: Wirft der Client bei 404 wirklich einen GooglePlayApiError? Überspringt der Reconcile-Vorgang den 404 korrekt? Und wird jeder Fehler, der kein 404 ist, weiterhin erneut geworfen? Der letzte Fall ist entscheidend - wenn man beim Stummschalten von 404 aus Versehen auch einen 500 verschluckt, sieht man einen echten Ausfall nicht mehr.

"Man könnte es doch einfach in Staging abschalten?"

Nach dem PR kam eine gute Frage: Warum läuft dieser Job in Staging überhaupt? Das ist eine berechtigte Intuition - in Staging gibt es keinen echten Rückerstattungstraffic, also gibt es eigentlich keinen Grund, die API überhaupt aufzurufen. Also habe ich das geprüft.

Das Abschalten war weniger einfach, als es klang. Der naheliegende Schalter, um diese Funktion in Staging abzuschalten, ist das Vorhandensein des Service-Account-Keys (isConfigured) - aber dieser Key gehört nicht nur diesem Job. Verifikation von App-Kaufbelegen, Verarbeitung der Google-Realtime-Developer-Notifications-Webhooks (RTDN), der Subscription-Reconcile-Prozessor - die gesamte Subscription-Verifikation hängt am selben Key. Um in Staging Payments und Subscriptions überhaupt testen zu können, braucht man diesen Key. Nimmt man ihn weg, um den Job abzuschalten, schaltet man genau das mit ab, was eigentlich getestet werden soll.

Dann baut man eben einen eigenen Toggle nur für diesen Job? So einen Toggle gab es nicht. Das hätte bedeutet, etwas Neues wie GOOGLE_VOIDED_RECONCILE_ENABLED einzuführen. Auch "nur Staging abschalten" ist also am Ende eine Änderung an Code und Environment-Variablen. Einen kostenlosen Weg zum Abschalten gab es von Anfang an nicht.

Abschalten und Beheben sind zwei verschiedene Probleme

Der eigentliche Punkt war aber gar nicht der Schwierigkeitsgrad. Selbst wenn das Anlegen eines Toggles einfach gewesen wäre, hätte es den 404-Fix nicht ersetzt. Denn beide Ansätze lösen unterschiedliche Probleme.

  • 404 aus dem Retry-Pfad zu nehmen ist eine grundlegende Absicherung. In jeder Umgebung - ob Staging, eine fehlkonfigurierte Produktion oder ein zukünftiger Fall, in dem jemand vergisst, einen Toggle zu setzen - verhindert das, dass ein 404, bei dem Retry sowieso nichts bringt, zu einer Alarmflut wird.
  • Den Job in Staging abzuschalten ist eine betriebliche Optimierung. Die Idee "es gibt in dieser Umgebung keinen Grund für den Aufruf, also lassen wir ihn ganz weg" ist eine gute Ergänzung obendrauf.

Die Reihenfolge zählt. Neben der 404-Absicherung noch einen Toggle hinzuzufügen ist unproblematisch. Baut man aber nur den Toggle ein und lässt die 404-Absicherung weg, bricht der Alarm eines Tages wieder los, sobald in Produktion mal eine Fehlkonfiguration einen 404 auslöst - und dann kann man es nicht mehr mit "ist ja nur Staging" abtun. Die Quelle abzuschalten löscht nur den einen Alarm, den man gerade sieht; den Fehlermodus zu beheben löscht jeden Fall, in dem dieser Alarm wiederkehren könnte.

Bei einem lauten Alarm greift die Hand zuerst zur Quelle: den Job abschalten, die Alarmregel löschen, aus dieser Umgebung entfernen. Das ist ein natürlicher Reflex, und manchmal ist er auch richtig. Aber bevor man abschaltet, sollte man sich einmal fragen: Schalte ich gerade nur den Alarm ab, oder behebe ich den Fehlermodus, auf den der Alarm hingewiesen hat? Ersteres macht diesen einen Alarm still, Letzteres verhindert, dass derselbe Fehler in anderer Form zurückkommt.

Was man an der Grenze fragen sollte

Diese Geschichte betrifft nicht nur die Google-Play-API. Jeder Code, der etwas Externes aufruft und dessen Fehler über eine Queue erneut versucht, steht vor derselben Weggabelung. Bevor man die Retry-Policy in der Queue-Konfiguration anfasst, sollte man zuerst an der Client-Grenze fragen:

Wird dieser Fehler durch Retry besser? Bei 5xx, 429 und Timeout meist ja - es geht gerade nicht, kann aber gleich gehen. Bei 4xx meist nein - 404, 403, 400 liefern dieselbe Antwort, solange sich die Bedingung nicht ändert. Wirft man beides in eine einzige Exception, verbrennt ein Fehler, bei dem Retry ohnehin nichts bringt, das Retry-Budget und füllt die Alarme. Der richtige Ort, um zu trennen, ist nicht die Queue, sondern die Client-Grenze, an der der Response-Code zuerst gesehen wird.

Und bevor man etwas neu behebt, lohnt sich auch ein Blick in die bestehende Codebasis. In unserem Fall hatte die Nachbarmethode in derselben Datei den 404 bereits korrekt behandelt. Es musste nichts neu erfunden werden. Häufiger als man denkt liegt die Antwort schon im Code - nur der neue Code folgt ihr noch nicht.

Häufige Fragen

Ein Hintergrundjob wiederholt sich ständig wegen eines 404 und der Fehleralarm kommt immer wieder. Was tun?

Zuerst muss man unterscheiden, ob dieser 404 vorübergehend oder dauerhaft ist. Kommt der 404 daher, dass die Ressource in dieser Umgebung von vornherein nicht existiert oder kein Zugriff besteht, bringt ein Retry dasselbe Ergebnis. In so einem Fall sollte der Client an der Stelle, an der er den Response-Code zuerst sieht, 404 als eigenen Fehlertyp abzweigen, sodass der Job ihn nicht erneut wirft, sondern mit einem warn-Log übersprungen wird (graceful skip). Dann entstehen weder Retry noch DLQ noch ERROR-Alarm.

Woran unterscheidet man vorübergehende von dauerhaften Fehlern?

Grundsätzlich gelten 5xx, 429 und Timeouts als vorübergehend und damit als Retry-Kandidaten, weil es jetzt nicht klappt, aber gleich schon wieder klappen kann. 4xx-Fehler wie 404, 403 oder 400 dagegen liefern immer dieselbe Antwort, solange sich die Anfrage oder die Bedingung nicht ändert - ein Retry bringt also nichts. Diese Unterscheidung trifft man am besten nicht in der Retry-Policy der Queue, sondern an der Client-Grenze, wo der Response-Code zuerst gesehen wird.

Ähnliche Beiträge