fix(mail): rate-limit direct and fallback sends

This commit is contained in:
2026-07-20 20:06:31 +02:00
parent 79d3f45068
commit 2e906d9ddd
2 changed files with 104 additions and 6 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import time
import threading
from dataclasses import dataclass
from redis import Redis
@@ -17,6 +18,10 @@ class RateLimitDecision:
waited_seconds: float
_local_rate_limit_lock = threading.Lock()
_local_next_allowed: dict[str, float] = {}
def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
@@ -30,15 +35,18 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
The implementation stores the next allowed send timestamp per key. A Redis
lock keeps multiple Celery processes from reading/updating the timestamp at
the same time. Direct local development runs do not have a broker, so Redis
is only used when Celery/worker mode is explicitly enabled. If Redis is
unavailable, it falls back to no distributed wait.
the same time. Direct local development runs do not have a broker, so they
use a process-local limiter. If Redis is unavailable in worker mode, the
process-local fallback still protects single-process sends.
"""
messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute
if not enabled or not _distributed_rate_limit_enabled():
if not enabled:
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
if not _distributed_rate_limit_enabled():
waited = _wait_for_local_rate_limit(key, gap)
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
lock_key = f"govoplan:ratelimit:{key}:lock"
@@ -56,7 +64,17 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
now = time.time()
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
except (RedisError, TimeoutError, ValueError):
# Development fallback: do not fail sending because Redis is absent.
waited = 0.0
waited = _wait_for_local_rate_limit(key, gap)
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
def _wait_for_local_rate_limit(key: str, gap: float) -> float:
with _local_rate_limit_lock:
now = time.time()
next_allowed = _local_next_allowed.get(key, now)
waited = max(0.0, next_allowed - now)
_local_next_allowed[key] = max(now, next_allowed) + gap
if waited > 0:
time.sleep(waited)
return waited