Spaces:
Runtime error
Runtime error
File size: 5,575 Bytes
cc15101 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """Phase S - hand-rolled sliding-window rate limiter, backed by redis"""
import time
import uuid
import redis.asyncio as aioredis
from fastapi import HTTPException, Request
from server.limits import REDIS_URL, RATE_LIMIT_ENABLED
from server.user_auth import decode_access_token
# Atomic sliding-window-log in one round trip
# KEYS[1] = bucket key
# ARGV[1] = now (ms) ARGV[2] = window (ms) ARGV[3] = limit ARGV[4] = member
# Returns: {allowed(0/1), remaining, retry_after_ms}
_SLIDING_WINDOW = """
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2]))
local count = redis.call('ZCARD', KEYS[1])
local limit = tonumber(ARGV[3])
if count < limit then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, limit - count - 1, 0}
end
-- Blocked: count >= limit >= 1, so the set is non-empty and the
-- oldest member's score always exists (the script runs atomically).
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0)
local score = redis.call('ZSCORE', KEYS[1], oldest[1])
local retry = (tonumber(score) + tonumber(ARGV[2])) - tonumber(ARGV[1])
if retry < 0 then retry = 0 end
return {0, 0, retry}
"""
_redis = None
_script = None
async def init_redis() -> None:
"""Connect to Redis and register the Lua script. Call on startup."""
global _redis, _script
if not RATE_LIMIT_ENABLED:
return
try:
_redis = aioredis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
_script = _redis.register_script(_SLIDING_WINDOW)
await _redis.ping()
except Exception:
_redis = None # fail open - limiter allows all until redis returns
async def close_redis() -> None:
"""Close the Redis connection. Call on shutdown."""
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
class RateResult:
def __init__(self, allowed, remaining, retry_after, limit):
self.allowed = allowed
self.remaining = remaining
self.retry_after = retry_after
self.limit = limit
# ---- In-memory fallback (used when Redis isn't configured / reachable) ----
# Per-process sliding-window log, keyed exactly like the Redis path. Good enough
# for a single-container deploy such as a free Hugging Face Space: it isn't
# shared across replicas and resets on restart, but it keeps real limits in
# force instead of failing open on a public endpoint.
_mem_buckets: dict = {}
def _hit_in_memory(key, limit, window) -> RateResult:
now_ms = time.time() * 1000
win_ms = window * 1000
cutoff = now_ms - win_ms
# Opportunistic cleanup so keys that went quiet don't leak memory.
if len(_mem_buckets) > 10000:
for k in [k for k, v in _mem_buckets.items() if not v]:
_mem_buckets.pop(k, None)
bucket = _mem_buckets.setdefault(key, [])
drop = 0
for ts in bucket:
if ts <= cutoff:
drop += 1
else:
break
if drop:
del bucket[:drop]
if len(bucket) < limit:
bucket.append(now_ms)
return RateResult(True, limit - len(bucket), 0, limit)
retry_ms = (bucket[0] + win_ms) - now_ms if bucket else win_ms
if retry_ms < 0:
retry_ms = 0
return RateResult(False, 0, (int(retry_ms) + 999) // 1000, limit)
async def _hit(key, limit, window) -> RateResult:
if not RATE_LIMIT_ENABLED:
return RateResult(True, limit, 0, limit)
if _redis is None:
# No Redis (e.g. single-container HF Space) -> per-process limiter.
return _hit_in_memory(key, limit, window)
now_ms = int(time.time() * 1000)
member = f"{now_ms}-{uuid.uuid4().hex[:8]}" # unique even within the same ms
try:
allowed, remaining, retry_ms = await _script(
keys=[key], args=[now_ms, window * 1000, limit, member]
)
except Exception:
return _hit_in_memory(key, limit, window) # Redis hiccup -> fall back
retry_after = (int(retry_ms) + 999) // 1000 # ms -> ceil seconds
return RateResult(bool(allowed), int(remaining), retry_after, limit)
def identity_from_request(request: Request) -> str:
"""Key by logged-in user if a valid JWT is present, else by client IP."""
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
uid = decode_access_token(auth[7:].strip())
if uid:
return f"user:{uid}"
fwd = request.headers.get("x-forwarded-for") # set by nginx
if fwd:
ip = fwd.split(",")[0].strip()
elif request.client:
ip = request.client.host
else:
ip = "unknown"
return f"ip:{ip}"
def rate_limit(limit: int, window: int, scope: str):
"""Build a FastAPI dependency enforcing `limit` requests / `window` seconds."""
async def _dep(request: Request) -> None:
key = f"rl:{scope}:{identity_from_request(request)}"
res = await _hit(key, limit, window)
request.state.rate_headers = { # middleware copies these onto the response
"X-RateLimit-Limit": str(res.limit),
"X-RateLimit-Remaining": str(max(res.remaining, 0)),
}
if not res.allowed:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Please slow down.",
headers={
"Retry-After": str(res.retry_after),
"X-RateLimit-Limit": str(res.limit),
"X-RateLimit-Remaining": "0",
},
)
return _dep
|