Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions django-backend/soroscan/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,19 @@ def _execute(execute, sql, params, many, context):
with connection.execute_wrapper(_execute):
response = self.get_response(request)

# Forward RateLimit-* headers set by APIKeyThrottle
headers = getattr(request, "_api_key_throttle_headers", None)
if headers and hasattr(response, "__setitem__"):
for name, value in headers.items():
# Forward RateLimit-* headers set by throttle classes.
# _throttle_headers is the unified dict written by RateLimitHeaderMixin
# and APIKeyThrottle. _api_key_throttle_headers is the legacy attribute
# kept for backwards-compatibility; it wins on conflict.
throttle_headers: dict = {}
generic = getattr(request, "_throttle_headers", None)
if generic:
throttle_headers.update(generic)
api_key = getattr(request, "_api_key_throttle_headers", None)
if api_key:
throttle_headers.update(api_key) # API-key values override generic
if throttle_headers and hasattr(response, "__setitem__"):
for name, value in throttle_headers.items():
response[name] = value

return response
Expand Down
95 changes: 91 additions & 4 deletions django-backend/soroscan/throttles.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
"""
Custom throttle classes for SoroScan API rate limiting.

Rate-limit response headers
---------------------------
Every throttle class in this module sets standardised RateLimit-* headers on
``request._throttle_headers`` (a dict) so that ``SlowQueryMiddleware`` can
copy them onto the outgoing response. The headers follow the IETF draft
specification (https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/):

RateLimit-Limit – maximum requests allowed in the current window
RateLimit-Remaining – requests remaining in the current window
RateLimit-Reset – Unix timestamp (UTC) at which the window resets

``APIKeyThrottle`` writes to ``request._api_key_throttle_headers`` (kept for
backwards-compatibility) and *also* to ``request._throttle_headers``.
``SlowQueryMiddleware`` merges both dicts, with API-key headers winning on
conflict so that callers with explicit key quotas always see their own limits.
"""
import logging
import math
import time

from django.conf import settings
Expand All @@ -21,6 +38,63 @@
_HISTORY_TTL = 3600 * 24 * 8


class RateLimitHeaderMixin:
"""
Mixin for ``SimpleRateThrottle`` subclasses that writes standardised
``RateLimit-*`` headers to ``request._throttle_headers`` after each
``allow_request()`` call.

Subclasses must call ``super().allow_request(request, view)`` and this
mixin will decorate the request object with header values derived from the
throttle's internal history cache.
"""

def allow_request(self, request, view):
allowed = super().allow_request(request, view)
self._write_ratelimit_headers(request, allowed)
return allowed

def _write_ratelimit_headers(self, request, allowed: bool) -> None:
"""Compute and store RateLimit-* header values on the request."""
try:
num_requests = getattr(self, "num_requests", None)
duration = getattr(self, "duration", None)
if num_requests is None or duration is None:
return

# History is a list of Unix timestamps stored by SimpleRateThrottle
history = getattr(self, "history", [])
now = getattr(self, "now", time.time())

# Count requests still inside the current window
window_start = now - duration
in_window = sum(1 for t in history if t > window_start)

# If throttled, all slots are consumed
if not allowed:
remaining = 0
else:
remaining = max(0, num_requests - in_window)

# Reset time: when the oldest in-window request ages out
if history:
oldest_in_window = min((t for t in history if t > window_start), default=now)
reset_ts = math.ceil(oldest_in_window + duration)
else:
reset_ts = math.ceil(now + duration)

headers = getattr(request, "_throttle_headers", {})
# Only write if no higher-priority throttle (APIKeyThrottle) already wrote
if HEADER_LIMIT not in headers:
headers[HEADER_LIMIT] = str(num_requests)
headers[HEADER_REMAINING] = str(remaining)
headers[HEADER_RESET] = str(reset_ts)
request._throttle_headers = headers
except Exception:
# Never let header computation break a request
logger.debug("RateLimitHeaderMixin: failed to compute headers", exc_info=True)


class APIKeyThrottle(BaseThrottle):
"""
Per-API-key tiered rate limiting with Redis counters.
Expand Down Expand Up @@ -132,19 +206,28 @@ def _next_reset() -> int:

@staticmethod
def _set_headers(request, *, limit: int, remaining: int, reset: int) -> None:
request._api_key_throttle_headers = {
headers = {
HEADER_LIMIT: str(limit),
HEADER_REMAINING: str(remaining),
HEADER_RESET: str(reset),
}
# Backwards-compatible attribute used by SlowQueryMiddleware
request._api_key_throttle_headers = headers
# Also write to the shared _throttle_headers dict so that the
# middleware has a single place to look for all throttle headers.
# API-key headers take precedence: overwrite whatever a simpler
# throttle may have written earlier.
request._throttle_headers = headers

def wait(self):
return max(0.0, self._next_reset() - time.time())


class IngestRateThrottle(SimpleRateThrottle):
class IngestRateThrottle(RateLimitHeaderMixin, SimpleRateThrottle):
"""
Stricter rate limit for the ingest endpoint (POST /api/ingest/record/).

Sets ``RateLimit-*`` headers on every response via ``RateLimitHeaderMixin``.
"""

scope = "ingest"
Expand All @@ -160,9 +243,11 @@ def throttle_failure(self):
return None # Use default DRF throttle failure behaviour


class GraphQLRateThrottle(SimpleRateThrottle):
class GraphQLRateThrottle(RateLimitHeaderMixin, SimpleRateThrottle):
"""
Rate limit specifically for GraphQL endpoints.

Sets ``RateLimit-*`` headers on every response via ``RateLimitHeaderMixin``.
"""

scope = "graphql"
Expand All @@ -175,7 +260,7 @@ def get_cache_key(self, request, view):
return self.cache_format % {"scope": self.scope, "ident": ident}


class DynamicEndpointThrottle(ScopedRateThrottle):
class DynamicEndpointThrottle(RateLimitHeaderMixin, ScopedRateThrottle):
"""
Dynamically applies a throttle scope to a ViewSet action or APIView.
Checks for `action_throttle_scopes` dictionary on the view to map an action to a scope.
Expand All @@ -184,6 +269,8 @@ class DynamicEndpointThrottle(ScopedRateThrottle):
Bypasses ScopedRateThrottle.allow_request() entirely to prevent it from
overwriting the dynamically resolved ``self.scope`` with the view's static
``throttle_scope`` attribute (which may be absent or None).

Sets ``RateLimit-*`` headers on every response via ``RateLimitHeaderMixin``.
"""

def get_rate(self):
Expand Down
Loading
Loading