Backend
Rate limiting and 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.
| Scope | Default rate | Applies to |
|---|---|---|
| user | 600/min | Every request of a signed in user. |
| anon | 120/min | Every anonymous request, per IP. |
| auth | 10/min | Login, 2FA verify, password reset, email verification and change, export. |
| signup | 5/hour | Signup. |
| contact | 5/hour | Newsletter, contact form, waitlist. |
| upload | 60/hour | File uploads. |
| ai | 30/min | Chat messages and regenerations. |
Change a rate, or add a scope for your own view:
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] |= {"ai": "60/min", "search": "30/min"}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/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.
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 countedhit(key, limit, window)counts one event atomically and returns(allowed, retry_after). A refused event is not counted.consumeis the same and also returnsremainingandreset, the seconds left in the current window.peekchecks without counting,resetclears,remainingtells what is left.fail_closed=Truedenies 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
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 functionKeys 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.
from pleate_backend.core.cache import ETagMixin
class PlanListView(ETagMixin, generics.ListAPIView):
...Redis
One Redis serves the cache, the counters, Celery and realtime events:
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