Jeden klient zapętlony w retry loop bez backoffu może w jedną minutę wygenerować na twoim API więcej obciążenia niż pozostałe dziesięć tysięcy klientów razem wzięte. Z twojej strony to nie wygląda jak atak. Wygląda jak bug u nich, a staje się incydentem u ciebie. Rate limiter zatrzymuje tego klienta, zanim do tego dojdzie.
Co właściwie decyduje rate limiter?
Przy każdym requeście limiter zadaje sobie jedno pytanie: czy ten klucz wykonał w bieżącym oknie czasowym za dużo requestów? Trzy decyzje zmieniają to w coś, co można zaimplementować.
Klucz to to, względem czego liczysz: adres IP, API key, ID uwierzytelnionego użytkownika. Wybierz niewłaściwy i albo blokujesz niewinnych użytkowników, którzy dzielą jedną tożsamość, albo pozwalasz prawdziwemu nadużywającemu ukryć się wśród nich.
Okno i limit mają sens tylko razem. “100 requestów” to nie limit; 100 na sekundę i 100 na dzień to dwa bardzo różne API.
Akcja to to, co się dzieje, gdy klucz przekroczy limit. Większość API odrzuca z 429 Too Many Requests. Zadanie w tle lub nadawca webhooków częściej kolejkuje pracę i odbiera ją później, co jest zadaniem message queue (zobacz Czym Jest Kolejka Wiadomości i Jak Jej Używać).
Który algorytm, jaki store, jakie nagłówki: to wszystko jest implementacją wokół tej jednej kontroli.
Cztery algorytmy rate limiting i to, co każdy z nich zawala
Fixed window counter. Zwiększa licznik dla klucza i resetuje go, gdy zegar przekroczy granicę, powiedzmy co minutę, równo z zegarem. Kosztuje jeden integer na klucz. Wada: klient, który wysyła 100 requestów o 0:59 i kolejne 100 o 1:00, wysłał 200 requestów w dwie sekundy, nie przekraczając “100 na minutę” w żadnym z dwóch okien. Na granicy fixed window przepuszcza nawet dwukrotność zadeklarowanej stawki.
Sliding window log. Zapisuje timestamp każdego requestu dla klucza (sorted set w Redis nadaje się do tego dobrze), a przy każdym nowym requeście liczy, ile z nich przypada na ostatnie 60 sekund. Jest dokładny, więc trik z granicą nie działa. Koszt to pamięć, która rośnie z ruchem: liczenie miliona timestampów na klucz to nie to samo co odczyt jednego integera – ta sama różnica co między przeglądaniem listy a odczytem indeksu, której nazwę daje Notacja Big O Wyjaśniona na Przykładach Kodu.
Sliding window counter. To ten, którego powinna używać większość API. Trzymasz dwa liczniki fixed-window, aktualny i poprzedni, i szacujesz wartość sliding jako licznik_aktualny + licznik_poprzedni * (udział nakładania). Jeśli jesteś 40% w bieżącej minucie, liczysz wszystkie requesty z bieżącego okna plus 60% z poprzedniego. Dwa integery na klucz, brak exploita na granicy i wynik na tyle bliski dokładnemu, by starczył do realnych limitów.
Token bucket. Bucket przechowuje do capacity tokenów i uzupełnia się ze stałą rate na sekundę. Każdy request zabiera jeden token; bez tokena nie ma requestu. Klient może zrobić burst do capacity requestów naraz, a potem zostaje ograniczony do tempa uzupełniania. To odpowiada temu, jak przeglądarki i aplikacje faktycznie wywołują API: ładowanie strony odpala osiem requestów naraz, a potem nic na chwilę. Sztywny limit na sekundę odrzuciłby połowę takiego ładowania strony.
Leaky bucket. Ten sam bucket, odwrotny cel. Przychodzące requesty trafiają do kolejki i wychodzą ze stałym tempem, więc burst zmienia się w równy strumień. Token bucket decyduje, jaki burst jest klientowi dozwolony. Leaky bucket decyduje, jak stałe jest tempo wychodzące. Sięgaj po niego, gdy to, co jest dalej w łańcuchu (API zewnętrzne z własną kwotą albo worker pool o stałym rozmiarze) potrzebuje stałego zasilania.
Jak zbudować rate limiter z Redis
Licznik musi mieszkać w miejscu, które widzi każda instancja Twojego API. Licznik w pamięci procesu działa, dopóki masz jedną instancję; gdy pojawia się druga, każda wymusza limit tylko na połowie ruchu. Redis jest tu typowym store’em (zobacz Jak Działa Cache Redis i Jak Go Używać, jeśli jeszcze go nie skonfigurowałeś), a powodem są bardziej jego atomowe komendy niż szybkość.
Fixed window counter w Pythonie z INCR i EXPIRE:
| |
INCR jest atomowe na jednej instancji Redis, więc dwa requesty przychodzące w tej samej mikrosekundzie wciąż dostają dwa różne wyniki. Błąd, który niesie ze sobą większość ręcznie pisanych limiterów, to GET po którym idzie SET. Dwa równoległe requesty czytają ten sam licznik, obydwa uznają, że są poniżej limitu, i obydwa przechodzą. W wersji powyżej pozostaje jedna dziura: jeśli proces padnie między INCR a EXPIRE, klucz nigdy nie wygasa i ten klient zostaje zablokowany na zawsze. Przeniesienie obu wywołań do skryptu Lua, jak w token bucket poniżej, zamyka tę dziurę.
Token bucket wymaga trzech kroków: policzyć refill, zabrać token, zapisać stan z powrotem. Żadna pojedyncza komenda Redis nie robi tego wszystkiego naraz, więc wykonujesz je jako skrypt Lua przez EVAL, który Redis wykonuje jako jedną atomową jednostkę:
| |
Wywołuje się go przez redis.eval(script, 1, f"bucket:{key}", capacity, refill_rate, time.time()). Skrypt uzupełnia bucket o czas, który minął, zabiera token w tym samym przebiegu i ustawia TTL wystarczająco długi na pełny refill, więc klient, który przestaje wywoływać API, nie zostawia swojego bucketa zajmującego miejsce w Redis na zawsze. Żaden inny request nie może czytać ani zmieniać bucketa, kiedy skrypt działa.
Jakie nagłówki wysyłać z odpowiedzią 429
Odrzucony request powinien powiedzieć klientowi, kiedy wrócić.
Użyj 429 Too Many Requests, nie 403. Klient nie jest zablokowany na stałe, jest po prostu za szybki.
Dodaj Retry-After: 30, w sekundach albo jako data HTTP. Wiele bibliotek klienckich i SDK-ów HTTP czyta go, by zaplanować swój retry, a te, które tego nie robią, przynajmniej dają człowiekowi punkt odniesienia.
Wysyłaj X-RateLimit-Limit, X-RateLimit-Remaining i X-RateLimit-Reset w każdej odpowiedzi, nie tylko w odrzuconych. Mówią klientowi, jaki jest limit, ile zostało z kwoty i kiedy okno się resetuje. To konwencja de facto (API GitHuba z niej korzysta), a nie standard, ale to właśnie tego szukają twórcy klientów. Dobrze zachowujący się klient obserwuje, jak spada Remaining, i zwalnia, zanim w ogóle zobaczy 429.
Istnieje wersja standardowa w wersji roboczej: pola RateLimit i RateLimit-Policy z draft-ietf-httpapi-ratelimit-headers IETF. To Internet-Draft od kilku lat i wciąż nie jest RFC. Wysyłaj nagłówki X-RateLimit-* już teraz, a pola standardowe dodaj, gdy draft zostanie opublikowany jako RFC.
Częste błędy w rate limiting
Limitowanie tylko po IP. Sieć firmowa, NAT operatora mobilnego albo edge CDN mogą postawić tysiące realnych użytkowników za jednym adresem. Używaj jako klucza API key albo ID uwierzytelnionego użytkownika, gdy tylko go masz, a po IP sięgaj tylko dla ruchu anonimowego. JWT vs Sesja: Którą Autentykację Wybrać wyjaśnia, jak ta tożsamość jest ustalana.
Licznik w pamięci za load balancerem. Każda instancja liczy na własną rękę, więc limit 100 zmienia się w 100 × liczbę instancji. Gdy skalujesz się horyzontalnie, licznik musi mieszkać w Redis, nie w słowniku lokalnym dla procesu.
Jeden limit dla całego API. Wyszukiwanie i masowy eksport nie kosztują tyle samo w obsłudze. Ustaw limity per route, albo przynajmniej per klasa kosztu.
Zero tolerancji na burst. Retry webhooka, masowy import, aplikacja mobilna łącząca się na nowo po tunelu: dla sztywnego limitu na sekundę to wszystko wygląda jak nadużycie. Token bucket istnieje właśnie dla takiego ruchu.
Który rate limiter zbudować najpierw?
Dla typowego API z dużą liczbą odczytów zbuduj sliding window counter z kluczem na API key, przechowywany w Redis, z nagłówkami X-RateLimit-* w każdej odpowiedzi. Unikasz w ten sposób burstu na granicy fixed window i kosztu pamięciowego pełnego logu requestów. Token bucket dodaj tylko na route’ach, gdzie klienci legalnie wysyłają partie (dostawa webhooków, masowe uploady), i ustaw jego capacity na podstawie rozmiaru największej spodziewanej partii, nie na podstawie tempa w stanie ustalonym.