Am 6. November um 03:04 hat mein Bot aufgehört, Orders zu senden. Der Markt war in Bewegung, der Bot war online, und die API antwortete mit invalid_grant. Ich lag im Bett und habe es erst um 7:30 gesehen, zusammen mit elf fehlgeschlagenen Versuchen, die alle dieselbe Antwort bekommen hatten.

Der Access-Token war abgelaufen, das ist normal. Der Refresh-Token war ebenfalls ungültig, und das war nicht normal. Ich hatte Rotation eingebaut, weil die Dokumentation sie empfahl. Ich hatte nur nicht verstanden, was sie bedeutet.

Der Refresh-Token ist ein Einmal-Token

Ein Access-Token lebt eine Stunde und wird bei jeder Anfrage mitgeschickt. Ein Refresh-Token lebt länger und wird benutzt, um einen neuen Access-Token zu holen. Bei Rotation gibt der Server dabei auch einen neuen Refresh-Token zurück und macht den alten ungültig. Das ist der ganze Mechanismus, und er ist gut: Wer einen Token kopiert, kann ihn nur einmal benutzen, danach fällt es auf.

Der Teil, den ich überlesen habe, steht in einem Absatz weiter unten. Wenn ein bereits benutzter Refresh-Token noch einmal vorgelegt wird, wertet der Server das als Diebstahl und macht nicht nur diesen Token ungültig, sondern die ganze Familie. Das ist Absicht. Es ist auch der Grund, warum mein Bot um 03:04 nicht nur einen Fehler hatte, sondern dauerhaft gesperrt war.

Was Rotation wirklich bedeutet

Bildschirmfoto eines Logs mit Zeitstempeln, in dem zwei Prozesse im Abstand von 300 Millisekunden denselben Token erneuern, danach rot markierte Fehlerzeilen
Das Log vom 6. November: zwei Erneuerungen, 300 Millisekunden Abstand, danach elf rote Zeilen. Der zweite Aufruf hat nicht nur sich selbst zerstört, sondern alles.
sequenceDiagram participant B as Bot participant D as Dashboard participant S as Auth-Server B->>S: refresh(token_x) S-->>B: access_neu + token_y D->>S: refresh(token_x) S-->>D: invalid_grant, Familie gesperrt B->>S: refresh(token_y) S-->>B: invalid_grant

Rotation ist damit kein Cache-Problem, sondern ein Zustandsproblem. Es gibt genau einen gültigen Refresh-Token zu jedem Zeitpunkt, und wer ihn benutzt, muss den neuen Wert sofort und dauerhaft speichern, bevor irgendetwas anderes passiert. Zwei Prozesse, die denselben Token lesen, sind kein Wettlauf um Geschwindigkeit. Sie sind ein Wettlauf darum, wer den anderen aussperrt.

Mein Fehler: zwei Prozesse, ein Token

Ich hatte drei Dinge, die denselben Token benutzten: den Bot, ein Dashboard-Skript, das alle fünf Minuten Kennzahlen abholte, und ein Cronjob, der nachts die Order-Historie zog. Alle drei lasen dieselbe Datei, alle drei erneuerten selbstständig, und alle drei schrieben das Ergebnis zurück. In der Nacht hat der Cronjob um 03:04 erneuert, der Bot hat 300 Millisekunden später denselben alten Token vorgelegt, und damit war die Familie gesperrt.

Der zweite Fehler war schlimmer als der erste: Mein Code hat bei invalid_grant zehnmal wiederholt, mit 30 Sekunden Pause. Jeder Versuch war ein weiterer Beweis für den Server, dass hier jemand einen alten Token benutzt. Die Wiederholung hat nichts repariert, sie hat nur das Log gefüllt. Hätte ich einmal wiederholt und dann gestoppt, hätte ich morgens einen Fehler gehabt statt einer Stunde Handarbeit mit neuer Anmeldung über den Browser.

Wie ich es jetzt baue

Heute gibt es genau einen Ort, der Tokens erneuert, und alle anderen fragen ihn. Der Ort ist keine Bibliothek, sondern eine Tabelle mit einer Zeile. Die Bedingung im update ist der Lock: Wer sie nicht gewinnt, hat verloren und liest den neuen Wert, statt selbst zu erneuern.

import sqlite3, time

def refresh_if_needed(db: str, refresh_fn):
    con = sqlite3.connect(db, timeout=5, isolation_level=None)
    con.execute("begin immediate")
    try:
        access, refresh, exp = con.execute(
            "select access, refresh, expires_at from tokens where id = 1"
        ).fetchone()
        if exp - time.time() > 300:
            con.execute("commit")
            return access
        new = refresh_fn(refresh)
        cur = con.execute(
            "update tokens set access = ?, refresh = ?, expires_at = ? "
            "where id = 1 and refresh = ?",
            (new["access"], new["refresh"], new["expires_at"], refresh),
        )
        if cur.rowcount != 1:
            con.execute("rollback")
            return con.execute("select access from tokens where id = 1").fetchone()[0]
        con.execute("commit")
        return new["access"]
    except Exception:
        con.execute("rollback")
        raise

Drei Details sind wichtig. begin immediate nimmt die Schreibsperre sofort, nicht erst beim ersten Schreibbefehl, sonst entsteht genau das Rennen, das ich hatte. Die Bedingung and refresh = ? sorgt dafür, dass nur der Prozess gewinnt, der den aktuellen Wert gelesen hat. Und die Erneuerung passiert fünf Minuten vor Ablauf, nicht bei Ablauf, damit eine Uhr, die zwei Sekunden falsch geht, nicht den ganzen Betrieb anhält.

Dazu kommt eine Regel, die nichts mit Code zu tun hat: Bei invalid_grant wird nicht wiederholt. Der Prozess stoppt, schreibt eine Nachricht und wartet auf mich. Ein Token, der ungültig ist, wird nicht gültig, indem man ihn oft genug schickt.

Fazit

Ich habe geglaubt, Rotation sei eine Sicherheitsfunktion, die man einmal einbaut und dann vergisst. In Wahrheit ist sie eine Zusage über Zustand: Es gibt genau einen gültigen Token, und mein System muss diesen Satz respektieren, auch wenn drei Programme ihn gern hätten. Die Nacht hat mich eine Stunde Handarbeit und einen Tag Ausfall gekostet, weil ich einen Absatz in einer Dokumentation überlesen habe.

Wenn du einen Refresh-Token in einer Datei liegen hast: Welcher Prozess darf ihn benutzen, und was passiert, wenn zwei es gleichzeitig tun?