レートリミット(SlowAPI)¶
SlowAPI は、レートリミットをエンドポイントごとに @limiter デコレーターで設定できるライブラリ。
後々 Redis を使うので、 Redis のセットアップも済ませておいた方がいい。
- インストール
サンプルコード¶
/ へのアクセスを 1 分間に 5 回までに制限する。
デコレーターの順番
必ず FastAPI の @app デコレーターの下に @limiter デコレーターを書く: cf. Decorators order
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler, # type: ignore
)
@app.get("/")
@limiter.limit("5/minute")
async def read_root(request: Request):
return {"message": "Rate is not exceeded."}
実験1¶
Uvicorn をワーカー数 1 で起動してレートリミットが効いているかを確認する。
| 項目 | 値 |
|---|---|
| --workers | 1 |
| FastAPI サーバー | 192.168.0.88 |
| クライアント | 192.168.0.89, 192.168.0.90 の 2 つ |
curl で http://192.168.0.88:8000/ にリクエストを送る。1 秒間に 1 回くらいでテキトーに連打する。
Uvicorn ログ
$ uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1
INFO: Started server process [1659]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 192.168.0.89:62501 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62502 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62503 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62504 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62505 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62506 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:62507 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:62508 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62364 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62365 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62366 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62367 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62368 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62369 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62370 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62371 - "GET / HTTP/1.1" 429 Too Many Requests
### 1分間待つ ###
INFO: 192.168.0.89:62756 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:62757 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:61310 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:61311 - "GET / HTTP/1.1" 200 OK
^CINFO: Shutting down
- 5 回目まではどっちのクライアントも OK だけど、 6 回目以降は Too Many Requests になってる: IP アドレスごとに制限できてる。
- 1 分経過したら、どっちのクライアントも OK に戻る: 制限される時間も OK。
実験2¶
ワーカー数を 4 に増やすとどうなるかを確認する。
| 項目 | 値 |
|---|---|
| --workers | 4 |
| FastAPI サーバー | 192.168.0.88 |
| クライアント | 192.168.0.89 の 1 つ |
curl を 1 秒間に 1 回くらいでテキトーに連打する。
Uvicorn ログ
$ uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started parent process [1707]
INFO: Started server process [1713]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [1710]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [1712]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [1711]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 192.168.0.89:63523 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63524 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63525 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63526 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63527 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63528 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63529 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63530 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63531 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63532 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63533 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63534 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63535 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63536 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63537 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63538 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63539 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63540 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63541 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63542 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63543 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63544 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63545 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63546 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63547 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63548 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63549 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63550 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63551 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63552 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63553 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63554 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63555 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63556 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63557 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63558 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63559 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:63560 - "GET / HTTP/1.1" 429 Too Many Requests
(中略)
INFO: 192.168.0.89:63092 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63093 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63094 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63095 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63096 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63097 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63098 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63099 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63100 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63101 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63102 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63103 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63104 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63105 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63106 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63107 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:63108 - "GET / HTTP/1.1" 429 Too Many Requests
^CINFO: Shutting down
- 5 回ではレートリミットがかからなくて、 20 回(ワーカー数 × 5)を使い切る感じになってる。
- どのワーカーがリクエストを受け付けるかによって、制限がかかるときもあれば、まだ大丈夫なときもある。
- ちゃんと FastAPI がロードバランスをやってる証拠でもあるか。
- 複数ワーカーで FastAPI を起動しつつレートリミットも安定させたいとなると、他の方法と組み合わせないとダメ。
- Redis が使えそう: cf. Use Redis as backend for the limiter
サンプルコード変更: Redis を使うように¶
Redis のセットアップを完了させてから、 Limiter に storage_uri を渡す。
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379",
)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler, # type: ignore
)
@app.get("/")
@limiter.limit("5/minute")
async def read_root(request: Request):
return {"message": "Rate is not exceeded."}
Redis が停止したときの挙動
上のデフォルト設定のままだと、もし Redis に繋がらなくなったら、レート制限をかけたエンドポイントは HTTP 500 を返す。ログインのエンドポイントにレートリミットをかけてたとしたら、 Redis に繋がらないときはログインできなくなる。
Traceback エラーログ
Traceback (most recent call last):
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1033, in connect_check_health
sock = self._connect()
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1581, in _connect
raise err
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1565, in _connect
sock.connect(socket_address)
~~~~~~~~~~~~^^^^^^^^^^^^^^^^
ConnectionRefusedError: [Errno 111] Connection refused
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/uvicorn/protocols/http/httptools_impl.py", line 421, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
self.scope, self.receive, self.send
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/uvicorn/middleware/proxy_headers.py", line 63, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/applications.py", line 1159, in __call__
await super().__call__(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/applications.py", line 90, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/middleware/errors.py", line 186, in __call__
raise exc
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette_csrf/middleware.py", line 72, in __call__
await self.app(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/middleware/cors.py", line 96, in __call__
await self.simple_response(scope, receive, send, request_headers=headers)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/middleware/cors.py", line 154, in simple_response
await self.app(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
await self.app(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/routing.py", line 660, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/routing.py", line 680, in app
await route.handle(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/routing.py", line 276, in handle
await self.app(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/routing.py", line 134, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/routing.py", line 120, in app
response = await f(request)
^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/routing.py", line 674, in app
raw_response = await run_endpoint_function(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<3 lines>...
)
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/fastapi/routing.py", line 330, in run_endpoint_function
return await run_in_threadpool(dependant.call, **values)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/starlette/concurrency.py", line 32, in run_in_threadpool
return await anyio.to_thread.run_sync(func)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/anyio/to_thread.py", line 63, in run_sync
return await get_async_backend().run_sync_in_worker_thread(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/anyio/_backends/_asyncio.py", line 2518, in run_sync_in_worker_thread
return await future
^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/anyio/_backends/_asyncio.py", line 1002, in run
result = context.run(func, *args)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/slowapi/extension.py", line 764, in sync_wrapper
self._check_request_limit(request, func, False)
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/slowapi/extension.py", line 630, in _check_request_limit
self.__evaluate_limits(request, _endpoint_key, all_limits)
~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
if not self.limiter.hit(lim.limit, *args, cost=cost):
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/limits/strategies.py", line 157, in hit
self.storage.incr(
~~~~~~~~~~~~~~~~~^
item.key_for(*identifiers),
^^^^^^^^^^^^^^^^^^^^^^^^^^^
item.get_expiry(),
^^^^^^^^^^^^^^^^^^
amount=cost,
^^^^^^^^^^^^
)
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/limits/storage/base.py", line 25, in inner
return fn(*args, **kwargs)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/limits/storage/redis.py", line 224, in incr
return int(self.lua_incr_expire([key], [expiry, amount]))
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/commands/core.py", line 10520, in __call__
return client.evalsha(self.sha, len(keys), *args)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/commands/core.py", line 10878, in evalsha
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/commands/core.py", line 10846, in _evalsha
return self.execute_command(command, sha, numkeys, *keys_and_args)
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/client.py", line 787, in execute_command
return self._execute_command(*args, **options)
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/client.py", line 793, in _execute_command
conn = self.connection or pool.get_connection()
~~~~~~~~~~~~~~~~~~~^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/utils.py", line 258, in wrapper
return func(*args, **kwargs)
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 3139, in get_connection
connection.connect()
~~~~~~~~~~~~~~~~~~^^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1006, in connect
self.retry.call_with_retry(
~~~~~~~~~~~~~~~~~~~~~~~~~~^
lambda: self.connect_check_health(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...<2 lines>...
lambda error: self.disconnect(error),
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
)
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/retry.py", line 132, in call_with_retry
raise error
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/retry.py", line 120, in call_with_retry
return do()
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1007, in <lambda>
lambda: self.connect_check_health(
~~~~~~~~~~~~~~~~~~~~~~~~~^
check_health=True, retry_socket_connect=False
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
),
^
File "/home/alice/my-fastapi-app/.venv/lib/python3.14/site-packages/redis/connection.py", line 1055, in connect_check_health
raise e
要件に応じてだと思うけど、無制限にログインを試せる状況になってもいいとか不安定になってもいいなら、 Limiter に下の 2 つのどちらかを渡す。
swallow_errors=True… エラーログを出すけど、制限なしでリクエストを通す。in_memory_fallback_enabled=True… Redis が落ちたらインメモリ制限にフォールバックする(ただしワーカー間でカウンターは共有されない)。
SlowApi のソースコードと Traceback エラーログを見るに、Limiter._check_request_limit の部分でこの挙動が定義されてる。
Limiter._check_request_limit
最後の self.__evaluate_limits(...) の中で self.limiter.hit(...) が Redis にアクセスし、例外が出ると except Exception で捕まえる。 RateLimitExceeded だったらそのまま再送出、それ以外は in_memory_fallback_enabled ならフォールバック、無効なら swallow_errors 次第でエラーログに出すか再送出する。 Redis に繋がらなくて再送出された例外は RateLimitExceeded 用のハンドラーに捕まらないので、最終的に HTTP 500 になる。
def _check_request_limit(
self,
request: Request,
endpoint_func: Optional[Callable[..., Any]],
in_middleware: bool = True,
) -> None:
"""
Determine if the request is within limits
"""
endpoint_url = request["path"] or ""
view_func = endpoint_func
endpoint_func_name = (
f"{view_func.__module__}.{view_func.__name__}" if view_func else ""
)
_endpoint_key = endpoint_url if self._key_style == "url" else endpoint_func_name
# cases where we don't need to check the limits
if (
not _endpoint_key
or not self.enabled
# or we are sending a static file
# or view_func == current_app.send_static_file
or endpoint_func_name in self._exempt_routes
or any(fn() for fn in self._request_filters)
):
return
limits: List[Limit] = []
dynamic_limits: List[Limit] = []
if not in_middleware:
limits = (
self._route_limits[endpoint_func_name]
if endpoint_func_name in self._route_limits
else []
)
dynamic_limits = []
if endpoint_func_name in self._dynamic_route_limits:
for lim in self._dynamic_route_limits[endpoint_func_name]:
try:
dynamic_limits.extend(list(lim.with_request(request)))
except ValueError as e:
self.logger.error(
"failed to load ratelimit for view function %s (%s)",
endpoint_func_name,
e,
)
try:
all_limits: List[Limit] = []
if self._storage_dead and self._fallback_limiter:
if in_middleware and endpoint_func_name in self.__marked_for_limiting:
pass
else:
if self.__should_check_backend() and self._storage.check():
self.logger.info("Rate limit storage recovered")
self._storage_dead = False
self.__check_backend_count = 0
else:
all_limits = list(itertools.chain(*self._in_memory_fallback))
if not all_limits:
route_limits: List[Limit] = limits + dynamic_limits
all_limits = (
list(itertools.chain(*self._application_limits))
if in_middleware
else []
)
all_limits += route_limits
combined_defaults = all(
not limit.override_defaults for limit in route_limits
)
if (
not route_limits
and not (
in_middleware
and endpoint_func_name in self.__marked_for_limiting
)
or combined_defaults
):
all_limits += list(itertools.chain(*self._default_limits))
# actually check the limits, so far we've only computed the list of limits to check
self.__evaluate_limits(request, _endpoint_key, all_limits)
except Exception as e: # no qa
if isinstance(e, RateLimitExceeded):
raise
if self._in_memory_fallback_enabled and not self._storage_dead:
self.logger.warning(
"Rate limit storage unreachable - falling back to"
" in-memory storage"
)
self._storage_dead = True
self._check_request_limit(request, endpoint_func, in_middleware)
else:
if self._swallow_errors:
self.logger.exception("Failed to rate limit. Swallowing error")
else:
raise
limits の hit
cf. https://github.com/alisaifee/limits/blob/22d7c4f6fd26eeb02f770edf2e0f12a58eafc214/limits/strategies.py#L144
hit は self.storage.incr(...) でカウンターを 1 増やそうとするだけ。 Redis が停止してるとこの incr が例外を投げる。
def hit(self, item: RateLimitItem, *identifiers: str, cost: int = 1) -> bool:
"""
Consume the rate limit
:param item: The rate limit item
:param identifiers: variable list of strings to uniquely identify this
instance of the limit
:param cost: The cost of this hit, default 1
:return: True if ``cost`` could be deducted from the rate limit without exceeding it
"""
return (
self.storage.incr(
item.key_for(*identifiers),
item.get_expiry(),
amount=cost,
)
<= item.amount
)
limits の incr(RedisStorage)
cf. https://github.com/alisaifee/limits/blob/22d7c4f6fd26eeb02f770edf2e0f12a58eafc214/limits/storage/redis.py#L209
incr が Lua スクリプト( lua_incr_expire )を Redis に投げる。 Redis に繋がらないと、ここで ConnectionError が出て、上に伝播していく。
def incr(
self,
key: str,
expiry: int,
amount: int = 1,
) -> int:
"""
increments the counter for a given rate limit key
:param key: the key to increment
:param expiry: amount in seconds for the key to expire in
:param amount: the number to increment by
"""
key = self.prefixed_key(key)
return int(self.lua_incr_expire([key], [expiry, amount]))
Redis 接続のタイムアウト設定¶
storage_uri への接続には redis-py が使われる。 Redis が停止してると上のように即 ConnectionRefusedError で返ってくるけど、 Redis が応答しないとき(過負荷でハングした、ネットワークが切れたなど)は、タイムアウトするまでリクエストが待たされる。タイムアウトの時間は socket_timeout(確立済みの接続での読み書き)と socket_connect_timeout(接続を確立するまで)で設定できる。
redis-py は 8.0.0 でこのデフォルトが 5 秒に変わった: cf. redis/_defaults.py
それより前は None(無制限)で、応答が返らないと待ち続ける挙動だった: cf. process hangs due to default socket_timeout=None
SlowAPI は storage_uri を limits 経由で redis-py の from_url にそのまま渡す。 limits 側はタイムアウトを上書きしないので、 redis-py のデフォルトがそのまま効く。明示するなら Limiter の storage_options に渡す。短いネットワークの揺らぎで誤タイムアウトしないよう、 1 秒以上にしておくと良いらしい。
実践Redis入門 技術の仕組みから現場の活用まで (by 林昌吾, 2022)
クライアント側に設定されたソケットのタイムアウトの設定値を確認しておくと良いです。少なくとも1秒以上で設定することをお勧めします。
— p.222
redis-py 8.0.0 なら、デフォルトの 5 秒がすでにこの「1 秒以上」を満たしてるので、何も指定しなくても問題はない。タイムアウトを調整する時は、下記のように。
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379",
storage_options={
"socket_timeout": 1,
"socket_connect_timeout": 1,
}
)
実験3¶
ストレージを Redis にして、ワーカー数 4 でもレートリミットが安定するかを確認する。
| 項目 | 値 |
|---|---|
| --workers | 4 |
| FastAPI サーバー | 192.168.0.88 |
| クライアント | 192.168.0.89, 192.168.0.90 の 2 つ |
| ストレージ | Redis |
Uvicorn ログ
$ uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started parent process [14292]
INFO: Started server process [14294]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [14295]
INFO: Started server process [14296]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [14297]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 192.168.0.89:61726 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:61727 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:61728 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:61729 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:61730 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.89:61731 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61732 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61733 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61734 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61735 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62739 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62740 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62741 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62742 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62743 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62744 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62745 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62746 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62747 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:62748 - "GET / HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:62493 - "GET / HTTP/1.1" 200 OK
INFO: 192.168.0.90:62420 - "GET / HTTP/1.1" 200 OK
^CINFO: Shutting down
- IP 単位で制限できてるし、 1 分間に 5 回までっていうのもちゃんと効いてる。複数ワーカーでも Redis でカウンターを共有できてる。
Redis の中身も確認¶
起動直後は KEYS に何もない。
クライアント 192.168.0.89 から 1 回 curl した後。まだ制限されないけど KEY に値が入って TTL も減り始める。カウンターに 1 が入る。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/192.168.0.89///5/1/minute"
127.0.0.1:6379> TTL "LIMITS:LIMITER/192.168.0.89///5/1/minute"
(integer) 48
127.0.0.1:6379> GET "LIMITS:LIMITER/192.168.0.89///5/1/minute"
"1"
192.168.0.89 から 6 回連続で curl した後。制限された状態で、カウンターの値自体は 5 を超えてる( 10 にでも 1000 にでもなる)。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/192.168.0.89///5/1/minute"
127.0.0.1:6379> TTL "LIMITS:LIMITER/192.168.0.89///5/1/minute"
(integer) 51
127.0.0.1:6379> GET "LIMITS:LIMITER/192.168.0.89///5/1/minute"
"6"
TTL の 1 分以上が経過した後。 KEY は削除済み( TTL が -2 )になって、 カウンターもない( nil )。
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> TTL "LIMITS:LIMITER/192.168.0.89///5/1/minute"
(integer) -2
127.0.0.1:6379> GET "LIMITS:LIMITER/192.168.0.89///5/1/minute"
(nil)
192.168.0.89 と 192.168.0.90 それぞれから 1 回ずつ curl した後。 IP アドレスごとに KEY が作られてる。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/192.168.0.89///5/1/minute"
2) "LIMITS:LIMITER/192.168.0.90///5/1/minute"
サンプルコード変更: パスパラメーターごとに制限する API (/hello) を追加¶
パスパラメータに入ってる name を取り出す get_path_param_name 関数を定義して、 key_func=get_path_param_name として @limiter デコレーターに書く。これで同じ name では 1 分間に 3 回までしかリクエストできなくなる。
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
def get_path_param_name(request: Request) -> str:
name = request.path_params.get("name", "unknown")
return f"name:{name}"
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379",
)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler, # type: ignore
)
@app.get("/")
@limiter.limit("5/minute")
async def read_root(request: Request):
return {"message": "Rate is not exceeded."}
@app.get("/hello/{name}")
@limiter.limit("3/minute", key_func=get_path_param_name)
async def hello(request: Request, name: str):
return {"message": f"Hello, {name}"}
実験4¶
パスパラメータの name ごとに 3 回までの制限がちゃんとかかるかを確認する。
| 項目 | 値 |
|---|---|
| --workers | 4 |
| FastAPI サーバー | 192.168.0.88 |
| クライアント | 192.168.0.89 の 1 つ |
| ストレージ | Redis |
curl で 1 分間以内に Uvicorn ログにある全てのリクエストを送った。
Uvicorn ログ
$ uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started parent process [16488]
INFO: Started server process [16490]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [16491]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [16492]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [16493]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 192.168.0.89:60401 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:60402 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:60403 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:60404 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:60405 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:60406 - "GET /hello/alice2 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60407 - "GET /hello/alice2 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60408 - "GET /hello/alice2 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60409 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:60410 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:60411 - "GET /hello/alice3 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60412 - "GET /hello/alice3 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60413 - "GET /hello/alice3 HTTP/1.1" 200 OK
INFO: 192.168.0.89:60414 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:60415 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
^CINFO: Shutting down
nameごとに 3 回までなら OK が返ってきて、それ以上は Too Many Requests。
Redis の中身も確認する。起動直後は KEY が何もない。
name=alice, alice2, alice3 でリクエストして制限がかかった後。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/name:alice3//hello/alice3/3/1/minute"
2) "LIMITS:LIMITER/name:alice//hello/alice/3/1/minute"
3) "LIMITS:LIMITER/name:alice2//hello/alice2/3/1/minute"
127.0.0.1:6379> GET "LIMITS:LIMITER/name:alice//hello/alice/3/1/minute"
"5"
127.0.0.1:6379> GET "LIMITS:LIMITER/name:alice2//hello/alice2/3/1/minute"
"5"
127.0.0.1:6379> GET "LIMITS:LIMITER/name:alice3//hello/alice3/3/1/minute"
"5"
サンプルコード変更: /hello に IP アドレスごとの制限も追加¶
@limiter デコレーターを 2 つ書く。 key_func を指定しない場合は、 Limiter 定義の key_func=get_remote_address が効く。
パスパラメータが入ってると、 IP アドレスごとに回数制限をしても name が変わるたびに URL が変わって /hello 全体に対する制限ができないので、 shared_limit を scope 付きで使う。
Info
例えば、パスパラメーターとしてメールアドレスを受け取って何かの処理をするエンドポイントがあったと考える。
IP アドレスごとの制限もしておかないと 実験 4 の結果から、攻撃者が大量のメールアドレスのリストを使ってアカウントが存在するかどうかを調べられる状況になるかもしれない。
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
def get_path_param_name(request: Request) -> str:
name = request.path_params.get("name", "unknown")
return f"name:{name}"
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379",
)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(
RateLimitExceeded,
_rate_limit_exceeded_handler, # type: ignore
)
@app.get("/")
@limiter.limit("5/minute")
async def read_root(request: Request):
return {"message": "Rate is not exceeded."}
@app.get("/hello/{name}")
@limiter.shared_limit("5/minute", scope="hello") # scope は名前をつけてるだけ。何でもいい
@limiter.limit("3/minute", key_func=get_path_param_name)
async def hello(request: Request, name: str):
return {"message": f"Hello, {name}"}
実験5¶
name ごとの 3 回制限と、 /hello 全体への IP ごと 5 回制限( shared_limit )が両立するかを確認する。
| 項目 | 値 |
|---|---|
| --workers | 4 |
| FastAPI サーバー | 192.168.0.88 |
| クライアント | 192.168.0.89, 192.168.0.90 の 2 つ |
| ストレージ | Redis |
curl で 1 分間以内に Uvicorn ログにある全てのリクエストを送った。。
Uvicorn ログ
$ uv run uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: Started parent process [16838]
INFO: Started server process [16840]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [16842]
INFO: Started server process [16841]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Started server process [16843]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 192.168.0.89:61606 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:61607 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:61608 - "GET /hello/alice HTTP/1.1" 200 OK
INFO: 192.168.0.89:61609 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61610 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61611 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61612 - "GET /hello/alice2 HTTP/1.1" 200 OK
INFO: 192.168.0.89:61613 - "GET /hello/alice2 HTTP/1.1" 200 OK
INFO: 192.168.0.89:61614 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61615 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61616 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61617 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61618 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61619 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61620 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.89:61621 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:61030 - "GET /hello/alice HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:61031 - "GET /hello/alice2 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:61032 - "GET /hello/alice3 HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:61033 - "GET /hello/tiangolo HTTP/1.1" 200 OK
INFO: 192.168.0.90:61034 - "GET /hello/tiangolo HTTP/1.1" 200 OK
INFO: 192.168.0.90:61035 - "GET /hello/tiangolo HTTP/1.1" 200 OK
INFO: 192.168.0.90:61036 - "GET /hello/tiangolo HTTP/1.1" 429 Too Many Requests
INFO: 192.168.0.90:61037 - "GET /hello/tiangolo HTTP/1.1" 429 Too Many Requests
^CINFO: Shutting down
- 192.168.0.89,
/hello/aliceは 3 回目まで OK。 - 192.168.0.89,
/hello/alice2は 2 回目までしか OK にならず、 3 回目以降は Too Many Requests。 - 192.168.0.89,
/hello/alice3は 1 回目から Too Many Requests。 - 同じパスパラメータなら 3 回まで、パスパラメータを抜いた URL(
/hello)に対するアクセスは 5 回までっていう制限ができてる。 - 192.168.0.90,
/hello/alice,/hello/alice2,/hello/alice3はすべて 1 回目から Too Many Requestsになってる。別 IP アドレスだけど、nameごとの 3 回制限はパスパラメーター単位なので、alice/alice2/alice3はすでに 192.168.0..89 が使い切ってる)。 - 192.168.0.90,
/hello/tiangoloは 3 回目まで OK、 4 回目以降は Too Many Requests。
Redis の中身も確認する。起動直後は KEY が何もない。
name=alice, alice2, alice3 でリクエストして制限がかかった後。 /hello へのリクエストは、パスパラメータ抜きで KEY を作ってる。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/name:alice2//hello/alice2/3/1/minute"
2) "LIMITS:LIMITER/192.168.0.89/hello/5/1/minute"
3) "LIMITS:LIMITER/name:alice//hello/alice/3/1/minute"
4) "LIMITS:LIMITER/name:alice3//hello/alice3/3/1/minute"
5) "LIMITS:LIMITER/192.168.0.90/hello/5/1/minute"
6) "LIMITS:LIMITER/name:tiangolo//hello/tiangolo/3/1/minute"
失敗例
shared_limit を使わずに limit( @limiter.limit("5/minute") )を使うと、パスパラメータを含めた URL に対して IP アドレスごとに KEY を作ってしまう。これだと name が変わるたびに別カウントになって、 /hello 全体への制限にならない。
127.0.0.1:6379> KEYS *
1) "LIMITS:LIMITER/192.168.0.89//hello/alice3/5/1/minute"
2) "LIMITS:LIMITER/name:alice2//hello/alice2/3/1/minute"
3) "LIMITS:LIMITER/192.168.0.89//hello/alice2/5/1/minute"
4) "LIMITS:LIMITER/name:alice3//hello/alice3/3/1/minute"
5) "LIMITS:LIMITER/name:alice//hello/alice/3/1/minute"
6) "LIMITS:LIMITER/192.168.0.89//hello/alice/5/1/minute"
メモ¶
KEY の名前は、 SlowAPI が依存してる limits ライブラリの key_for 関数で作られてる。
cf. limits/limits.py の key_for