Skip to content

Commit c27a6a7

Browse files
jimleroyerCopilot
andauthored
Introducing a buffered proxy rate limiter (#2916)
* Introducing a buffered proxy rate limiter and refactored rate limiter init * Apply suggestions from code review Changing comment to better reflect what's it actually doing on the rate limiter registration. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Improving comments --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent a6bcfce commit c27a6a7

5 files changed

Lines changed: 338 additions & 33 deletions

File tree

app/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,11 @@ def create_app(application, config=None):
141141
if application.config["FF_SALESFORCE_CONTACT"]:
142142
salesforce_client.init_app(application)
143143

144-
# Initialize the global rate limiter instance with the configured rate limit for SMS delivery tasks.
145-
initialize_rate_limiter(application.config["CELERY_DELIVER_SMS_RATE_LIMIT_PER_MINUTE"], namespace="sms")
144+
# Initialize the rate limiter for SMS delivery tasks, then wrap it in a
145+
# BufferedRateLimiter to reduce network round-trips per Celery worker.
146+
initialize_rate_limiter(application.config["CELERY_DELIVER_SMS_RATE_LIMIT_PER_MINUTE"], namespace="sms").buffered(
147+
application.config["SMS_RATE_LIMITER_BATCH"]
148+
)
146149

147150
flask_redis.init_app(application)
148151
flask_cache_ops.init_app(application)

app/celery/provider_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def deliver_sms(self, notification_id):
7474
)
7575
@statsd(namespace="tasks")
7676
def deliver_sms_rate_limited(self, notification_id: str, parts_count: int):
77-
acquired, seconds_to_wait = get_rate_limiter().acquire_lease(parts_count)
77+
acquired, seconds_to_wait = get_rate_limiter("sms").acquire_lease(parts_count)
7878
if acquired:
7979
_deliver_sms(self, notification_id)
8080
else:

app/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,8 @@ class Config(object):
609609
# SMS rate limiter backend class name. Must match a key in an implementation class in the rate_limiter module.
610610
# Options: "InMemoryRateLimiter", "RedisSlidingWindowLogRateLimiter", "RedisTokenBucketRateLimiter"
611611
SMS_RATE_LIMITER_BACKEND = os.getenv("SMS_RATE_LIMITER_BACKEND", "InMemoryRateLimiter")
612+
# Number of tokens to pre-fetch from the underlying rate limiter per Redis call (BufferedRateLimiter batch size).
613+
SMS_RATE_LIMITER_BATCH = env.int("SMS_RATE_LIMITER_BATCH", 100)
612614
AWS_SEND_SMS_BOTO_CALL_LATENCY = os.getenv("AWS_SEND_SMS_BOTO_CALL_LATENCY", 0.06) # average delay in production
613615

614616
CONTACT_FORM_EMAIL_ADDRESS = os.getenv("CONTACT_FORM_EMAIL_ADDRESS", "helpdesk@cds-snc.ca")

app/rate_limiter.py

Lines changed: 153 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# app/rate_limiter.py
2+
from __future__ import annotations
3+
24
"""
35
Rate 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

5580
class 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

190212
def _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

281308
class 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

Comments
 (0)