In klassischen REST Services ist Ratelimiting ein bekanntes Problem mit bekannten Lösungen. Hier hat jeder Endpoint und jede Aktion, die auf diesem Endpoint ausgeführt werden kann, bestimmte Kosten. Wird einer dieser Endpoints aufgerufen, werden die Kosten von einem Konto des Clients abgezogen. In REST ist das eine sehr einfache Methode, da die Kosten, die dem Betreiber durch das Aufrufen eines Endpoints entstehen, sehr berechenbar sind.
Bei GraphQL hingegen sieht die Sache anders aus. Hier gibt es nur einen einzigen Endpoint. Auch die Quries, die an den Server gestellt werden (und die am ehesten den althergebrachten Endpoints entsprechen), können sich in ihrer Komplexität erheblich voneinander unterscheiden. Das ist genau der Punkt, den GraphQL so stark von REST unterscheidet und der in Vergleichen oft als positiver Punkt für GraphQL aufgefasst wird. Doch diese Anpassbarkeit macht es schwerer und gleichzeitig absolut notwendig, ein Ratelimiting einzuführen. Ohne Ratelimiting könnte man mit einer einzigen Anfrage einen Server lahmlegen, in dem man rekursiv Daten abruft. Okay, es gibt zwar keine echte Rekursion in GraphQL, aber die Queries können sehr[SM1] [FP2] tief werden, was dann ziemlich nah an Rekursion herankommt. Es gibt Plugins, die die Tiefe begrenzen, aber ohne diese kann der User eine Query erstellen, die beliebig tief sein kann.
Für das Ratelimiting muss eine geeignete Metrik gefunden werden, die sowohl einfach berechnet, als auch bei einem Kunden abgebucht werden kann. Für eine solche Metrik gibt es verschiedene Optionen.
Die wahrscheinlich einfachste Möglichkeit wäre es, die Zeit, die ein Aufruf benötigen darf, zu begrenzen. Doch einige Queries werden länger benötigen, andere werden schneller ausgeführt werden. Daher ist es schwer, eine sinnvolle Obergrenze zu finden. Wenn die Query, die realistisch vorkommt und am längsten benötigt, 0.5 Sekunden braucht, haben alle Queries 0.5 Sekunden Zeit. Aber auch in dieser Zeit kann bereits erheblicher Schaden angerichtet werden. In dem Fall könnte für das tatsächliche Ratelimiting wieder die Anzahl an Anfragen gezählt werden. Ein User weiß immer vorher, wie viel die Query kosten wird, nämlich immer gleich viel. Aber Queries, die nicht viele Daten zurückgeben sollen und schnell ausgeführt werden, werden damit im Vergleich zu komplexeren Queries sehr teuer.
In einem ähnlichen Modell kann als Metrik auch die tatsächlich verbrauchte Zeit genutzt werden. Das ist aber für den User allerdings sehr schwer zu erfassen, da der User nicht weiß, wie lange eine bestimmte Query für die Ausführung braucht und welche Aktionen er dementsprechend durchführen will. Hier können höchstens mit der Zeit Erfahrungswerte gesammelt werden und Statistiken genutzt werden. Aber wenn die Last auf dem GraphQL Server steigt und dieser für die Bearbeitung der einzelnen Queries länger braucht, werden auch die Queries teurer. Das ist nicht unbedingt im Sinne des Betreibers oder Users.
Eine andere Metrik ist die Komplexität der Query. Diese kann in bestimmten Situationen sehr einfach berechnet und auch begrenzt werden. Wenn die Komplexität der Anfrage bestimmt wurde, kann sie sowohl als Metrik für das Ratelimiting, als auch als Schutz vor DOS Angriffen durch sehr komplexe Anfragen dienen.
Zur Bestimmung der Komplexität bekommt jedes einzelne Feld eine Komplexität. Die Komplexität der einzelnen Knoten einer Query wird addiert. Wenn die Komplexität ein bestimmtes Maß überschreitet, wird die Ausführung der Query unterbunden. Wenn ein Knoten eine Liste zurückgibt, sollte die Anzahl an Elementen, die zurückgegeben werden können, bekannt sein. Dazu können Parameter auf den Listen Elementen wie limit und offset genutzt werden. Das ist wichtig, da die Komplexität der Query und maximale Anzahl an Elementen, die von der Query zurückgegeben werden können, vor der Ausführung bekannt sein muss, um sie vor der Ausführung abzulehnen.
Diese Art der Begrenzung hat den Vorteil, dass es für einen Nutzer einfach nachzuvollziehen ist, wie sich sein aktueller Verbrauch zusammensetzt. Für den Serverbetreiber steigt zwar der Verwaltungsaufwand, aber auch nur gering. Um die Kosten und die Komplexität zu bestimmen, müssen Informationen über das Schema hinterlegt werden. Diese Informationen können entweder direkt im Schema mit Direktiven oder in einer gesonderten Cost Map angegeben werden. Die Angabe im Schema hat den Vorteil, dass keine Informationen doppelt gespeichert werden. Wenn jedoch Schemastitching genutzt wird, kann auf diese Informationen nicht zugegriffen werden. Auch wird in diesem Fall das Schema mit Informationen angereichert, die nicht unbedingt zum Schema gehören. Wenn die Kosten in einer Map angegeben werden, können auch beim Schemastiching die Kosten berechnet werden. Da unser Server Schemastitching verwendet (und der Use Case einfach interessanter ist :P), haben wir im folgenden Beispiel die Variante mit der Map umgesetzt.
Ein kleines Beispiel, um die Kosten zu verdeutlichen.
{
me {conversations(limit: 10) {
messages(limit: 10) {
author {
conversations(limit: 10) {
messages(limit: 3) {
text
}
}
}
}
}
}
}
Hier berechnen sich die Kosten wie folgend:
cost =
costs.me +
(costs.conversations + 10 *
(costs.messages + 10 *
(costs.author +
(costs.conversations + 10 *
(costs.messages + 3 *
(costs.text)
)
)
)
)
)
Wenn für jedes Element die Kosten 1 angenommen werden, bedeutet das, dass wir hier bereits Kosten von 4212 haben! Es können auch bis zu 3000 Elemente zurückgegeben werden. Wenn jetzt die Queries noch tiefer geschachtelt werden, steigen die Kosten enorm an.
Jedes Objekt und Feld hat hier die gleiche Komplexität und damit die gleichen Kosten. Wenn ein Objekt aber wesentlich größer oder selbst komplexer ist, als andere, kann für jedes Element die Kosten individuell angepasst werden. Ein anderer Faktor für die Kosten eines Feldes können auch die bei der Gewinnung der Daten entstandenen Kosten sein.
Eine einfache Möglichkeit die Komplexität zu bestimmen, ist das Plugin graphql-cost-analysis. Andere Plugins sind graphql-validation-complexity oder GraphQL Query Complexity Analysis for graphql-js. Alle gängigen GraphQL Server bieten die Möglichkeit Regeln zur Validierung der eingehenden Query zu definieren. Wir benutzen das Plugin graphql-cost-analysis. Das Plugin kann als eine solche Regel einfach eingebunden werden. Wenn die Komplexität ein bestimmtes Maximum überschreitet, wird die Ausführung abgebrochen. Im Nachgang kann die Komplexität noch von einem Konto für einen User abgezogen werden. Wenn das Konto vom User leer ist, kann die Query auch abgelehnt werden. Nach welchem Muster einem User Guthaben zur Verfügung gestellt wird, kann von der Anwendung abhängen. Eine Möglichkeit wäre es, pauschal eine gewisse Summe pro Stunde zur Verfügung zu stellen. Andere Möglichkeiten sind das Leaky Bucket Prinzip, oder eigene geeignete Modelle. Hier kann auch eine Schnittstelle für die Monetarisierung erfolgen. Wenn ein Nutzer mehr zahlt, als ein anderer, kann ihm mehr Guthaben gegeben werden, als anderen. In dem Fall sollte die Verwaltung der Kontingente an einen anderen Service ausgelagert werden, um die Domänen sauber zu trennen. In unserem Beispiel hat jeder Nutzer in einer Stunde ein Guthaben von 500.000 Punkten. Eine Stunde nach der ersten Anfrage wird das Konto zurückgesetzt. Jede Anfrage zieht dem Nutzer Punkte ab, auch wenn sie wegen ihrer Komplexität abgelehnt wird. Deshalb sollten User die Komplexität ihrer Anfrage im Vorfeld berechnen.
Also zusammengefasst: Ratelimiting in GraphQL ist mit einigen Plugins sehr einfach. Beim Verwenden von Remote Schemas sind die Möglichkeiten manchmal etwas eingeschränkt, da Direktiven nicht unbedingt zur Verfügung stehen. Aber einige Plugins bieten auch hier die Möglichkeit, damit umzugehen.