11# app/rate_limiter.py
2+ from __future__ import annotations
3+
24"""
35Rate Limiting Module
46
@@ -51,6 +53,29 @@ def get_current_usage(self) -> int:
5153 """
5254 pass
5355
56+ def buffered (self , size : int ) -> BufferedRateLimiter :
57+ """
58+ Wrap this rate limiter in a BufferedRateLimiter and register it under
59+ the same namespace, replacing the current entry in the registry.
60+
61+ Args:
62+ size (int): Number of tokens to pre-fetch from the underlying
63+ limiter per Redis call.
64+
65+ Returns:
66+ BufferedRateLimiter: The new buffered wrapper.
67+
68+ Raises:
69+ TypeError: If called on an already-buffered instance.
70+ """
71+ if isinstance (self , BufferedRateLimiter ):
72+ raise TypeError (
73+ f"Rate limiter [{ self .namespace } ] is already a BufferedRateLimiter. " "Double-wrapping is not allowed."
74+ )
75+ buffered_limiter = BufferedRateLimiter (self , size )
76+ _rate_limiter_instances [self .namespace ] = buffered_limiter
77+ return buffered_limiter
78+
5479
5580class InMemoryRateLimiter (RateLimiter ):
5681 """
@@ -171,20 +196,17 @@ def get_current_usage(self) -> int:
171196
172197
173198# ============================================================================
174- # Module-level rate limiter instance
199+ # Module-level rate limiter registry
175200# ============================================================================
176- # For the in-memory backend, this instance is process-local: it is shared only
177- # within the current Python process (for example, one Flask/Celery worker
178- # process) and is not coordinated across multiple Celery worker processes,
179- # pods, or hosts.
201+ # Keyed by namespace (e.g. "sms"). Each entry is a RateLimiter instance
202+ # (possibly a BufferedRateLimiter wrapping a concrete backend).
180203#
181- # As a result, the in-memory backend does not provide a true global cap unless
182- # deployment is constrained so that only a single worker process/pod consumes
183- # the relevant queue. A Redis-backed implementation enforces a global
184- # cross-process rate limit without changing task code.
204+ # For the in-memory backend, instances are process-local and not coordinated
205+ # across multiple Celery worker processes, pods, or hosts.
206+ # Redis-backed implementations enforce a global cross-process rate limit.
185207# ============================================================================
186208
187- _rate_limiter_instance : RateLimiter | None = None
209+ _rate_limiter_instances : dict [ str , RateLimiter ] = {}
188210
189211
190212def _build_limiter_registry () -> dict [str , type [RateLimiter ]]:
@@ -200,9 +222,10 @@ def initialize_rate_limiter(
200222 cap_per_minute : int , limiter_class : type [RateLimiter ] | None = None , * , namespace : str
201223) -> RateLimiter :
202224 """
203- Initialize the global rate limiter instance .
225+ Initialize a rate limiter for the given namespace and store it in the registry .
204226
205- Called during app initialization (see app/__init__.py).
227+ Called during app initialization (see app/__init__.py). Returns the raw
228+ limiter instance; chain `.buffered(size)` to wrap it in a BufferedRateLimiter.
206229
207230 The backend is chosen in this order:
208231 1. limiter_class argument — if provided, instantiated directly.
@@ -216,15 +239,13 @@ def initialize_rate_limiter(
216239 limiter_class (type[RateLimiter] | None): Implementation class to use.
217240 If None, the class is resolved from config/default.
218241 namespace (str): Logical name for this limiter instance (e.g. "sms").
219- Used in Redis key construction and log messages.
242+ Used in Redis key construction, log messages, and registry lookup .
220243
221244 Returns:
222245 RateLimiter: The initialized rate limiter instance.
223246 """
224247 import logging
225248
226- global _rate_limiter_instance
227-
228249 logger = logging .getLogger (__name__ )
229250
230251 if limiter_class is not None :
@@ -253,29 +274,35 @@ def initialize_rate_limiter(
253274 resolved_class = registry [class_name ]
254275
255276 logger .info ("Rate limiter [%s]: initializing with %s" , namespace , resolved_class .__name__ )
256- _rate_limiter_instance = resolved_class (cap_per_minute , namespace )
277+ instance = resolved_class (cap_per_minute , namespace )
278+ _rate_limiter_instances [namespace ] = instance
257279
258- return _rate_limiter_instance
280+ return instance
259281
260282
261- def get_rate_limiter () -> RateLimiter :
283+ def get_rate_limiter (namespace : str ) -> RateLimiter :
262284 """
263- Get the global rate limiter instance .
285+ Get the rate limiter registered under the given namespace .
264286
265- Call this from tasks to access the rate limiter.
266- Raises RuntimeError if initialize_rate_limiter() hasn't been called.
287+ If a BufferedRateLimiter was installed via `.buffered()`, that is returned
288+ transparently — callers need no awareness of buffering.
289+
290+ Args:
291+ namespace (str): The namespace used during initialization (e.g. "sms").
267292
268293 Returns:
269- RateLimiter: The global rate limiter instance.
294+ RateLimiter: The registered rate limiter instance.
270295
271296 Raises:
272- RuntimeError: If the rate limiter hasn't been initialized .
297+ RuntimeError: If no limiter has been registered for this namespace .
273298 """
274- if _rate_limiter_instance is None :
299+ instance = _rate_limiter_instances .get (namespace )
300+ if instance is None :
275301 raise RuntimeError (
276- "Rate limiter not initialized. " "Call initialize_rate_limiter() during app startup (app/__init__.py)."
302+ f"Rate limiter [{ namespace !r} ] not initialized. "
303+ "Call initialize_rate_limiter() during app startup (app/__init__.py)."
277304 )
278- return _rate_limiter_instance
305+ return instance
279306
280307
281308class RedisSlidingWindowLogRateLimiter (RateLimiter ):
@@ -625,3 +652,103 @@ def get_current_usage(self) -> int:
625652 def reset_limiter (self ):
626653 self .redis .delete (self ._key )
627654 current_app .logger .info (f"Rate limiter [{ self .namespace } ]: reset (token bucket)" )
655+
656+
657+ class BufferedRateLimiter (RateLimiter ):
658+ """
659+ A buffering proxy that wraps any RateLimiter and pre-fetches tokens in
660+ configurable batches to reduce calls to the underlying backend (e.g. Redis).
661+
662+ Each Celery worker process maintains its own local token counter. Tokens are
663+ spent locally without hitting the backend; the underlying limiter is only
664+ called when the local buffer runs dry.
665+
666+ Token expiry:
667+ Each batch is stamped with ``_acquired_at``. Before spending local tokens,
668+ the buffer is checked for staleness: tokens older than 60 seconds are
669+ discarded. This prevents tokens from a previous rate-limit window from being
670+ consumed after the backend (especially a token bucket) has refilled, which
671+ would allow over-consumption up to ``size * num_workers`` beyond the cap.
672+
673+ Top-up on partial buffer:
674+ When ``_local_tokens < units``, the buffer is topped up (not replaced).
675+ The underlying limiter is called for ``max(units - _local_tokens, size)``
676+ additional tokens. The existing local tokens are combined with the new
677+ batch, so already-claimed tokens are never wasted.
678+
679+ Thread safety:
680+ Not needed — Celery prefork runs one task at a time per process, and each
681+ worker process owns its own ``BufferedRateLimiter`` instance independently.
682+ """
683+
684+ TOKEN_WINDOW_SECONDS = 60
685+
686+ def __init__ (self , rate_limiter : RateLimiter , size : int ) -> None :
687+ if size <= 0 :
688+ raise ValueError ("size must be positive" )
689+ if size > rate_limiter .cap_per_minute :
690+ raise ValueError (f"size ({ size } ) must be <= cap_per_minute ({ rate_limiter .cap_per_minute } )" )
691+ super ().__init__ (rate_limiter .cap_per_minute , rate_limiter .namespace )
692+ self ._rate_limiter = rate_limiter
693+ self ._size = size
694+ self ._local_tokens : int = 0
695+ self ._acquired_at : float = 0.0
696+
697+ def acquire_lease (self , units : int ) -> Tuple [bool , int ]:
698+ """
699+ Attempt to acquire a lease for the given number of units.
700+
701+ Spends from the local buffer when possible. Tops up from the underlying
702+ rate limiter when the buffer is insufficient, fetching at least ``_size``
703+ tokens per Redis call.
704+
705+ Args:
706+ units (int): Number of units to acquire.
707+
708+ Returns:
709+ Tuple[bool, int]:
710+ - (True, 0) if units were acquired (locally or via the backend).
711+ - (False, seconds_to_wait) if the backend denied the request.
712+ """
713+ if units <= 0 :
714+ raise ValueError ("units must be positive" )
715+
716+ # Discard stale local tokens to prevent cross-window over-consumption.
717+ if self ._local_tokens > 0 and time () - self ._acquired_at >= self .TOKEN_WINDOW_SECONDS :
718+ current_app .logger .debug (
719+ f"BufferedRateLimiter [{ self .namespace } ]: discarding { self ._local_tokens } stale local tokens"
720+ )
721+ self ._local_tokens = 0
722+
723+ # Fast path: if the local buffer has enough tokens, spend them without hitting the backend.
724+ if self ._local_tokens >= units :
725+ self ._local_tokens -= units
726+ current_app .logger .debug (
727+ f"BufferedRateLimiter [{ self .namespace } ]: spent { units } local tokens " f"(remaining: { self ._local_tokens } )"
728+ )
729+ return True , 0
730+
731+ # Top-up: fetch enough to cover the deficit, at least _size tokens.
732+ deficit = units - self ._local_tokens
733+ batch = max (deficit , self ._size )
734+
735+ acquired , seconds_to_wait = self ._rate_limiter .acquire_lease (batch )
736+ if not acquired :
737+ current_app .logger .warning (
738+ f"BufferedRateLimiter [{ self .namespace } ]: backend denied { batch } token batch. "
739+ f"Retry in { seconds_to_wait } s. Local buffer unchanged ({ self ._local_tokens } tokens)."
740+ )
741+ return False , seconds_to_wait
742+
743+ self ._acquired_at = time ()
744+ self ._local_tokens += batch
745+ self ._local_tokens -= units
746+ current_app .logger .debug (
747+ f"BufferedRateLimiter [{ self .namespace } ]: fetched { batch } tokens from backend, "
748+ f"spent { units } , local buffer now { self ._local_tokens } "
749+ )
750+ return True , 0
751+
752+ def get_current_usage (self ) -> int :
753+ """Delegates to the wrapped rate limiter (reflects global consumed capacity)."""
754+ return self ._rate_limiter .get_current_usage ()
0 commit comments