Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/examples/middleware/rate_limit.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from litestar import Litestar, MediaType, get
from litestar.middleware.rate_limit import RateLimitConfig
from litestar.middleware.rate_limit import RateLimitMiddleware

rate_limit_config = RateLimitConfig(rate_limit=("minute", 1), exclude=["/schema"])
rate_limit_middleware = RateLimitMiddleware(rate_limit=("minute", 1), exclude=["/schema"])


@get("/", media_type=MediaType.TEXT, sync_to_thread=False)
Expand All @@ -10,4 +10,4 @@ def handler() -> str:
return "ok"


app = Litestar(route_handlers=[handler], middleware=[rate_limit_config.middleware])
app = Litestar(route_handlers=[handler], middleware=[rate_limit_middleware])
4 changes: 2 additions & 2 deletions docs/examples/stores/configure_integrations_set_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from litestar import Litestar
from litestar.config.response_cache import ResponseCacheConfig
from litestar.middleware.rate_limit import RateLimitConfig
from litestar.middleware.rate_limit import RateLimitMiddleware
from litestar.middleware.session.server_side import ServerSideSessionConfig
from litestar.stores.file import FileStore
from litestar.stores.redis import RedisStore
Expand All @@ -12,6 +12,6 @@
response_cache_config=ResponseCacheConfig(store="redis"),
middleware=[
ServerSideSessionConfig(store="file").middleware,
RateLimitConfig(rate_limit=("second", 10), store="redis").middleware,
RateLimitMiddleware(rate_limit=("second", 10), store="redis"),
],
)
4 changes: 2 additions & 2 deletions docs/examples/stores/registry_access_integration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from litestar import Litestar
from litestar.middleware.rate_limit import RateLimitConfig
from litestar.middleware.rate_limit import RateLimitMiddleware

app = Litestar(middleware=[RateLimitConfig(("second", 1)).middleware])
app = Litestar(middleware=[RateLimitMiddleware(("second", 1))])
rate_limit_store = app.stores.get("rate_limit")
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from litestar import Litestar, get
from litestar.middleware.rate_limit import RateLimitConfig
from litestar.middleware.rate_limit import RateLimitMiddleware
from litestar.middleware.session.server_side import ServerSideSessionConfig
from litestar.stores.redis import RedisStore
from litestar.stores.registry import StoreRegistry
Expand All @@ -17,7 +17,7 @@ def cached_handler() -> str:
[cached_handler],
stores=StoreRegistry(default_factory=root_store.with_namespace),
middleware=[
RateLimitConfig(("second", 1)).middleware,
RateLimitMiddleware(("second", 1)),
ServerSideSessionConfig().middleware,
],
)
41 changes: 41 additions & 0 deletions docs/release-notes/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@
.. changelog:: 3.0.0
:date: 2364-01-27

.. change:: Migrate ``RateLimitMiddleware`` to ``ASGIMiddleware``
:type: feature
:pr: 5005
:issue: 4009
:breaking:

``RateLimitMiddleware`` has been moved from the legacy ``AbstractMiddleware``
base to :class:`~litestar.middleware.ASGIMiddleware`, as part of migrating all
built-in middleware off the legacy bases.

Since the middleware is now directly constructible, the
:class:`~litestar.middleware.rate_limit.RateLimitConfig` configuration object is
obsolete and its ``middleware`` property is deprecated; it will be removed in
``4.0``. Pass a configured ``RateLimitMiddleware`` instance to the middleware
list instead:

.. code-block:: python

# before
config = RateLimitConfig(rate_limit=("minute", 10), exclude=["/schema"])
app = Litestar(..., middleware=[config.middleware])

# after
app = Litestar(
...,
middleware=[RateLimitMiddleware(rate_limit=("minute", 10), exclude=["/schema"])],
)

Two behavioural changes for excluded routes:

- :attr:`~litestar.middleware.rate_limit.RateLimitConfig.exclude` patterns are
now matched against the **handler's path template** (e.g.
``/user/{user_id:int}``) at startup, instead of the request path (e.g.
``/user/1``) at runtime. Patterns targeting literal handler paths keep
working; patterns written to match expanded path parameters must be rewritten
against the template. For mounted ASGI apps, patterns match the mount path
only, not sub-paths, and a handler registered on multiple paths is excluded as
a whole when any of its paths matches.
- Handlers excluded via ``exclude`` or ``exclude_opt_key`` now bypass the
middleware entirely at startup rather than per request.

.. change:: Fix ``TypeError`` when generating a schema for a union of enums
:type: bugfix
:pr: 4997
Expand Down
17 changes: 14 additions & 3 deletions docs/release-notes/whats-new-3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -302,9 +302,9 @@ Built-in middleware migrated to ``ASGIMiddleware``
Litestar's built-in middleware are being moved from the legacy ``AbstractMiddleware`` and
``MiddlewareProtocol`` bases onto :class:`~litestar.middleware.ASGIMiddleware`.
``CORSMiddleware``, ``ResponseCacheMiddleware``, ``CSRFMiddleware``,
``CompressionMiddleware`` and ``AllowedHostsMiddleware`` have made this move.
``ResponseCacheMiddleware`` and ``CSRFMiddleware`` have also been moved into
``litestar.middleware._internal``, removing them from the public API; for CSRF this
``CompressionMiddleware``, ``AllowedHostsMiddleware`` and ``RateLimitMiddleware`` have
made this move. ``ResponseCacheMiddleware`` and ``CSRFMiddleware`` have also been moved
into ``litestar.middleware._internal``, removing them from the public API; for CSRF this
includes the ``litestar.middleware.csrf`` module and its ``generate_csrf_token`` /
``generate_csrf_hash`` helpers.

Expand Down Expand Up @@ -364,6 +364,17 @@ attribute of each layer.
Subclasses must also rename ``__call__`` to ``handle``, which receives the next ASGI app
as an additional ``next_app`` argument in place of ``self.app``.

For rate limiting, :class:`~litestar.middleware.rate_limit.RateLimitMiddleware` is now
directly constructible, making the
:class:`~litestar.middleware.rate_limit.RateLimitConfig` object obsolete: its
``middleware`` property is deprecated and will be removed in ``4.0``. Pass a configured
middleware instance to the middleware list instead, e.g.
``middleware=[RateLimitMiddleware(rate_limit=("minute", 10))]``. The ``exclude``
patterns are now matched against the **handler's path template** (e.g.
``/user/{user_id:int}``) at startup, instead of the request path (e.g. ``/user/1``) at
runtime, and handlers excluded via ``exclude`` or ``exclude_opt_key`` bypass the
middleware entirely at startup rather than per request.

.. seealso::
:ref:`asgi-middleware-migration`

Expand Down
5 changes: 3 additions & 2 deletions docs/usage/middleware/builtin-middleware.rst
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,13 @@ Rate-Limit Middleware
Litestar includes an optional :class:`~litestar.middleware.rate_limit.RateLimitMiddleware` that follows
the `IETF RateLimit draft specification <https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/>`_.

To use the rate limit middleware, use the :class:`~litestar.middleware.rate_limit.RateLimitConfig`:
To use the rate limit middleware, add a configured
:class:`~litestar.middleware.rate_limit.RateLimitMiddleware` instance to the middleware list:

.. literalinclude:: /examples/middleware/rate_limit.py
:language: python

The only required configuration kwarg is ``rate_limit``, which expects a tuple containing a time-unit (``"second"``,
The only required kwarg is ``rate_limit``, which expects a tuple containing a time-unit (``"second"``,
``"minute"``, ``"hour"``, ``"day"``\ ) and a value for the request quota (integer).


Expand Down
132 changes: 89 additions & 43 deletions litestar/middleware/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
from litestar.datastructures import MutableScopeHeaders
from litestar.enums import ScopeType
from litestar.exceptions import TooManyRequestsException
from litestar.middleware.base import AbstractMiddleware, DefineMiddleware
from litestar.middleware.base import ASGIMiddleware
from litestar.serialization import decode_json, encode_json
from litestar.utils import ensure_async_callable
from litestar.utils.deprecation import warn_deprecation

__all__ = (
"CacheObject",
Expand Down Expand Up @@ -57,40 +58,82 @@ def get_remote_address(request: Request[Any, Any, Any]) -> str:
return request.client.host if request.client else "127.0.0.1"


class RateLimitMiddleware(AbstractMiddleware):
class RateLimitMiddleware(ASGIMiddleware):
"""Rate-limiting middleware."""

def __init__(self, app: ASGIApp, config: RateLimitConfig) -> None:
scopes = (ScopeType.HTTP, ScopeType.ASGI)

def __init__(
self,
rate_limit: tuple[DurationUnit, int],
*,
store: str = "rate_limit",
identifier_for_request: Callable[[Request[Any, Any, Any]], str] = get_remote_address,
check_throttle_handler: Callable[[Request[Any, Any, Any]], SyncOrAsyncUnion[bool]] | None = None,
set_rate_limit_headers: bool = True,
rate_limit_policy_header_key: str = "RateLimit-Policy",
rate_limit_limit_header_key: str = "RateLimit-Limit",
rate_limit_remaining_header_key: str = "RateLimit-Remaining",
rate_limit_reset_header_key: str = "RateLimit-Reset",
exclude: str | list[str] | None = None,
exclude_opt_key: str | None = None,
) -> None:
"""Initialize ``RateLimitMiddleware``.

Args:
app: The ``next`` ASGI app to call.
config: An instance of RateLimitConfig.
rate_limit: A tuple containing a time unit (second, minute, hour, day) and quantity, e.g. ("day", 1) or
("minute", 5).
store: Name of the :class:`Store <.stores.base.Store>` to use, looked up on the application's store
registry.
identifier_for_request: A callable that receives the request and returns an identifier for which the
limit should be applied.
check_throttle_handler: Handler callable that receives the request instance, returning a boolean dictating
whether or not the request should be checked for rate limiting.
set_rate_limit_headers: Boolean dictating whether to set the rate limit headers on the response.
rate_limit_policy_header_key: Key to use for the rate limit policy header.
rate_limit_limit_header_key: Key to use for the rate limit limit header.
rate_limit_remaining_header_key: Key to use for the rate limit remaining header.
rate_limit_reset_header_key: Key to use for the rate limit reset header.
exclude: A pattern or list of patterns to skip in the rate limiting middleware, matched against the
handler path.
exclude_opt_key: An identifier to use on routes to disable rate limiting for a particular route.
"""
super().__init__(
app=app, exclude=config.exclude, exclude_opt_key=config.exclude_opt_key, scopes={ScopeType.HTTP}
self.unit: DurationUnit = rate_limit[0]
self.max_requests: int = rate_limit[1]
self.store = store
self.get_identifier_for_request = identifier_for_request
self.check_throttle_handler = cast(
"Callable[[Request], Awaitable[bool]] | None",
ensure_async_callable(check_throttle_handler) if check_throttle_handler else None,
)
self.check_throttle_handler = cast("Callable[[Request], Awaitable[bool]] | None", config.check_throttle_handler)
self.config = config
self.max_requests: int = config.rate_limit[1]
self.unit: DurationUnit = config.rate_limit[0]
self.get_identifier_for_request = config.identifier_for_request
self.set_rate_limit_headers = set_rate_limit_headers
self.rate_limit_policy_header_key = rate_limit_policy_header_key
self.rate_limit_limit_header_key = rate_limit_limit_header_key
self.rate_limit_remaining_header_key = rate_limit_remaining_header_key
self.rate_limit_reset_header_key = rate_limit_reset_header_key
self.exclude_path_pattern = tuple(exclude) if isinstance(exclude, list) else exclude
self.exclude_opt_key = exclude_opt_key
self._lock = anyio.Lock()

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""ASGI callable.
async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None:
"""Handle ASGI call.

Args:
scope: The ASGI connection scope.
receive: The ASGI receive function.
send: The ASGI send function.
next_app: The next ASGI application in the middleware stack to call.

Returns:
None
"""
if scope["type"] != ScopeType.HTTP:
await next_app(scope, receive, send)
return

app = scope["litestar_app"]
request: Request[Any, Any, Any] = app.request_class(scope)
store = self.config.get_store_from_app(app)
store = app.stores.get(self.store)
if await self.should_check_request(request=request):
identifier = self.get_identifier_for_request(request)
key = f"{type(self).__name__}::{identifier}"
Expand All @@ -103,14 +146,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if len(cache_object.history) >= self.max_requests:
raise TooManyRequestsException(
headers=self.create_response_headers(cache_object=cache_object)
if self.config.set_rate_limit_headers
if self.set_rate_limit_headers
else None
)
await self.set_cached_history(key=key, cache_object=cache_object, store=store)
if self.config.set_rate_limit_headers:
if self.set_rate_limit_headers:
send = self.create_send_wrapper(send=send, cache_object=cache_object)

await self.app(scope, receive, send)
await next_app(scope, receive, send)

def create_send_wrapper(self, send: Send, cache_object: CacheObject) -> Send:
"""Create a ``send`` function that wraps the original send to inject response headers.
Expand Down Expand Up @@ -206,10 +249,10 @@ def create_response_headers(self, cache_object: CacheObject) -> dict[str, str]:
)

return {
self.config.rate_limit_policy_header_key: f"{self.max_requests}; w={DURATION_VALUES[self.unit]}",
self.config.rate_limit_limit_header_key: str(self.max_requests),
self.config.rate_limit_remaining_header_key: remaining_requests,
self.config.rate_limit_reset_header_key: str(cache_object.reset - int(time())),
self.rate_limit_policy_header_key: f"{self.max_requests}; w={DURATION_VALUES[self.unit]}",
self.rate_limit_limit_header_key: str(self.max_requests),
self.rate_limit_remaining_header_key: remaining_requests,
self.rate_limit_reset_header_key: str(cache_object.reset - int(time())),
}


Expand Down Expand Up @@ -259,30 +302,33 @@ def __post_init__(self) -> None:
self.check_throttle_handler = ensure_async_callable(self.check_throttle_handler) # type: ignore[arg-type]

@property
def middleware(self) -> DefineMiddleware:
"""Use this property to insert the config into a middleware list on one of the application layers.

Examples:
.. code-block:: python

from litestar import Litestar, Request, get
from litestar.middleware.rate_limit import RateLimitConfig

# limit to 10 requests per minute, excluding the schema path
throttle_config = RateLimitConfig(rate_limit=("minute", 10), exclude=["/schema"])


@get("/")
def my_handler(request: Request) -> None: ...


app = Litestar(route_handlers=[my_handler], middleware=[throttle_config.middleware])
def middleware(self) -> RateLimitMiddleware:
"""Create an instance of :attr:`middleware_class`, configured from this config instance.

Returns:
An instance of :class:`DefineMiddleware <.middleware.base.DefineMiddleware>` including ``self`` as the
config kwarg value.
An instance of :attr:`middleware_class`, configured from this config instance.
"""
return DefineMiddleware(self.middleware_class, config=self)
warn_deprecation(
version="3.0",
deprecated_name="RateLimitConfig.middleware",
kind="property",
removal_in="4.0",
alternative="RateLimitMiddleware",
info="Construct a RateLimitMiddleware instance directly and pass it to the middleware list",
)
return self.middleware_class(
rate_limit=self.rate_limit,
store=self.store,
identifier_for_request=self.identifier_for_request,
check_throttle_handler=self.check_throttle_handler,
set_rate_limit_headers=self.set_rate_limit_headers,
rate_limit_policy_header_key=self.rate_limit_policy_header_key,
rate_limit_limit_header_key=self.rate_limit_limit_header_key,
rate_limit_remaining_header_key=self.rate_limit_remaining_header_key,
rate_limit_reset_header_key=self.rate_limit_reset_header_key,
exclude=self.exclude,
exclude_opt_key=self.exclude_opt_key,
)

def get_store_from_app(self, app: Litestar) -> Store:
"""Get the store defined in :attr:`store` from an :class:`Litestar <.app.Litestar>` instance."""
Expand Down
2 changes: 1 addition & 1 deletion tests/examples/test_stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def test_registry_access_integration() -> None:

assert app.stores.get("rate_limit") is rate_limit_store
# this is a weird assertion but the easiest way to check if our example is correct
assert app.middleware[0].kwargs["config"].get_store_from_app(app) is rate_limit_store
assert app.stores.get(app.middleware[0].store) is rate_limit_store # type: ignore[union-attr]


@patch("litestar.stores.redis.Redis")
Expand Down
Loading