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 EntwicklerGezä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
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.
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.
Seiten gross machen
`limit=200` ist das Maximum jeder Liste. Zehn Seiten à 200 kosten zehn Aufrufe, zweihundert Seiten à 10 kosten zweihundert — bei identischem Ergebnis.
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.
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
{ "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"}{ "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"}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-"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')}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")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}Siehe auch
- Die Form jedes FehlersEin Körper, ein Aufbau, ein geschlossener Katalog von Kennungen — und warum in `message` nie eine rohe Datenbankmeldung steht.
- Blätterung: der Zeiger statt der SeitenzahlWarum es kein `?page=` gibt, wie `nextCursor` benutzt wird, welche zwei Umschläge es gibt — und wie ein inkrementeller Abgleich aussieht, der nichts überspringt.
- Gästekartei lesen, anlegen und pflegenDie Stammdaten der Gäste: exakte Suche statt Auskunftei, drei Sichtbarkeitsstufen, Merkmale additiv ändern und der Newsletter-Zustand als eine einzige verlässliche Frage.