Rate-Limits verstehen, bevor Discord es einem beibringt
Ein Befehl, der zwanzig Nachrichten schickt, eine gesperrte Anwendung und ein Nachmittag mit HTTP-Kopfzeilen. Wie Rate-Limits funktionieren und wie man sie einhält, ohne alles zu verlangsamen.
959 Wörter · 5 Min. Lesezeit
- yurna
- discord
- api
Ich wollte eine Serverstatistik ausgeben: für jeden der zwanzig aktivsten Nutzer eine Zeile, jede als eigene Nachricht, damit man sie einzeln zitieren kann. Der Befehl lief, die ersten fünf Nachrichten kamen, dann wurde es zäh, dann kam gar nichts mehr, und in meiner Konsole stapelten sich Fehler mit dem Status 429.
Eine Stunde später war meine Anwendung vorübergehend komplett gesperrt — kein einzelner Endpunkt mehr, sondern global. Das war der Nachmittag, an dem ich gelernt habe, wie Rate-Limits funktionieren.
Wie eine Begrenzung aufgebaut ist
Rate-Limits sind bei Discord nicht ein Wert, sondern viele, und sie hängen am sogenannten Bucket. Vereinfacht: Für „Nachricht in Kanal X senden" gilt eine eigene Zählung, für „Nachricht in Kanal Y senden" eine andere, für „Rolle vergeben" wieder eine andere.
Die Antwort auf jede Anfrage sagt einem, wie es um den jeweiligen Eimer steht:
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 2
X-RateLimit-Reset-After: 1.482
X-RateLimit-Bucket: abcd1234...
Das liest sich so: In diesem Eimer sind fünf Anfragen erlaubt, zwei habe ich noch, in knapp anderthalb Sekunden wird wieder aufgefüllt. Wer diese Angaben auswertet, muss nie einen Fehler produzieren — man wartet einfach, bevor der Eimer leer ist.
Passiert es trotzdem, kommt Status 429 mit einer Angabe, wie lange man warten muss:
{ "message": "You are being rate limited.", "retry_after": 0.925, "global": false }Das entscheidende Feld ist global. Ein lokales Limit betrifft einen Endpunkt. Ein globales betrifft alles, was die Anwendung tut — und genau dort war ich gelandet, weil ich auf die 429er nicht reagiert, sondern sofort erneut probiert hatte.
Warnung
Der teuerste Fehler ist nicht, in ein Limit zu laufen. Es ist, danach sofort wieder anzufragen. Jede weitere Anfrage in der Sperrzeit zählt gegen einen. Wer in einer Schleife wiederholt, ohne zu warten, wird von einer lokalen Bremse in eine globale Sperre laufen — und die trifft dann auch alle anderen Server, auf denen der Bot läuft.
Was ich falsch gemacht habe
Ich habe zwanzig Nachrichten geschickt, wo eine gereicht hätte. Das war der eigentliche Fehler und keine Frage der Technik. Eine Liste mit zwanzig Zeilen ist eine Nachricht. Die Idee, jede Zeile einzeln zu verschicken, war Bequemlichkeit beim Programmieren, nicht ein Bedürfnis der Nutzer.
Ich habe alles parallel abgeschickt. Promise.all über zwanzig Sendevorgänge fühlt sich modern an und ist an dieser Stelle genau falsch. Zwanzig gleichzeitige Anfragen an denselben Endpunkt sind der schnellste Weg in ein Limit.
Ich habe die Fehler nicht gelesen. Der 429er enthält alles, was man braucht. Ich habe ihn als „Fehler" behandelt und wiederholt, statt als „Anweisung" und gewartet.
Wie es danach aussah
Erstens: weniger Anfragen. Statt zwanzig Nachrichten eine, statt einzelner Rollenvergaben eine gesammelte Änderung des Mitglieds, statt Abfragen im Sekundentakt ein Zwischenspeicher.
Zweitens: eine Warteschlange pro Ziel. Zwei Nachrichten in denselben Kanal gehen nacheinander, Nachrichten in verschiedene Kanäle dürfen parallel laufen.
const schlangen = new Map();
function inSchlange(schluessel, aufgabe) {
const vorher = schlangen.get(schluessel) ?? Promise.resolve();
const jetzt = vorher.then(aufgabe, aufgabe);
schlangen.set(schluessel, jetzt.catch(() => {}));
return jetzt;
}Drittens: Wiederholung mit Rücksicht. Bei einem 429er wird genau so lange gewartet, wie die Antwort sagt, und danach einmal erneut versucht — nicht endlos.
async function mitRuecksicht(anfrage, versuche = 3) {
for (let i = 0; i < versuche; i++) {
const antwort = await anfrage();
if (antwort.status !== 429) return antwort;
const daten = await antwort.json();
await warten((daten.retry_after ?? 1) * 1000 + 100);
}
throw new Error("Rate-Limit auch nach mehreren Versuchen aktiv");
}Die zusätzlichen hundert Millisekunden sind kein Aberglaube: Die Uhren von Client und Server laufen nicht synchron, und wer auf die Millisekunde genau wieder anfragt, landet gelegentlich erneut im Limit.
Wie ich heute mit fremden Schnittstellen umgehe
Aus diesem Nachmittag ist eine feste Reihenfolge geworden, die ich bei jeder neuen Schnittstelle durchgehe, bevor ich den ersten produktiven Aufruf mache.
Erstens: die Grenzen nachlesen. Wie viele Anfragen pro Zeiteinheit, pro Endpunkt, pro Anwendung? Steht praktisch immer in der Dokumentation, und praktisch nie liest es jemand vorher.
Zweitens: die Antwortstruktur bei Fehlern ansehen. Welcher Statuscode, welche Kopfzeilen, welcher Inhalt? Ein Fehler, den man kennt, ist eine Anweisung. Ein Fehler, den man nicht kennt, ist ein Rätsel.
Drittens: einen einzigen Aufruf machen und die vollständige Antwort protokollieren. Alle Kopfzeilen, den ganzen Rumpf. Das ist die beste Dokumentation, die es gibt, weil sie den tatsächlichen Stand zeigt statt den beschriebenen.
Viertens: absichtlich in die Grenze laufen — in einer Testumgebung, mit einem harmlosen Endpunkt. Man will das Verhalten kennen, bevor man es im Betrieb sieht.
Der vierte Punkt ist der, den ich am wertvollsten finde und der am seltensten gemacht wird. Eine Begrenzung, deren Verhalten man einmal ausgelöst hat, ist ein bekannter Zustand. Eine, die man nur aus der Dokumentation kennt, ist eine Vermutung.
Der Nebeneffekt, der wichtiger war als die Technik
Nachdem ich die Warteschlange gebaut hatte, ist mir etwas aufgefallen: Die Zahl der Anfragen meines Bots ist nicht deshalb gesunken, weil ich sie gedrosselt habe, sondern weil ich beim Umbau gemerkt habe, wie viele davon überflüssig waren.
Eine Rangliste, die bei jedem Aufruf sämtliche Nutzer einzeln nachschlug. Eine Statusanzeige, die im Sekundentakt aktualisierte, obwohl sie sich stündlich ändert. Eine Prüfung, die bei jeder Nachricht Serverdaten neu holte, statt sie zu behalten.
Jede dieser Stellen war für sich harmlos und in Summe der eigentliche Verbrauch. Die Begrenzung war insofern kein Hindernis, sondern eine Rückmeldung über die Qualität meines Codes — eine unangenehme, aber sehr direkte.
Was das mit allem anderen zu tun hat
Diese Denkweise ist auf jede fremde Schnittstelle übertragbar, und ich habe sie seitdem immer wieder gebraucht — bei Zahlungsanbietern, bei Kartendiensten, bei Downloadservern, bei Modrinth-Abfragen für Minecraft-Plugins.
Die Regeln sind überall dieselben:
- Die Antwort sagt dir, was du tun sollst. Kopfzeilen und Fehlerinhalte sind keine Deko.
- Wiederholung braucht eine Obergrenze und eine wachsende Wartezeit. Sonst baut man einen Selbstangriff.
- Weniger Anfragen ist immer besser als klügere Wiederholung. Zwischenspeichern, zusammenfassen, gar nicht erst fragen.
- Fremde Grenzen sind kein Ärgernis, sondern Information. Sie sagen, wofür eine Schnittstelle gedacht ist. Wenn man ständig anstößt, benutzt man sie meistens auf eine Art, die nicht vorgesehen war.
Der letzte Punkt hat bei mir am längsten gebraucht. Mein erster Impuls war Ärger über die Begrenzung. Der zweite, richtige Gedanke kam später: Zwanzig Nachrichten für eine Rangliste sind nicht das, was Discord kaputtmacht — sie sind das, was den Chat kaputtmacht. Das Limit hat mich vor einem Feature bewahrt, das ohnehin schlecht war.