"""Database-backed fixed-window rate limiting. Stored in the database rather than a process-local cache so that the limit holds across all gunicorn workers without needing Redis. A limit of 0 means unlimited. """ from datetime import timedelta from django.db import IntegrityError, transaction from django.utils import timezone from .models import RateLimitBucket def _expired(bucket, window, now): return bucket.window_start + window <= now def is_limited(key, limit, window: timedelta): """True if the key has already reached the limit in the current window.""" if limit <= 0: return False bucket = RateLimitBucket.objects.filter(key=key).first() if bucket is None or _expired(bucket, window, timezone.now()): return False return bucket.count >= limit def hit(key, limit, window: timedelta): """Count one event. Returns False if this event exceeds the limit.""" if limit <= 0: return True now = timezone.now() for _ in range(2): try: with transaction.atomic(): bucket = RateLimitBucket.objects.select_for_update().filter(key=key).first() if bucket is None: RateLimitBucket.objects.create(key=key, window_start=now, count=1) return True if _expired(bucket, window, now): bucket.window_start = now bucket.count = 0 bucket.count += 1 bucket.save(update_fields=["window_start", "count"]) return bucket.count <= limit except IntegrityError: # Another worker created the row concurrently; retry with the row present. continue return False def reset(key): RateLimitBucket.objects.filter(key=key).delete() def purge_expired(older_than: timedelta = timedelta(days=2)): RateLimitBucket.objects.filter(window_start__lt=timezone.now() - older_than).delete()