Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/contrib/ratelimit.py: 0%
49 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1"""
2Request-level rate limiter using App Engine Memcache.
4Registers as a :class:`~viur.core.request.RequestValidator` and therefore
5runs *before* any session, routing, or handler logic — making it the
6earliest possible place to shed excess traffic.
8Guests are identified by their IP address (IPv6 addresses are bucketed into
9/64 prefixes so that a single host cannot trivially rotate around the limit).
10Authenticated users are identified by their Datastore user key.
12Usage::
14 from viur.core.request import Router
15 from viur.core.contrib.ratelimit import RequestRateLimit, TimeWindow
17 Router.requestValidators.append(
18 RequestRateLimit(
19 rate_for_guests=TimeWindow(limit=200, time_window=60),
20 rate_for_users=TimeWindow(limit=500, time_window=60),
21 )
22 )
23"""
24import dataclasses
25import ipaddress
26import logging
27import time
28import typing as t
30from google.appengine.api.memcache import Client
32from viur.core import current
33from viur.core.request import RequestValidator, Router
35logger = logging.getLogger(__name__)
37_memcache = Client()
40@dataclasses.dataclass(frozen=True)
41class TimeWindow:
42 """Rate-limit budget for a single time window.
44 :param limit: Maximum number of requests allowed within *time_window*.
45 :param time_window: Length of the window in seconds.
46 """
47 limit: int
48 time_window: int
51class RequestRateLimit(RequestValidator):
52 """Global HTTP request rate limiter.
54 Enforces separate budgets for anonymous (guest) and authenticated
55 requests. When the budget is exceeded the validator returns HTTP 429
56 and sets the ``Retry-After`` header so clients know when to retry.
58 :param rate_for_guests: Budget applied to unauthenticated requests.
59 :param rate_for_users: Budget applied to authenticated requests.
60 :param namespace: Memcache namespace used for all rate-limit keys.
61 """
63 name = "RequestRateLimit"
65 def __init__(
66 self,
67 rate_for_guests: TimeWindow = TimeWindow(limit=1000, time_window=60),
68 rate_for_users: TimeWindow = TimeWindow(limit=2000, time_window=60),
69 namespace: str = "viur_rate_limit",
70 ):
71 self.rate_for_guests = rate_for_guests
72 self.rate_for_users = rate_for_users
73 self.namespace = namespace
75 def validate(self, request: "Router") -> t.Optional[tuple[int, str, str]]:
76 if request.is_deferred:
77 return None # Task Queue requests are always allowed
79 if user := current.user.get():
80 client_id = str(user["key"])
81 rate = self.rate_for_users
82 else:
83 client_id = self._get_request_ip()
84 rate = self.rate_for_guests
86 current_time = time.time() / rate.time_window
87 key = f"rate_limit:{client_id}:{int(current_time)}"
89 count = _memcache.get(key, namespace=self.namespace)
90 logger.debug(f"rate limit check: {client_id=} {count=} limit={rate.limit}")
92 if count is None:
93 _memcache.add(key, 1, time=rate.time_window, namespace=self.namespace)
94 return None
96 if count < rate.limit:
97 _memcache.incr(key, initial_value=1, namespace=self.namespace)
98 return None
100 # Budget exhausted — tell the client when the current window expires.
101 seconds_into_window = (current_time - int(current_time)) * rate.time_window
102 retry_after = int(rate.time_window - seconds_into_window)
103 request.response.headers["Retry-After"] = str(retry_after)
104 return 429, "Too Many Requests", "Too Many Requests. Please try again later."
106 @staticmethod
107 def _get_request_ip() -> str:
108 """Return a stable client identifier derived from the remote address.
110 IPv4 addresses are returned as-is. For IPv6 the /64 network prefix
111 is returned so that a single host cannot trivially rotate its
112 interface identifier to bypass the limit.
113 """
114 raw = current.request.get().request.remote_addr
115 ip = ipaddress.ip_address(raw)
117 if isinstance(ip, ipaddress.IPv4Address):
118 return str(ip)
120 if isinstance(ip, ipaddress.IPv6Address):
121 return str(ipaddress.IPv6Network((ip, 64), strict=False))
123 raise NotImplementedError(f"Unsupported IP version: {ip!r}")