Skip to content

レートリミット(SlowAPI)

SlowAPI は、レートリミットをエンドポイントごとに @limiter デコレーターで設定できるライブラリ。

後々 Redis を使うので、 Redis のセットアップも済ませておいた方がいい。

  • インストール
    uv add slowapi
    

サンプルコード

/ へのアクセスを 1 分間に 5 回までに制限する。

デコレーターの順番

必ず FastAPI の @app デコレーターの下に @limiter デコレーターを書く: cf. Decorators order

@app.get("/")
@limiter.limit("5/minute")

main.py
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 を使うように

Redis のセットアップを完了させてから、 Limiterstorage_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
hitself.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

DEFAULT_SOCKET_TIMEOUT = 5  # 5s
DEFAULT_SOCKET_CONNECT_TIMEOUT = DEFAULT_SOCKET_TIMEOUT

それより前は None(無制限)で、応答が返らないと待ち続ける挙動だった: cf. process hangs due to default socket_timeout=None

SlowAPI は storage_urilimits 経由で redis-py の from_url にそのまま渡す。 limits 側はタイムアウトを上書きしないので、 redis-py のデフォルトがそのまま効く。明示するなら Limiterstorage_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 に何もない。

# redis-cli
127.0.0.1:6379> KEYS *
(empty array)

クライアント 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 が何もない。

# redis-cli
127.0.0.1:6379> KEYS *
(empty array)

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_limitscope 付きで使う。

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 が何もない。

# redis-cli
127.0.0.1:6379> KEYS *
(empty array)

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