feat: implement API versioning /api/v1/
- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -228,12 +228,16 @@ class GlobalRateLimiter:
|
||||
blocked until the oldest request expires.
|
||||
4. A background cleanup task removes dormant IPs from memory periodically.
|
||||
|
||||
**Per-Endpoint Configuration:**
|
||||
**Per-Bucket Configuration:**
|
||||
|
||||
Different endpoints can have different limits. For example:
|
||||
- Login endpoint: 5 requests per 60 seconds
|
||||
- Dashboard read: 100 requests per 60 seconds
|
||||
- Config write: 20 requests per 60 seconds
|
||||
Different endpoints can have different limits via named buckets:
|
||||
- `bans:ban` — 100/minute per IP (ban operations)
|
||||
- `bans:unban` — 100/minute per IP (unban operations)
|
||||
- `blocklist:import` — 10/hour per IP (import operations)
|
||||
- `config:update` — 50/minute per IP (config write operations)
|
||||
|
||||
Each bucket tracks its own requests independently, so hitting the
|
||||
blocklist:import limit does not affect the bans:ban limit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -250,6 +254,32 @@ class GlobalRateLimiter:
|
||||
self.max_requests: int = max_requests
|
||||
self.window_seconds: int = window_seconds
|
||||
self._requests: dict[str, deque[float]] = {}
|
||||
self._buckets: dict[str, dict[str, deque[float]]] = {}
|
||||
|
||||
def _get_bucket_deque(
|
||||
self,
|
||||
bucket: str,
|
||||
ip_address: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> deque[float]:
|
||||
"""Get or create the deque for a specific bucket and IP.
|
||||
|
||||
Args:
|
||||
bucket: Bucket name (e.g., "bans:ban").
|
||||
ip_address: Client IP address.
|
||||
max_requests: Maximum requests for this bucket (unused, for future).
|
||||
window_seconds: Window in seconds (unused, for future).
|
||||
|
||||
Returns:
|
||||
The deque of timestamps for this bucket+IP.
|
||||
"""
|
||||
if bucket not in self._buckets:
|
||||
self._buckets[bucket] = {}
|
||||
bucket_dict = self._buckets[bucket]
|
||||
if ip_address not in bucket_dict:
|
||||
bucket_dict[ip_address] = deque()
|
||||
return bucket_dict[ip_address]
|
||||
|
||||
def check_allowed(self, ip_address: str) -> tuple[bool, float]:
|
||||
"""Check if a request from *ip_address* is allowed.
|
||||
@@ -292,6 +322,53 @@ class GlobalRateLimiter:
|
||||
|
||||
return False, retry_after
|
||||
|
||||
def check_allowed_for_bucket(
|
||||
self,
|
||||
bucket: str,
|
||||
ip_address: str,
|
||||
max_requests: int,
|
||||
window_seconds: int,
|
||||
) -> tuple[bool, float]:
|
||||
"""Check if a request for a specific bucket is allowed.
|
||||
|
||||
Each bucket has independent rate limiting. This allows different
|
||||
endpoints to have different limits (e.g., blocklist import is more
|
||||
restrictive than ban operations).
|
||||
|
||||
Args:
|
||||
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
|
||||
ip_address: The client IP address to rate-limit.
|
||||
max_requests: Maximum requests allowed within the window.
|
||||
window_seconds: Time window (seconds) for this bucket.
|
||||
|
||||
Returns:
|
||||
A tuple of (is_allowed, retry_after_seconds). If is_allowed is True,
|
||||
retry_after_seconds is 0. If False, it's the estimated time to wait.
|
||||
"""
|
||||
now = time()
|
||||
|
||||
requests = self._get_bucket_deque(bucket, ip_address, max_requests, window_seconds)
|
||||
cutoff = now - window_seconds
|
||||
|
||||
# Remove old requests outside the window
|
||||
while requests and requests[0] < cutoff:
|
||||
requests.popleft()
|
||||
|
||||
# If under the limit, allow the request
|
||||
if len(requests) < max_requests:
|
||||
requests.append(now)
|
||||
return True, 0.0
|
||||
|
||||
# Over the limit: calculate how long to wait
|
||||
oldest_request = requests[0]
|
||||
age = now - oldest_request
|
||||
retry_after = window_seconds - age
|
||||
|
||||
# Ensure retry_after is at least 1 second
|
||||
retry_after = max(retry_after, 1.0)
|
||||
|
||||
return False, retry_after
|
||||
|
||||
def cleanup_expired(self) -> None:
|
||||
"""Remove all IPs with no recent requests (cleanup task).
|
||||
|
||||
@@ -316,6 +393,21 @@ class GlobalRateLimiter:
|
||||
if ips_to_remove:
|
||||
log.debug("global_rate_limiter_cleanup", removed_ips=len(ips_to_remove))
|
||||
|
||||
# Cleanup per-bucket dictionaries
|
||||
for bucket, bucket_dict in list(self._buckets.items()):
|
||||
bucket_ips_to_remove = []
|
||||
bucket_window = 60 # Use a reasonable window for bucket cleanup
|
||||
bucket_cutoff = now - bucket_window
|
||||
for ip_address, requests in bucket_dict.items():
|
||||
while requests and requests[0] < bucket_cutoff:
|
||||
requests.popleft()
|
||||
if not requests:
|
||||
bucket_ips_to_remove.append(ip_address)
|
||||
for ip_address in bucket_ips_to_remove:
|
||||
del bucket_dict[ip_address]
|
||||
if not bucket_dict:
|
||||
del self._buckets[bucket]
|
||||
|
||||
def get_state(self) -> Mapping[str, int]:
|
||||
"""Return a read-only view of current request counts per IP.
|
||||
|
||||
@@ -334,6 +426,30 @@ class GlobalRateLimiter:
|
||||
result[ip_address] = count
|
||||
return result
|
||||
|
||||
def get_bucket_state(self, bucket: str) -> Mapping[str, int]:
|
||||
"""Return a read-only view of current request counts per IP for a bucket.
|
||||
|
||||
For debugging and monitoring.
|
||||
|
||||
Args:
|
||||
bucket: Bucket name to get state for.
|
||||
|
||||
Returns:
|
||||
A mapping of IP addresses to their request counts in this bucket.
|
||||
"""
|
||||
if bucket not in self._buckets:
|
||||
return {}
|
||||
now = time()
|
||||
result = {}
|
||||
for ip_address, requests in self._buckets[bucket].items():
|
||||
# Count non-expired requests (use max window of 3600s for hourly buckets)
|
||||
cutoff = now - 3600
|
||||
count = sum(1 for ts in requests if ts >= cutoff)
|
||||
if count > 0:
|
||||
result[ip_address] = count
|
||||
return result
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all tracked requests (for testing)."""
|
||||
self._requests.clear()
|
||||
self._buckets.clear()
|
||||
|
||||
Reference in New Issue
Block a user