Throttling API w Springu

Publicznie dostępne API, takie jak Star Trek API, w miarę, jak zyskują popularność, muszą poradzić sobie ze zwiększającym się ruchem. Jedną ze strategii radzenia sobie ze zbyt wieloma zapytaniami jest nałożenie limitu na klienty. Można to zrobić na kilka sposobów, np. implementując limit godzinny lub dobowy dla danego adresu IP, lub odnawialne, płatne lub odświeżane cyklicznie, limity dla kluczy API.

Istniejące implementacje

Spodziewałem się, że ten problem został już rozwiązany i wystarczy dorzucić do projektu zależność, która zajęłaby się throttlingiem. Tak jednak nie jest. Istnieje co prawda Apache CXF Throttling Feature, ale analizując kod doszedłem do wniosku, że nie nadaje się on do throttlingu w sytuacji, gdy mamy wiele klientów. Nadaje się jedynie do throttlingu per zasób, a to mnie nie zadowala. W internecie można znaleźć też posty polecające RateLimiter z Guavy, ale tutaj z kolei filozofia jest taka, że limitujemy dostęp do zasobu, a wątek, który chce zająć zasób, ma czekać, jeśli zasób jest już zajęty. To nie wpisuje się w moją wizję.

Założenia

Postanowiłem zaimplementować omawianą funkcjonalność samodzielnie. Na pierwszy ogień poszedł throttling na podstawie adresu IP. Założenia były proste. Adresy IP, które uzyskują dostęp do API, będą zapisywane w bazie. Pierwszy zapis adresu IP ustawi jego maksymalny limit zapytań na 100, a ilość pozostałych zapytań na 99. O każdej pełnej godzinie limity są odświeżane i każdy adres IP może znów wykonać 100 zapytań. Co godzinę, 30 minut po pełnej godzinie, adresy które nie wykonywały zapytań dłużej, niż dzień, są usuwane.

Model

Postanowiłem nie zamykać się na możliwość, że w przyszłości zaimplementuję także throttling na podstawie kluczy API, a więc schemat bazy danych przygotowałem pod obie te ewentualności. W ten sposób powstał model oraz towarzyszące mu repozytorium z niezbędnymi operacjami.

Interceptory

Skoro było już skąd pobrać i gdzie zapisać informację o tym, czy dany adres IP może dalej wysyłać zapytania do API i otrzymywać poprawne odpowiedzi, trzeba było zaimplementować interceptory, które pozwoliłyby odpowiednio wcześnie zdecydować o tym, że przetrwarzania żądania powinno zostać anulowane. Nie chodzi wszak o to, żeby zapytać bazę, a na koniec zamiast poprawnej odpowiedzi pokazać komunikat o błędzie.

Na początek trzeba było stworzyć interfejs ApiThrottlingInterceptor. Bean implementujący interfejs zostaje zarejestrowany w trakcie tworzenia CXF-owego serwera, oraz we wszysktich endpointach SOAP-owych, w ich wspólnej metodzie wytwórczej. Interfejs będzie implementowany przez dwie klasy, zależnie od tego, czy jest włączony springowy profil odpowiedzialny za throttling, czy nie. Wówczas, gdy profil nie jest obecny, jako bean zostanie zarejestrowana klasa, która nic nie robi. Jeśli profil jest obecny, zostanie zarejestrowana klasa, która deleguje nadchodzące zapytanie do fasady.

Proces walidacji

Zadaniem fasady jest przekazanie nadchodzącego zapytania do walidatora i, jeśli walidator nie pozwoli, by zapytanie było dalej procesowane, rzucenie wyjątku, inego dla REST-owych, a innego dla SOAP-owych zapytań.

Walidator ma na celu pobranie adresu IP z zapytania i sprawdzenie, czy ten adres IP może jeszcze wykonywać zapytania. Jeśli tak, zwraca DTO z flagą throttle ustawioną na fałsz, i pustymi komunikatem o błędzie. Jeśli adres IP nie może już wykonywać zapytać, zwracany jest DTO z flagą throttle ustawioną na prawdę, oraz powodem throttlingu.

Komunikaty o błędach

Jeśli chodzi o SOAP-owy wyjątek, rzucenei SoapFault-a wystarczy, by CXF zamienił go na komunikat w spodziewanym formacie. W przypadku REST-owego błędu trzeba była napisać customowy mapper, który przemapowuje REST-owy wyjątek na format zdefiniowany w swaggerowym kontrakcie.

Procesy cykliczne

Na koniec trzeba było stworzyć dwa procesy cykliczne. Jeden do odświeżania limitów IP, a drugi do kasowania starych wpisów z adresami IP. Oba jako jedyną zależność mają repozytium encji Throttle, i po prostu wykonują na nim wymagane operacje.

Podsumowanie

I to właściwie wszystko. Jedyną podstępną rzeczą, na którą trzeba uważać w implementacji takiej jak ta, to by dekrementować ilość pozostałych zapytań za pomocą SQL, a nie Javy. Może się bowiem zdarzyć, że pobierzemy dla kogoś limit i zechcemy go w następnej operacji zapisać pomniejszony o 1, a w międzyczasie proces cykliczny ponownie ustawi limit na 100. Czyli trzeba zrobić tak.

Myślę, że zaimplementuję w przyszłości także throttling na podstawie kluczy API. Nie robię tego w tej chwili, bo w przeciwieństwie do limitowania dostępu do API na podstawie adresu IP, limitowanie na podstawie kluczy wymaga, by powstał jakoś frontend, do którego można się zalogować, najlepiej GitHubem, Facebookiem i Googlem. Wymagane byłoby tu też, aby każdy zalogowany użytkownik mógł swoimi kluczami zarządzać, czyli przynajmniej tworzyć nowe, inwalidować stare, a być może też requestować większe limity lub je dokupować. To praca, której nie chcę na razie wykonywać, i wolę skupić się na skończeniu samego API.