Backend

Rate limiting and cache

Sliding window throttles with standard headers, login lockouts, a caching decorator and ETags, all on the Django cache.

pleate_backend.ratelimit replaces DRF's throttles with sliding window counters on the Django cache and publishes the standard RateLimit headers. pleate_backend.core.cache adds a caching decorator and conditional GET. Both work on the local memory cache in development; in production point the cache at Redis so every worker shares the counters.

Throttles

Every request passes the default throttles; some views add a named scope.

ScopeDefault rateApplies to
user600/minEvery request of a signed in user.
anon120/minEvery anonymous request, per IP.
auth10/minLogin, 2FA verify, password reset, email verification and change, export.
signup5/hourSignup.
contact5/hourNewsletter, contact form, waitlist.
upload60/hourFile uploads.
ai30/minChat messages and regenerations.

Change a rate, or add a scope for your own view:

settings.py
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] |= {"ai": "60/min", "search": "30/min"}
python
from pleate_backend.ratelimit.throttles import ScopedRateThrottle, StrictScopedRateThrottle

class SearchView(APIView):
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "search"          # per user when signed in, per IP otherwise

class RedeemCodeView(APIView):
    throttle_classes = [StrictScopedRateThrottle]   # denies when the cache is down
    throttle_scope = "auth"

Responses carry the state of the tightest throttle that applied (RateLimit-Reset is the seconds left in the current window), and a 429 says when to come back:

http
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 10
RateLimit-Remaining: 0
RateLimit-Reset: 42

{"error": {"status": 429, "code": "throttled", "message": "Request was throttled. Expected available in 42 seconds.", "retry_after": 42}}

In the client, error.retryAfter holds the seconds. Rates parse s, sec, m, min, h, hour, d and day.

Counters for security

For limits that are not one request, such as failed logins per account, use the counters directly. The window is approximated with two fixed buckets weighted by overlap: accurate enough for abuse protection, and only two cache keys.

python
from pleate_backend.ratelimit import limits

# Count first, check after: a peek then a hit lets parallel requests all pass.
allowed, retry_after = limits.hit(f"redeem:{user.pk}", limit=5, window=3600, fail_closed=True)
if not allowed:
    raise ApiError("too_many_attempts", _("Too many attempts. Try again later."), 429, {"retry_after": retry_after})
if code_is_valid:
    limits.reset(f"redeem:{user.pk}", 3600)   # only failures stay counted
  • hit(key, limit, window) counts one event atomically and returns (allowed, retry_after). A refused event is not counted.
  • consume is the same and also returns remaining and reset, the seconds left in the current window.
  • peek checks without counting, reset clears, remaining tells what is left.
  • fail_closed=True denies when the cache is unreachable. Use it on authentication paths; everything else fails open.

Client IP

Per IP limits, bans and audit entries use core.utils.client_ip. X-Forwarded-For is trusted only when the request comes from PLEATE["TRUSTED_PROXIES"] (CIDR list, loopback by default). Behind a load balancer or the Next.js proxy, add its network, or every user shares one address.

Cache

python
from pleate_backend.core.cache import cached, invalidate

@cached("plans", ttl=300)
def load_plans(): ...

@cached(lambda user: f"dashboard:{user.pk}", ttl=60)
def dashboard(user): ...

invalidate("plans")
dashboard.invalidate(user)          # same key function

Keys are prefixed with pl:cache:. Cache errors are swallowed: when Redis is down the function simply runs.

Conditional GET

ETagMixin hashes the response data of GET and HEAD. The response carries an ETag and Cache-Control: private, no-cache; a request with a matching If-None-Match gets a 304 with no body.

python
from pleate_backend.core.cache import ETagMixin

class PlanListView(ETagMixin, generics.ListAPIView):
    ...

Redis

One Redis serves the cache, the counters, Celery and realtime events:

settings.py
from pleate_backend.settings import redis

globals().update(redis("redis://localhost:6389/0"))   # CACHES, CELERY_BROKER_URL
PLEATE["REDIS_URL"] = "redis://localhost:6389/0"      # health check and RedisBroker