Coverage for src/lexigram/web/integrations/throttle.py: 33%
33 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""@throttle decorator and RateLimitModule — ergonomic rate limiting sugar.
3Provides a NestJS-style ``@throttle("30/minute")`` decorator that sits on top
4of the lower-level :func:`~lexigram.web.middleware.rate_limit.rate_limit`
5decorator, plus a ``RateLimitModule`` for module-level configuration.
7Usage::
9 from lexigram.web import throttle, RateLimitModule
11 # Boot-time module config
12 app.add_module(RateLimitModule.configure(
13 backend="memory", # "redis" | "memory"
14 default_limit="100/minute", # applied when no route-level @throttle
15 ))
17 class APIController(Controller):
18 @get("/search")
19 @throttle("30/minute")
20 async def search(self, request: Request) -> list[dict]: ...
22 @post("/upload")
23 @throttle("5/hour", by="user")
24 async def upload(self, request: Request) -> dict: ...
26 @get("/public")
27 @throttle("1000/hour", by="ip")
28 async def public_data(self, request: Request) -> dict: ...
29"""
31from __future__ import annotations
33import re
34from typing import TYPE_CHECKING, Any, Literal
36if TYPE_CHECKING:
37 from collections.abc import Callable
39__all__ = ["RateLimitModule", "throttle"]
41# ---------------------------------------------------------------------------
42# Rate string parsing
43# ---------------------------------------------------------------------------
45_WINDOW_MAP: dict[str, int] = {
46 "second": 1,
47 "seconds": 1,
48 "minute": 60,
49 "minutes": 60,
50 "hour": 3600,
51 "hours": 3600,
52 "day": 86400,
53 "days": 86400,
54}
56_RATE_RE = re.compile(
57 r"^(\d+)\s*/\s*(" + "|".join(_WINDOW_MAP) + r")$",
58 re.IGNORECASE,
59)
62def _parse_rate(rate: str) -> tuple[int, int]:
63 """Parse a rate string like ``"30/minute"`` into ``(max_requests, window_seconds)``.
65 Args:
66 rate: Rate string in the format ``"<count>/<unit>"`` where unit is
67 one of ``second``, ``minute``, ``hour``, or ``day`` (plural forms
68 are also accepted).
70 Returns:
71 Tuple of ``(max_requests, window_seconds)``.
73 Raises:
74 ValueError: If the rate string is not in the expected format.
75 """
76 m = _RATE_RE.match(rate.strip())
77 if not m:
78 raise ValueError(
79 f"Invalid rate string {rate!r}. "
80 "Expected format: '<count>/<unit>' where unit is "
81 "second/minute/hour/day (e.g. '30/minute', '5/hour')."
82 )
83 max_requests = int(m.group(1))
84 window_seconds = _WINDOW_MAP[m.group(2).lower()]
85 return max_requests, window_seconds
88# ---------------------------------------------------------------------------
89# @throttle decorator
90# ---------------------------------------------------------------------------
93def throttle(
94 rate: str,
95 *,
96 by: Literal["user", "ip", "endpoint"] = "user",
97) -> Callable[[Any], Any]:
98 """Rate-limit a route handler using a human-readable rate string.
100 This is ergonomic sugar over the lower-level
101 :func:`~lexigram.web.middleware.rate_limit.rate_limit` decorator.
103 Args:
104 rate: Rate string in ``"<count>/<unit>"`` format, e.g.
105 ``"30/minute"``, ``"5/hour"``, ``"100/second"``.
106 by: Scope for the rate limit key:
108 * ``"user"`` — per authenticated user (falls back to IP when
109 ``request.state.user`` is not set).
110 * ``"ip"`` — per client IP address.
111 * ``"endpoint"`` — per route path + HTTP method.
113 Returns:
114 Decorator that enforces the rate limit on the decorated handler.
116 Raises:
117 ValueError: If *rate* is not a valid rate string.
119 Example::
121 @get("/search")
122 @throttle("30/minute")
123 async def search(self, request: Request) -> list[dict]: ...
125 @post("/upload")
126 @throttle("5/hour", by="user")
127 async def upload(self, request: Request) -> dict: ...
128 """
129 max_requests, window_seconds = _parse_rate(rate)
131 from lexigram.web.middleware.rate_limit import rate_limit as _rate_limit
133 return _rate_limit(
134 max_requests=max_requests,
135 window_seconds=window_seconds,
136 scope=by,
137 )
140# ---------------------------------------------------------------------------
141# RateLimitModule
142# ---------------------------------------------------------------------------
145class RateLimitModule:
146 """Module-level rate limiting configuration.
148 Registers a :class:`~lexigram.web.di.rate_limit.RateLimitProvider` in
149 the application's DI container so that route-level ``@throttle`` and
150 ``@rate_limit`` decorators can resolve a limiter from the container.
152 Usage::
154 app.add_module(RateLimitModule.configure(
155 backend="redis",
156 default_limit="100/minute",
157 ))
159 # Development / testing — no external service required:
160 app.add_module(RateLimitModule.configure(backend="memory"))
161 """
163 @classmethod
164 def configure(
165 cls,
166 *,
167 backend: Literal["redis", "memory"] = "memory",
168 default_limit: str | None = None,
169 redis_client: Any = None,
170 enabled: bool = True,
171 ) -> Any:
172 """Create a :class:`~lexigram.di.module.DynamicModule` that wires up
173 rate limiting.
175 Args:
176 backend: Storage backend. ``"redis"`` uses atomic Lua scripts
177 (recommended for production); ``"memory``" uses a
178 process-local sliding window (suitable for development and
179 single-process deployments).
180 default_limit: Optional default rate limit applied globally (not
181 yet enforced by the module — provided for future middleware
182 use). Accepts the same rate string format as
183 :func:`throttle`.
184 redis_client: Pre-built Redis client. Only used when
185 ``backend="redis"``. When *None*, the provider tries to
186 resolve a ``"redis_client"`` binding from the container at
187 boot time.
188 enabled: When ``False`` the rate limiter is not started.
190 Returns:
191 A :class:`~lexigram.di.module.DynamicModule` descriptor.
192 """
193 from lexigram.di.module import DynamicModule
194 from lexigram.web.di.rate_limit import RateLimitProvider
196 if default_limit is not None:
197 # Validate at module-configuration time so errors surface early.
198 _parse_rate(default_limit)
200 provider_kwargs: dict[str, Any] = {"enabled": enabled}
201 if backend == "redis" and redis_client is not None:
202 provider_kwargs["redis_client"] = redis_client
204 rate_provider = RateLimitProvider(**provider_kwargs)
206 # Build a minimal "wrapper" module class to satisfy DynamicModule
207 from lexigram.di.module import module
209 @module()
210 class _RateLimitModuleClass:
211 pass
213 return DynamicModule(
214 module=_RateLimitModuleClass,
215 providers=[rate_provider],
216 is_global=True,
217 )