@ -87,8 +87,6 @@ RL_INDEX_API_BLOCKED_IPS = 'rl:index:api_blocked_ips'
# TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only,
# TTL set only on first INCR (Lua); daily=24h, weekly=7d — cleanup only,
# resets are implicit in the date/week embedded in the key name.
# resets are implicit in the date/week embedded in the key name.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
QUOTA_USER_DAILY = ' quota: {ns} :daily: {date} :user: {uid} '
QUOTA_USER_WEEKLY = ' quota: {ns} :weekly: {week} :user: {uid} '
QUOTA_IP_DAILY = ' quota: {ns} :daily: {date} :ip: {ip} '
QUOTA_IP_DAILY = ' quota: {ns} :daily: {date} :ip: {ip} '
QUOTA_IP_WEEKLY = ' quota: {ns} :weekly: {week} :ip: {ip} '
QUOTA_IP_WEEKLY = ' quota: {ns} :weekly: {week} :ip: {ip} '
@ -324,7 +322,7 @@ class RateLimitMiddleware:
[ p . pattern for p in self . _bypass_paths ] or ' (none) ' ,
[ p . pattern for p in self . _bypass_paths ] or ' (none) ' ,
)
)
logger . info (
logger . info (
' [API QUOTAS] daily= %s weekly= %s (auth keyed by user pk, anon by IP) ' ,
' [API QUOTAS] daily= %s weekly= %s (all callers keyed by IP) ' ,
settings . API_QUOTA_DAILY ,
settings . API_QUOTA_DAILY ,
settings . API_QUOTA_WEEKLY ,
settings . API_QUOTA_WEEKLY ,
)
)
@ -428,37 +426,21 @@ class RateLimitMiddleware:
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
response [ ' X-RateLimit-Reason ' ] = f ' quota_ { exceeded } '
return response
return response
user = getattr ( request , ' user ' , None )
# 6. Per-minute rate limit — 60/min for all callers (anon and auth).
if not ( user and user . is_authenticated ) :
# Auth is not exempt: authenticating must not bypass this cap.
# 6. API-specific per-minute rate limit for anonymous external callers.
# Writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.
# Writes rl:api:ip:<ip>:blocked only — never rl:ip:<ip>:blocked.
if self . api_rate_limit_enabled :
if self . api_rate_limit_enabled :
count = self . _incr_with_ttl ( RL_API_IP_REQUESTS . format ( ip = ip ) , self . api_window )
count = self . _incr_with_ttl ( RL_API_IP_REQUESTS . format ( ip = ip ) , self . api_window )
if count > = self . api_threshold :
if count > = self . api_threshold :
_set_block ( RL_API_IP_BLOCKED . format ( ip = ip ) , RL_INDEX_API_BLOCKED_IPS , self . api_block_seconds )
_set_block ( RL_API_IP_BLOCKED . format ( ip = ip ) , RL_INDEX_API_BLOCKED_IPS , self . api_block_seconds )
logger . warning (
logger . warning (
' api_rate_limit_block reason=api_threshold_exceeded '
' api_rate_limit_block reason=api_threshold_exceeded '
' ip= %s path= %s user_agent= %s count= %s threshold= %s ' ,
' ip= %s path= %s user_agent= %s count= %s threshold= %s ' ,
ip , request . path , request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) ,
ip , request . path , request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) ,
count , self . api_threshold ,
count , self . api_threshold ,
)
)
self . _inc_block_metric ( ' api_threshold_exceeded ' )
self . _inc_block_metric ( ' api_threshold_exceeded ' )
return self . _api_block_response ( ' api_threshold_exceeded ' )
return self . _api_block_response ( ' api_threshold_exceeded ' )
return self . get_response ( request )
# 7. Authenticated /api/ — existing per-user rate limiter unchanged
decision = self . _evaluate ( request )
if decision [ ' action ' ] == ' block ' :
logger . warning (
' ratelimit_block layer=django reason= %s ip= %s path= %s namespace= %s ' ,
decision [ ' reason ' ] , decision [ ' ip ' ] , request . path , _NAMESPACE ,
extra = { ' ua ' : request . META . get ( ' HTTP_USER_AGENT ' , ' ' ) } ,
)
self . _inc_block_metric ( decision [ ' reason ' ] )
response = HttpResponse ( status = 429 )
response [ ' Retry-After ' ] = self . BLOCK_TTL
response [ ' X-RateLimit-Reason ' ] = decision [ ' reason ' ]
return response
return self . get_response ( request )
return self . get_response ( request )
# ------------------------------------------------------------------
# ------------------------------------------------------------------
@ -566,8 +548,7 @@ class RateLimitMiddleware:
def _check_api_quota ( self , request ) :
def _check_api_quota ( self , request ) :
"""
"""
Increment daily and weekly API quota counters for all / api / callers .
Increment daily and weekly API quota counters for all / api / callers .
Auth users are keyed by user pk ( NAT - safe ) ; anon callers by IP .
All callers are keyed by IP — auth status is not checked .
Both groups share the same daily / weekly cap .
Fails open ( returns None ) if Redis / cache is unavailable .
Fails open ( returns None ) if Redis / cache is unavailable .
"""
"""
today = date . today ( )
today = date . today ( )
@ -575,15 +556,9 @@ class RateLimitMiddleware:
date_str = today . isoformat ( )
date_str = today . isoformat ( )
week_str = f ' { iso [ 0 ] } -W { iso [ 1 ] : 02d } '
week_str = f ' { iso [ 0 ] } -W { iso [ 1 ] : 02d } '
user = getattr ( request , ' user ' , None )
ip = get_client_ip ( request )
if user and user . is_authenticated :
d_key = QUOTA_IP_DAILY . format ( ns = _NAMESPACE , date = date_str , ip = ip )
uid = str ( user . pk )
w_key = QUOTA_IP_WEEKLY . format ( ns = _NAMESPACE , week = week_str , ip = ip )
d_key = QUOTA_USER_DAILY . format ( ns = _NAMESPACE , date = date_str , uid = uid )
w_key = QUOTA_USER_WEEKLY . format ( ns = _NAMESPACE , week = week_str , uid = uid )
else :
ip = get_client_ip ( request )
d_key = QUOTA_IP_DAILY . format ( ns = _NAMESPACE , date = date_str , ip = ip )
w_key = QUOTA_IP_WEEKLY . format ( ns = _NAMESPACE , week = week_str , ip = ip )
try :
try :
if _incr_with_ttl ( d_key , 86400 ) > self . api_quota_daily :
if _incr_with_ttl ( d_key , 86400 ) > self . api_quota_daily :