Ratenbegrenzung: wie viel, wovon, und was bei 429 zu tun ist

Fünf Eimer, drei Kopfzeilen und ein Stundenkontingent für Gästedaten — mit fertigem Wiederholversuch in vier Sprachen.

Für Entwickler

Gezählt wird je SCHLÜSSEL, nicht je Adresse. Wer den Server wechselt, bekommt keinen frischen Eimer — und ein Agenturserver, der zehn Betriebe von einer Adresse bedient, teilt sich keinen. Der Schlüssel ist ausserdem das, was man zurücknehmen kann; die Adresse nicht.

ES GELTEN VIER GRENZEN GLEICHZEITIG: 600 lesende Aufrufe je Minute und Schlüssel, 120 schreibende je Minute und Schlüssel, 1 200 Aufrufe je Minute über ALLE Schlüssel eines Betriebs (damit zehn Schlüssel nicht das zehnfache Kontingent ergeben), und — nur im Gästebereich — 120 zeilenliefernde Aufrufe je STUNDE und Schlüssel. Ein Betrieb kann für einen einzelnen Schlüssel eine niedrigere Minutengrenze setzen; höher als die Vorgabe geht nicht.

DAS GÄSTEKONTINGENT IST DIE EINE ABWEICHUNG, und sie hat einen Grund. Der vollständige Abzug der Gästekartei ist erlaubt — die Übergabe an ein Newsletter-Werkzeug IST ein Vollabzug. Aber 600 Aufrufe je Minute mal 200 Zeilen wären 120 000 Gäste in einer Minute; ein gestohlener Schlüssel hätte die Kartei eines grossen Hauses, bevor irgendjemand hinsieht. 120 Seiten je Stunde sind höchstens 24 000 Datensätze — genug für jeden ehrlichen Abgleich und langsam genug, dass ein Abzug Stunden dauert und dabei in der Schlüsselverwaltung sichtbar wird. Gezählt werden nur `GET /api/v1/guests` und `GET /api/v1/guests/{id}/export`; ein Einzelabruf zählt nicht mit.

JEDE ANTWORT SAGT, WO SIE STEHEN: `X-RateLimit-Limit` (das Kontingent dieses Eimers), `X-RateLimit-Remaining` (was davon offen ist) und `X-RateLimit-Reset` (Unix-Zeit in SEKUNDEN, wann er sich füllt). Lesen Sie diese Werte, statt zu zählen — Ihre Zählung weiss nichts von einem zweiten Prozess mit demselben Schlüssel.

BEI 429 STEHT `Retry-After` IN SEKUNDEN in der Kopfzeile UND `retryAfterSeconds` im Körper. Warten Sie diese Zeit ab. Ein Wiederholversuch ohne Wartezeit verlängert die Sperre nicht, aber er bringt auch nichts — und mit mehreren Prozessen gleichzeitig entsteht daraus ein Sturm, der nach dem Zurücksetzen sofort wieder anschlägt. Streuen Sie deshalb ein wenig (`Retry-After` plus ein zufälliger Zuschlag).

DIE BREMSE FÄLLT GESCHLOSSEN AUS. Ist der geteilte Zähler gerade nicht erreichbar, antwortet die API mit 503 `rate_limit_unavailable` und hat NICHTS ausgeführt — auch keinen Schreibvorgang. Das ist Absicht: ein stiller Rückfall auf einen prozesslokalen Zähler wäre bei n Instanzen faktisch das n-fache Kontingent, ohne dass es jemand merkt. Behandeln Sie 503 wie 429, nur mit kürzerer Wartezeit.

MEHRFACHE FEHLVERSUCHE MIT UNGÜLTIGEN SCHLÜSSELN werden getrennt gezählt, je Adresse: 30 je Minute. Ein ehrlicher Aufrufer zählt dort NIE mit — der Eimer wird ausschliesslich bei gescheiterter Authentifizierung angefasst. Wer Kennungen durchprobiert, erzeugt nichts anderes als Fehlversuche.

Schritt für Schritt

  1. Nicht zählen, sondern lesen

    Werten Sie `X-RateLimit-Remaining` nach jeder Antwort aus. Fällt der Wert unter ein Zehntel, drosseln Sie selbst — das ist billiger als ein 429 mitten im Nachtlauf.

  2. Inkrementell abgleichen statt alles zu lesen

    Fast jede Liste kennt `updatedSince` bzw. `updatedAtFrom`. Merken Sie sich den `updatedAt` der zuletzt gelesenen Zeile und fragen Sie beim nächsten Lauf nur, was sich seither geändert hat. Das ist der eine Griff, der aus einem Stundenkontingent ein Nichtproblem macht.

  3. Seiten gross machen

    `limit=200` ist das Maximum jeder Liste. Zehn Seiten à 200 kosten zehn Aufrufe, zweihundert Seiten à 10 kosten zweihundert — bei identischem Ergebnis.

  4. Bei 429 warten, nicht drängeln

    `Retry-After` abwarten, dann genau EINMAL wiederholen und beim nächsten Fehlschlag die Wartezeit verdoppeln. Deckeln Sie die Zahl der Versuche — ein Lauf, der nie aufgibt, blockiert den nächsten.

  5. Schreibvorgänge mit Idempotency-Key wiederholen

    Nur so ist sicher, dass die Wiederholung nach einem 429 oder 503 nicht ein zweites Mal bucht. Siehe „Idempotenz".

Codebeispiele

Antwort 429 — das Minutenkontingent des Schlüssels
JSON
{  "error": "rate_limited",  "message": "Zu viele Aufrufe mit diesem Schluessel (600 je Minute).",  "retryAfterSeconds": 23,  "scope": "key",  "docs": "https://tactictable.com/dokumentation/api/fehler/rate_limited",  "requestId": "req_8f31c0a94d2b47e6ba05"}
`scope` sagt, WELCHE Grenze gegriffen hat: `key` (Ihr Schlüssel), `tenant` (alle Schlüssel dieses Betriebs zusammen), `guests.bulk` oder `guests.anonymize` (die Stundenkontingente). Bei `tenant` hilft kein zweiter Schlüssel — dann läuft parallel noch etwas anderes.
Antwort 429 — das Stundenkontingent für Gästedaten
JSON
{  "error": "rate_limited",  "message": "Das Stundenkontingent fuer zeilenliefernde Gaesteaufrufe dieses Schluessels ist aufgebraucht. Die Gaestekartei ist personenbezogen; ihr Abzug ist erlaubt, aber gedeckelt und wird dem Inhaber beziffert. Gleichen Sie inkrementell ab (updatedSince), statt jedes Mal alles zu lesen.",  "retryAfterSeconds": 1874,  "scope": "guests.bulk",  "limit": 120,  "docs": "https://tactictable.com/dokumentation/api/fehler/rate_limited",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Dazu kommen die Kopfzeilen `TT-Guests-Budget`, `TT-Guests-Budget-Remaining` und `TT-Guests-Budget-Reset`. Ein Wiederholversuch in 31 Minuten ist hier richtig — ein Wiederholversuch in 5 Sekunden ist es nie.
curl — die Grenzwerte einer Antwort ansehen
curl
curl -sS -D - -o /dev/null https://tactictable.com/api/v1/tables \
  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \
  | grep -i "ratelimit\|retry-after\|tt-"
`-D -` schreibt die Kopfzeilen nach stdout, `-o /dev/null` wirft den Körper weg. Genau das, was man beim Einrichten eines Abgleichs sehen will.
TypeScript — Wiederholversuch mit Rücksicht
TypeScript
const MAX_VERSUCHE = 4 async function schlafe(sekunden: number): Promise<void> {    await new Promise((weiter) => setTimeout(weiter, sekunden * 1000))} export async function holeMitGeduld(pfad: string, token: string): Promise<unknown> {    for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) {        const antwort = await fetch('https://tactictable.com/api/v1' + pfad, {            headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },        })         if (antwort.status !== 429 && antwort.status !== 503) {            return await antwort.json()        }         // Die Kopfzeile ist massgeblich; der Koerper nennt dieselbe Zahl.        const warten = Number(antwort.headers.get('retry-after') ?? '5')        // Zufaelliger Zuschlag: ohne ihn laufen alle Prozesse nach dem        // Zuruecksetzen in derselben Millisekunde wieder los.        const streuung = Math.random() * 2        if (versuch === MAX_VERSUCHE) {            throw new Error('Nach ' + MAX_VERSUCHE + ' Versuchen weiterhin gebremst.')        }        await schlafe(warten + streuung)    }    throw new Error('unerreichbar')}
Wiederholt wird NUR bei 429 und 503. Ein 400 wird durch Wiederholen nicht richtiger, und ein 409 `conflict` bedeutet, dass der Tisch inzwischen jemand anderem gehört.
Python — dieselbe Geduld
Python
import randomimport time import requests MAX_VERSUCHE = 4  def hole_mit_geduld(sitzung: requests.Session, pfad: str) -> dict:    for versuch in range(1, MAX_VERSUCHE + 1):        antwort = sitzung.get("https://tactictable.com/api/v1" + pfad, timeout=30)         if antwort.status_code not in (429, 503):            return antwort.json()         if versuch == MAX_VERSUCHE:            raise RuntimeError("Nach {} Versuchen weiterhin gebremst.".format(MAX_VERSUCHE))         warten = int(antwort.headers.get("Retry-After", "5"))        time.sleep(warten + random.uniform(0, 2))     raise RuntimeError("unerreichbar")
Bei einem nächtlichen Vollabgleich der Gästekartei ist `Retry-After` durchaus eine halbe Stunde. Planen Sie das Zeitfenster entsprechend — oder gleichen Sie inkrementell ab und brauchen es nie.
Go — Grenzwerte auslesen und selbst drosseln
Go
package tactictable import (    "net/http"    "strconv"    "time") // Grenze beschreibt den Stand des Eimers nach einer Antwort.type Grenze struct {    Limit     int    Verbleibt int    Reset     time.Time} func GrenzeAus(antwort *http.Response) Grenze {    limit, _ := strconv.Atoi(antwort.Header.Get("X-RateLimit-Limit"))    rest, _ := strconv.Atoi(antwort.Header.Get("X-RateLimit-Remaining"))    reset, _ := strconv.ParseInt(antwort.Header.Get("X-RateLimit-Reset"), 10, 64)    return Grenze{Limit: limit, Verbleibt: rest, Reset: time.Unix(reset, 0)}} // Knapp meldet, ob man selbst langsamer werden sollte, bevor 429 kommt.func (g Grenze) Knapp() bool {    return g.Limit > 0 && g.Verbleibt*10 < g.Limit}
`X-RateLimit-Reset` ist eine Unix-Zeit in SEKUNDEN, nicht in Millisekunden. Wer sie als Millisekunden liest, wartet bis 1970 — also gar nicht — und läuft sofort ins nächste 429.