ratelimit: tiers, fenêtres glissantes Redis (script Lua unique), middleware ASGI (principal, 429, en-têtes X-RateLimit-*), comptabilité d'usage
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 changed files +969 −0
added
hfmarketdata/api/ratelimit/__init__.py
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +"""Rate limiting (tiers · Redis sliding windows · ASGI middleware · usage accounting).""" | |
added
hfmarketdata/api/ratelimit/middleware.py
+378 −0
@@ -0,0 +1,378 @@ | ||
| 1 | +"""Pure ASGI rate-limit middleware (`install(app)`). | |
| 2 | + | |
| 3 | +Per request under `/v1/`: | |
| 4 | +1. resolve the principal — `Authorization: Bearer hfmd_live_…` or `?api_key=` (hashed lookup, 60 s | |
| 5 | + in-process cache) → `key:<id>` with the key/user tier; otherwise the client IP (first hop of | |
| 6 | + `X-Forwarded-For`, ngrok fronts the API) hashed with `settings.key_hash_salt` → `ip:<hash>`, tier keyless; | |
| 7 | +2. expose `request.state.principal / principal_kind / tier / max_rows / user_id / api_key_id / ratelimit`; | |
| 8 | +3. keyless principals are refused (401 AUTH_REQUIRED) on routes tagged `stream`/`screener`, and on any | |
| 9 | + response whose handler set `request.state.requires_key = True`; | |
| 10 | +4. `/v1/auth/*` is throttled per IP (10 / hour) regardless of key; | |
| 11 | +5. reserve 1 request in the Redis sliding window before the handler (429 when exhausted, with `Retry-After` | |
| 12 | + and `error.type` ∈ requests_per_hour | rows_per_hour | requests_per_minute | rows_per_minute), then charge | |
| 13 | + the rows read from `X-Row-Count` (Parquet ½, quota_exempt/304 → 0) and the extra `request_cost` after it; | |
| 14 | +6. `X-RateLimit-*` headers on every response, usage counters recorded for the dashboard. | |
| 15 | + | |
| 16 | +Account endpoints (`/v1/limits`, `/v1/me/*`, `/v1/admin/*`) are not charged. `/health`, `/openapi.json` | |
| 17 | +and the SPA are exempt. Redis down → fail-open (no headers), the API never goes down because of quotas. | |
| 18 | + | |
| 19 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 20 | +""" | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import hashlib | |
| 24 | +import logging | |
| 25 | +import threading | |
| 26 | +import time | |
| 27 | +from dataclasses import dataclass | |
| 28 | +from datetime import datetime, timezone | |
| 29 | +from typing import Any | |
| 30 | + | |
| 31 | +from fastapi import FastAPI | |
| 32 | +from starlette.datastructures import Headers, MutableHeaders, QueryParams | |
| 33 | +from starlette.middleware import Middleware | |
| 34 | +from starlette.routing import Match | |
| 35 | + | |
| 36 | +from core.config import settings | |
| 37 | +from core.errors import ApiError | |
| 38 | + | |
| 39 | +from . import redis_limiter as rl | |
| 40 | +from . import usage | |
| 41 | +from .tiers import AUTH_REQUESTS_PER_WINDOW, AUTH_WINDOW_S, KEY_REQUIRED_TAGS, TIERS, Tier, row_cost, tier_for, upgrade_hint | |
| 42 | + | |
| 43 | +log = logging.getLogger("hfmarketdata.ratelimit") | |
| 44 | + | |
| 45 | +KEY_PREFIX = "hfmd_live_" | |
| 46 | +KEY_CACHE_TTL_S = 60 | |
| 47 | +EXEMPT_PREFIXES = ("/health", "/openapi.json", "/docs", "/redoc") | |
| 48 | +UNCHARGED_PREFIXES = ("/v1/limits", "/v1/me", "/v1/admin") | |
| 49 | +AUTH_PREFIX = "/v1/auth/" | |
| 50 | +AUTH_UNTHROTTLED = ("/v1/auth/logout",) | |
| 51 | + | |
| 52 | + | |
| 53 | +# ---------------------------------------------------------------------------------------------------- | |
| 54 | +# Principal resolution | |
| 55 | +# ---------------------------------------------------------------------------------------------------- | |
| 56 | + | |
| 57 | +@dataclass(frozen=True) | |
| 58 | +class KeyInfo: | |
| 59 | + key_id: int | |
| 60 | + user_id: int | |
| 61 | + tier: str | |
| 62 | + key_status: str | |
| 63 | + user_status: str | |
| 64 | + email: str | |
| 65 | + | |
| 66 | + | |
| 67 | +@dataclass(frozen=True) | |
| 68 | +class Principal: | |
| 69 | + id: str # key:<id> | ip:<hash> | |
| 70 | + kind: str # "key" | "keyless" | |
| 71 | + tier: Tier | |
| 72 | + user_id: int | None = None | |
| 73 | + api_key_id: int | None = None | |
| 74 | + | |
| 75 | + @property | |
| 76 | + def keyless(self) -> bool: | |
| 77 | + return self.kind == "keyless" | |
| 78 | + | |
| 79 | + | |
| 80 | +_key_cache: dict[str, tuple[float, KeyInfo | None]] = {} | |
| 81 | +_key_cache_lock = threading.Lock() | |
| 82 | + | |
| 83 | + | |
| 84 | +def hash_key(raw_key: str) -> str: | |
| 85 | + from accounts.security import hash_key as _hk # single formula: sha256(salt + key) | |
| 86 | + return _hk(raw_key) | |
| 87 | + | |
| 88 | + | |
| 89 | +def hash_ip(ip: str) -> str: | |
| 90 | + return hashlib.sha256((settings.key_hash_salt + "|ip|" + ip).encode()).hexdigest()[:16] | |
| 91 | + | |
| 92 | + | |
| 93 | +def invalidate_key_cache(raw_key_hash: str | None = None) -> None: | |
| 94 | + """Drop cached lookups (call after revoke / rotate / tier or status change).""" | |
| 95 | + with _key_cache_lock: | |
| 96 | + if raw_key_hash is None: | |
| 97 | + _key_cache.clear() | |
| 98 | + else: | |
| 99 | + _key_cache.pop(raw_key_hash, None) | |
| 100 | + | |
| 101 | + | |
| 102 | +def _lookup_key(key_hash: str) -> KeyInfo | None: | |
| 103 | + from sqlalchemy import select | |
| 104 | + | |
| 105 | + from accounts.models import ApiKey, User | |
| 106 | + from core.db import session | |
| 107 | + with session() as s: | |
| 108 | + row = s.execute(select(ApiKey, User).join(User, User.id == ApiKey.user_id) | |
| 109 | + .where(ApiKey.key_hash == key_hash)).first() | |
| 110 | + if row is None: | |
| 111 | + return None | |
| 112 | + k, u = row | |
| 113 | + if k.status == "active": | |
| 114 | + k.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None) | |
| 115 | + return KeyInfo(k.id, u.id, k.tier_override or u.tier, k.status, u.status, u.email) | |
| 116 | + | |
| 117 | + | |
| 118 | +def resolve_key(raw_key: str) -> KeyInfo | None: | |
| 119 | + h = hash_key(raw_key) | |
| 120 | + now = time.time() | |
| 121 | + with _key_cache_lock: | |
| 122 | + hit = _key_cache.get(h) | |
| 123 | + if hit and hit[0] > now: | |
| 124 | + return hit[1] | |
| 125 | + info = _lookup_key(h) | |
| 126 | + with _key_cache_lock: | |
| 127 | + _key_cache[h] = (now + KEY_CACHE_TTL_S, info) | |
| 128 | + return info | |
| 129 | + | |
| 130 | + | |
| 131 | +def extract_key(headers: Headers, query: QueryParams) -> str | None: | |
| 132 | + auth = headers.get("authorization", "") | |
| 133 | + if auth[:7].lower() == "bearer ": | |
| 134 | + return auth[7:].strip() or None | |
| 135 | + return query.get("api_key") or None | |
| 136 | + | |
| 137 | + | |
| 138 | +def client_ip(scope: dict, headers: Headers) -> str: | |
| 139 | + xff = headers.get("x-forwarded-for") | |
| 140 | + if xff: | |
| 141 | + first = xff.split(",")[0].strip() | |
| 142 | + if first: | |
| 143 | + return first | |
| 144 | + real = headers.get("x-real-ip") | |
| 145 | + if real: | |
| 146 | + return real.strip() | |
| 147 | + client = scope.get("client") | |
| 148 | + return client[0] if client else "unknown" | |
| 149 | + | |
| 150 | + | |
| 151 | +def resolve_principal(scope: dict) -> Principal: | |
| 152 | + """Raises ApiError(401 INVALID_API_KEY / 403 ACCOUNT_DISABLED) for a bad key; never for a missing one.""" | |
| 153 | + headers = Headers(scope=scope) | |
| 154 | + query = QueryParams(scope.get("query_string", b"")) | |
| 155 | + raw = extract_key(headers, query) | |
| 156 | + if raw: | |
| 157 | + info = resolve_key(raw) if raw.startswith(KEY_PREFIX) else None | |
| 158 | + if info is None or info.key_status != "active": | |
| 159 | + raise ApiError(401, "INVALID_API_KEY", "The API key is unknown, malformed or revoked. Check it in your " | |
| 160 | + f"dashboard ({settings.public_url}/dashboard) or omit it to use keyless access.") | |
| 161 | + if info.user_status != "active": | |
| 162 | + raise ApiError(403, "ACCOUNT_DISABLED", "This API key belongs to a disabled account. " | |
| 163 | + f"Contact {settings.contact_email}.") | |
| 164 | + return Principal(f"key:{info.key_id}", "key", tier_for(info.tier), info.user_id, info.key_id) | |
| 165 | + ip = client_ip(scope, headers) | |
| 166 | + return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) | |
| 167 | + | |
| 168 | + | |
| 169 | +# ---------------------------------------------------------------------------------------------------- | |
| 170 | +# Helpers | |
| 171 | +# ---------------------------------------------------------------------------------------------------- | |
| 172 | + | |
| 173 | +def _leaf_routes(routes): | |
| 174 | + """Flatten FastAPI's routing tree (plain APIRoutes + nested included routers) into matchable leaves.""" | |
| 175 | + for route in routes: | |
| 176 | + ctxs = getattr(route, "effective_route_contexts", None) # FastAPI ≥ 0.13x `_IncludedRouter` | |
| 177 | + if callable(ctxs): | |
| 178 | + try: | |
| 179 | + yield from ctxs() | |
| 180 | + except Exception: # pragma: no cover | |
| 181 | + continue | |
| 182 | + else: | |
| 183 | + yield route | |
| 184 | + | |
| 185 | + | |
| 186 | +def route_tags(scope: dict) -> set[str]: | |
| 187 | + """Tags of the route that will handle `scope` (empty set when nothing matches / unknown router type).""" | |
| 188 | + app = scope.get("app") | |
| 189 | + routes = getattr(getattr(app, "router", None), "routes", None) or [] | |
| 190 | + for route in _leaf_routes(routes): | |
| 191 | + try: | |
| 192 | + match, _ = route.matches(scope) | |
| 193 | + except Exception: | |
| 194 | + continue | |
| 195 | + if match == Match.FULL: | |
| 196 | + return {str(t) for t in (getattr(route, "tags", None) or [])} | |
| 197 | + return set() | |
| 198 | + | |
| 199 | + | |
| 200 | +def _auth_required_error(kind: str = "endpoint") -> ApiError: | |
| 201 | + return ApiError(401, "AUTH_REQUIRED", f"This {kind} requires an API key. {upgrade_hint(TIERS['keyless'])}", | |
| 202 | + details={"docs": f"{settings.docs_url}/authentication"}) | |
| 203 | + | |
| 204 | + | |
| 205 | +def _rate_limited_error(decision: rl.Decision, tier: Tier, principal: Principal, *, now_s: int, | |
| 206 | + scope_label: str | None = None) -> ApiError: | |
| 207 | + if not decision.allowed_requests: | |
| 208 | + typ, reset, limit = tier.requests_type, decision.reset_requests, decision.limit_requests | |
| 209 | + what = f"{limit} requests per {tier.window_label}" | |
| 210 | + else: | |
| 211 | + typ, reset, limit = tier.rows_type, decision.reset_rows, decision.limit_rows | |
| 212 | + what = f"{limit:,} rows per {tier.window_label}" | |
| 213 | + retry = max(1, reset - now_s) | |
| 214 | + label = scope_label or ("keyless (per IP)" if principal.keyless else f"{tier.name} tier") | |
| 215 | + msg = f"Rate limit exceeded: {what} for {label}. Retry in {retry} s." | |
| 216 | + if principal.keyless or tier.name == "free": | |
| 217 | + msg += " " + upgrade_hint(tier) | |
| 218 | + headers = {**decision.headers(), "Retry-After": str(retry)} | |
| 219 | + return ApiError(429, "RATE_LIMIT_EXCEEDED", msg, type=typ, headers=headers, | |
| 220 | + details={"limit": limit, "window_seconds": tier.window_s, "reset": reset, "tier": tier.name}) | |
| 221 | + | |
| 222 | + | |
| 223 | +def snapshot(decision: rl.Decision | None, tier: Tier, principal: Principal) -> dict[str, Any]: | |
| 224 | + out: dict[str, Any] = {"principal": principal.id, "kind": principal.kind, "tier": tier.name, | |
| 225 | + "window_seconds": tier.window_s, "max_rows_per_request": tier.max_rows_per_request, | |
| 226 | + "requests": {"limit": tier.requests, "remaining": None, "reset": None}, | |
| 227 | + "rows": {"limit": tier.rows, "remaining": None, "reset": None}, "redis": decision is not None} | |
| 228 | + if decision is not None: | |
| 229 | + out["requests"].update(remaining=decision.remaining_requests, reset=decision.reset_requests) | |
| 230 | + out["rows"].update(remaining=decision.remaining_rows, reset=decision.reset_rows) | |
| 231 | + return out | |
| 232 | + | |
| 233 | + | |
| 234 | +def _is_charged(path: str) -> bool: | |
| 235 | + return not path.startswith(UNCHARGED_PREFIXES) and not path.startswith(AUTH_PREFIX) | |
| 236 | + | |
| 237 | + | |
| 238 | +# ---------------------------------------------------------------------------------------------------- | |
| 239 | +# Middleware | |
| 240 | +# ---------------------------------------------------------------------------------------------------- | |
| 241 | + | |
| 242 | +class RateLimitMiddleware: | |
| 243 | + def __init__(self, app) -> None: | |
| 244 | + self.app = app | |
| 245 | + | |
| 246 | + async def __call__(self, scope, receive, send): | |
| 247 | + if scope["type"] == "websocket": | |
| 248 | + return await self._websocket(scope, receive, send) | |
| 249 | + if scope["type"] != "http": | |
| 250 | + return await self.app(scope, receive, send) | |
| 251 | + path: str = scope.get("path", "") | |
| 252 | + if not path.startswith("/v1/") or path.startswith(EXEMPT_PREFIXES) or not settings.ratelimit_enabled: | |
| 253 | + return await self.app(scope, receive, send) | |
| 254 | + usage.start_background() | |
| 255 | + state: dict = scope.setdefault("state", {}) | |
| 256 | + now_s = rl.now_ms() // 1000 | |
| 257 | + | |
| 258 | + try: | |
| 259 | + principal = resolve_principal(scope) | |
| 260 | + except ApiError as exc: | |
| 261 | + return await exc.response()(scope, receive, send) | |
| 262 | + tier = principal.tier | |
| 263 | + state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, | |
| 264 | + max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) | |
| 265 | + | |
| 266 | + if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): | |
| 267 | + return await _auth_required_error().response()(scope, receive, send) | |
| 268 | + | |
| 269 | + # /v1/auth/* : per-IP throttle (10 / hour), whatever the key | |
| 270 | + if path.startswith(AUTH_PREFIX): | |
| 271 | + if path not in AUTH_UNTHROTTLED: | |
| 272 | + ip_principal = f"auth:ip:{hash_ip(client_ip(scope, Headers(scope=scope)))}" | |
| 273 | + auth_tier = Tier("auth", "ip", AUTH_WINDOW_S, AUTH_REQUESTS_PER_WINDOW, 10**9, 0) | |
| 274 | + d = rl.apply_tier(ip_principal, auth_tier, req_cost=1) | |
| 275 | + if d is not None and not d.allowed_requests: | |
| 276 | + err = _rate_limited_error(d, auth_tier, principal, now_s=now_s, scope_label="authentication endpoints (per IP)") | |
| 277 | + err.message = f"Too many authentication attempts: {AUTH_REQUESTS_PER_WINDOW} per hour per IP. Retry in {err.headers['Retry-After']} s." | |
| 278 | + return await err.response()(scope, receive, send) | |
| 279 | + extra = d.headers() if d is not None else {} | |
| 280 | + else: | |
| 281 | + extra = {} | |
| 282 | + return await self._forward(scope, receive, send, principal, tier, None, charged=False, extra_headers=extra) | |
| 283 | + | |
| 284 | + charged = _is_charged(path) | |
| 285 | + decision = rl.apply_tier(principal.id, tier, req_cost=1 if charged else 0) | |
| 286 | + if decision is not None and charged and not decision.allowed: | |
| 287 | + err = _rate_limited_error(decision, tier, principal, now_s=now_s) | |
| 288 | + usage.record(principal.id, requests=0, status=429) | |
| 289 | + return await err.response()(scope, receive, send) | |
| 290 | + state["ratelimit"] = snapshot(decision, tier, principal) | |
| 291 | + return await self._forward(scope, receive, send, principal, tier, decision, charged=charged) | |
| 292 | + | |
| 293 | + async def _forward(self, scope, receive, send, principal: Principal, tier: Tier, decision: rl.Decision | None, | |
| 294 | + *, charged: bool, extra_headers: dict[str, str] | None = None): | |
| 295 | + state: dict = scope["state"] | |
| 296 | + started = False | |
| 297 | + replaced = False | |
| 298 | + final = decision | |
| 299 | + | |
| 300 | + async def send_wrapper(message): | |
| 301 | + nonlocal started, replaced, final | |
| 302 | + if message["type"] == "http.response.start": | |
| 303 | + started = True | |
| 304 | + if principal.keyless and state.get("requires_key"): | |
| 305 | + replaced = True | |
| 306 | + err = _auth_required_error() | |
| 307 | + for k, v in (final.headers() if final else {}).items(): | |
| 308 | + err.headers[k] = v | |
| 309 | + return await err.response()(scope, receive, send) | |
| 310 | + headers = MutableHeaders(scope=message) | |
| 311 | + status = int(message["status"]) | |
| 312 | + try: | |
| 313 | + rows = int(headers.get("x-row-count") or 0) | |
| 314 | + except ValueError: | |
| 315 | + rows = 0 | |
| 316 | + media = headers.get("content-type") | |
| 317 | + is_parquet = bool(media) and media.split(";")[0].strip().lower() == "application/vnd.apache.parquet" | |
| 318 | + cost = row_cost(rows, status=status, media_type=media, quota_exempt=bool(state.get("quota_exempt"))) | |
| 319 | + extra_req = max(int(state.get("request_cost", 1) or 1) - 1, 0) if charged else 0 | |
| 320 | + if charged and decision is not None and (cost > 0 or extra_req > 0): | |
| 321 | + d2 = rl.apply_tier(principal.id, tier, req_cost=extra_req, rows_cost=cost, force=True) | |
| 322 | + if d2 is not None: | |
| 323 | + final = d2 | |
| 324 | + state["ratelimit"] = snapshot(d2, tier, principal) | |
| 325 | + if final is not None: | |
| 326 | + for k, v in final.headers().items(): | |
| 327 | + headers[k] = v | |
| 328 | + for k, v in (extra_headers or {}).items(): | |
| 329 | + headers[k] = v | |
| 330 | + if charged: | |
| 331 | + try: | |
| 332 | + size = int(headers.get("content-length") or 0) | |
| 333 | + except ValueError: | |
| 334 | + size = 0 | |
| 335 | + usage.record(principal.id, requests=1 + extra_req, rows=cost, rows_parquet=cost if is_parquet else 0, | |
| 336 | + bytes_=size, status=status) | |
| 337 | + elif replaced: | |
| 338 | + return | |
| 339 | + await send(message) | |
| 340 | + | |
| 341 | + try: | |
| 342 | + await self.app(scope, receive, send_wrapper) | |
| 343 | + except Exception as exc: | |
| 344 | + if started: | |
| 345 | + raise | |
| 346 | + log.exception("unhandled error on %s: %s", scope.get("path"), exc) | |
| 347 | + err = ApiError(500, "INTERNAL_ERROR", f"Unexpected server error. Please retry or contact {settings.contact_email}.") | |
| 348 | + if final is not None: | |
| 349 | + err.headers.update(final.headers()) | |
| 350 | + await err.response()(scope, receive, send) | |
| 351 | + | |
| 352 | + async def _websocket(self, scope, receive, send): | |
| 353 | + path: str = scope.get("path", "") | |
| 354 | + if not path.startswith("/v1/") or not settings.ratelimit_enabled: | |
| 355 | + return await self.app(scope, receive, send) | |
| 356 | + state: dict = scope.setdefault("state", {}) | |
| 357 | + try: | |
| 358 | + principal = resolve_principal(scope) | |
| 359 | + except ApiError as exc: | |
| 360 | + return await send({"type": "websocket.close", "code": 4401, "reason": exc.message[:120]}) | |
| 361 | + tier = principal.tier | |
| 362 | + state.update(principal=principal.id, principal_kind=principal.kind, tier=tier.name, | |
| 363 | + max_rows=tier.max_rows_per_request, user_id=principal.user_id, api_key_id=principal.api_key_id) | |
| 364 | + if principal.keyless and (route_tags(scope) & KEY_REQUIRED_TAGS): | |
| 365 | + return await send({"type": "websocket.close", "code": 4401, "reason": "API key required (create a free account)"}) | |
| 366 | + decision = rl.apply_tier(principal.id, tier, req_cost=1) | |
| 367 | + if decision is not None and not decision.allowed: | |
| 368 | + return await send({"type": "websocket.close", "code": 4429, "reason": "rate limit exceeded"}) | |
| 369 | + state["ratelimit"] = snapshot(decision, tier, principal) | |
| 370 | + usage.record(principal.id, requests=1, status=200) | |
| 371 | + return await self.app(scope, receive, send) | |
| 372 | + | |
| 373 | + | |
| 374 | +def install(app: FastAPI) -> None: | |
| 375 | + """Mount the middleware INSIDE the CORS layer so 429/401 responses keep their CORS headers.""" | |
| 376 | + if app.middleware_stack is not None: # pragma: no cover — must run before the first request | |
| 377 | + raise RuntimeError("ratelimit.middleware.install() must be called before the app starts") | |
| 378 | + app.user_middleware.append(Middleware(RateLimitMiddleware)) | |
added
hfmarketdata/api/ratelimit/redis_limiter.py
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +"""Sliding-window counters in Redis, evaluated atomically by ONE Lua script. | |
| 2 | + | |
| 3 | +Each (principal, kind) has a HASH `rl:{principal}:{req|rows}` whose fields are 1-second buckets | |
| 4 | +(`epoch_second -> count`). The script prunes buckets older than the window, sums the live ones, decides | |
| 5 | +whether the request fits and applies the charge — for the requests counter AND the rows counter in the | |
| 6 | +same EVALSHA, so the two decisions are consistent. | |
| 7 | + | |
| 8 | + EVALSHA sha 2 rl:{p}:req rl:{p}:rows now_ms window_s req_cost req_limit rows_cost rows_limit force | |
| 9 | + -> {allowed_req, remaining_req, reset_req, allowed_rows, remaining_rows, reset_rows, applied} | |
| 10 | + | |
| 11 | +`force=1` applies the charge even when it overshoots (used after the handler ran: the rows were already | |
| 12 | +served, the next request will be refused). `force=0` with zero costs is a pure peek. | |
| 13 | + | |
| 14 | +Redis availability: the client uses short timeouts; on any error we log a warning, open a 5-second | |
| 15 | +circuit and return `None` so the middleware fails open (headers omitted, API stays up). | |
| 16 | + | |
| 17 | +`settings.redis_url == "fakeredis://"` swaps in fakeredis (tests, CI). | |
| 18 | + | |
| 19 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 20 | +""" | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import logging | |
| 24 | +import threading | |
| 25 | +import time | |
| 26 | +from dataclasses import dataclass | |
| 27 | + | |
| 28 | +import redis | |
| 29 | + | |
| 30 | +from core.config import settings | |
| 31 | + | |
| 32 | +from .tiers import Tier | |
| 33 | + | |
| 34 | +log = logging.getLogger("hfmarketdata.ratelimit") | |
| 35 | + | |
| 36 | +LUA = r""" | |
| 37 | +local unpack_ = table.unpack or unpack | |
| 38 | +local now_s = math.floor(tonumber(ARGV[1]) / 1000) | |
| 39 | +local window = tonumber(ARGV[2]) | |
| 40 | +local cost_r, lim_r = tonumber(ARGV[3]), tonumber(ARGV[4]) | |
| 41 | +local cost_w, lim_w = tonumber(ARGV[5]), tonumber(ARGV[6]) | |
| 42 | +local force = tonumber(ARGV[7]) | |
| 43 | +local cutoff = now_s - window | |
| 44 | + | |
| 45 | +local function scan(key) | |
| 46 | + local used, oldest = 0, nil | |
| 47 | + local h = redis.call('HGETALL', key) | |
| 48 | + local stale = {} | |
| 49 | + for i = 1, #h, 2 do | |
| 50 | + local b = tonumber(h[i]) | |
| 51 | + local v = tonumber(h[i + 1]) or 0 | |
| 52 | + if b == nil or b <= cutoff then | |
| 53 | + stale[#stale + 1] = h[i] | |
| 54 | + else | |
| 55 | + used = used + v | |
| 56 | + if oldest == nil or b < oldest then oldest = b end | |
| 57 | + end | |
| 58 | + end | |
| 59 | + for i = 1, #stale, 500 do | |
| 60 | + redis.call('HDEL', key, unpack_(stale, i, math.min(i + 499, #stale))) | |
| 61 | + end | |
| 62 | + return used, oldest | |
| 63 | +end | |
| 64 | + | |
| 65 | +local used_r, old_r = scan(KEYS[1]) | |
| 66 | +local used_w, old_w = scan(KEYS[2]) | |
| 67 | +local ok_r = (used_r + cost_r) <= lim_r | |
| 68 | +local ok_w = (used_w + math.max(cost_w, 1)) <= lim_w | |
| 69 | +local applied = 0 | |
| 70 | +if force == 1 or (ok_r and ok_w) then | |
| 71 | + if cost_r > 0 then | |
| 72 | + redis.call('HINCRBY', KEYS[1], tostring(now_s), cost_r) | |
| 73 | + redis.call('EXPIRE', KEYS[1], window + 2) | |
| 74 | + used_r = used_r + cost_r | |
| 75 | + if old_r == nil then old_r = now_s end | |
| 76 | + end | |
| 77 | + if cost_w > 0 then | |
| 78 | + redis.call('HINCRBY', KEYS[2], tostring(now_s), cost_w) | |
| 79 | + redis.call('EXPIRE', KEYS[2], window + 2) | |
| 80 | + used_w = used_w + cost_w | |
| 81 | + if old_w == nil then old_w = now_s end | |
| 82 | + end | |
| 83 | + applied = 1 | |
| 84 | +end | |
| 85 | +local rem_r = math.max(lim_r - used_r, 0) | |
| 86 | +local rem_w = math.max(lim_w - used_w, 0) | |
| 87 | +local reset_r = (old_r and (old_r + window)) or (now_s + window) | |
| 88 | +local reset_w = (old_w and (old_w + window)) or (now_s + window) | |
| 89 | +return {ok_r and 1 or 0, rem_r, reset_r, ok_w and 1 or 0, rem_w, reset_w, applied} | |
| 90 | +""" | |
| 91 | + | |
| 92 | +_lock = threading.Lock() | |
| 93 | +_client: redis.Redis | None = None | |
| 94 | +_fake_server = None | |
| 95 | +_sha: str | None = None | |
| 96 | +_down_until = 0.0 | |
| 97 | +_last_warn = 0.0 | |
| 98 | +CIRCUIT_OPEN_S = 5.0 | |
| 99 | + | |
| 100 | + | |
| 101 | +def now_ms() -> int: | |
| 102 | + """Time source (patched by tests).""" | |
| 103 | + return int(time.time() * 1000) | |
| 104 | + | |
| 105 | + | |
| 106 | +def client() -> redis.Redis: | |
| 107 | + """Process-wide Redis client (fakeredis when the URL is `fakeredis://`).""" | |
| 108 | + global _client, _fake_server | |
| 109 | + if _client is None: | |
| 110 | + with _lock: | |
| 111 | + if _client is None: | |
| 112 | + if settings.redis_url.startswith("fakeredis://"): | |
| 113 | + import fakeredis | |
| 114 | + _fake_server = _fake_server or fakeredis.FakeServer() | |
| 115 | + _client = fakeredis.FakeRedis(server=_fake_server) | |
| 116 | + else: | |
| 117 | + _client = redis.Redis.from_url(settings.redis_url, socket_connect_timeout=0.25, | |
| 118 | + socket_timeout=0.5, health_check_interval=30) | |
| 119 | + return _client | |
| 120 | + | |
| 121 | + | |
| 122 | +def _register() -> str: | |
| 123 | + global _sha | |
| 124 | + if _sha is None: | |
| 125 | + _sha = client().script_load(LUA) | |
| 126 | + return _sha | |
| 127 | + | |
| 128 | + | |
| 129 | +def _warn(exc: Exception) -> None: | |
| 130 | + global _last_warn | |
| 131 | + t = time.time() | |
| 132 | + if t - _last_warn > 30: | |
| 133 | + _last_warn = t | |
| 134 | + log.warning("redis unavailable (%s: %s) — rate limiting fails open for %.0fs", type(exc).__name__, exc, CIRCUIT_OPEN_S) | |
| 135 | + | |
| 136 | + | |
| 137 | +def available() -> bool: | |
| 138 | + return time.time() >= _down_until | |
| 139 | + | |
| 140 | + | |
| 141 | +def _call(fn, *args): | |
| 142 | + """Run a Redis operation through the circuit breaker. Returns None when Redis is down.""" | |
| 143 | + global _down_until | |
| 144 | + if not available(): | |
| 145 | + return None | |
| 146 | + try: | |
| 147 | + return fn(*args) | |
| 148 | + except redis.exceptions.NoScriptError: | |
| 149 | + global _sha | |
| 150 | + _sha = None | |
| 151 | + try: | |
| 152 | + _register() | |
| 153 | + return fn(*args) | |
| 154 | + except Exception as exc: # pragma: no cover | |
| 155 | + _down_until = time.time() + CIRCUIT_OPEN_S | |
| 156 | + _warn(exc) | |
| 157 | + return None | |
| 158 | + except Exception as exc: | |
| 159 | + _down_until = time.time() + CIRCUIT_OPEN_S | |
| 160 | + _warn(exc) | |
| 161 | + return None | |
| 162 | + | |
| 163 | + | |
| 164 | +@dataclass(frozen=True) | |
| 165 | +class Decision: | |
| 166 | + allowed_requests: bool | |
| 167 | + remaining_requests: int | |
| 168 | + reset_requests: int # epoch seconds | |
| 169 | + allowed_rows: bool | |
| 170 | + remaining_rows: int | |
| 171 | + reset_rows: int | |
| 172 | + applied: bool | |
| 173 | + limit_requests: int | |
| 174 | + limit_rows: int | |
| 175 | + window_s: int | |
| 176 | + | |
| 177 | + @property | |
| 178 | + def allowed(self) -> bool: | |
| 179 | + return self.allowed_requests and self.allowed_rows | |
| 180 | + | |
| 181 | + @property | |
| 182 | + def reset(self) -> int: | |
| 183 | + return max(self.reset_requests, self.reset_rows) | |
| 184 | + | |
| 185 | + def headers(self) -> dict[str, str]: | |
| 186 | + return { | |
| 187 | + "X-RateLimit-Limit-Requests": str(self.limit_requests), | |
| 188 | + "X-RateLimit-Remaining-Requests": str(self.remaining_requests), | |
| 189 | + "X-RateLimit-Limit-Rows": str(self.limit_rows), | |
| 190 | + "X-RateLimit-Remaining-Rows": str(self.remaining_rows), | |
| 191 | + "X-RateLimit-Reset": str(self.reset), | |
| 192 | + } | |
| 193 | + | |
| 194 | + | |
| 195 | +def keys_for(principal: str, namespace: str = "rl") -> tuple[str, str]: | |
| 196 | + return f"{namespace}:{principal}:req", f"{namespace}:{principal}:rows" | |
| 197 | + | |
| 198 | + | |
| 199 | +def apply(principal: str, *, window_s: int, limit_requests: int, limit_rows: int, req_cost: int = 1, | |
| 200 | + rows_cost: int = 0, force: bool = False, namespace: str = "rl", at_ms: int | None = None) -> Decision | None: | |
| 201 | + """Evaluate (and apply) a charge. `None` means Redis is unavailable (caller fails open).""" | |
| 202 | + k_req, k_rows = keys_for(principal, namespace) | |
| 203 | + t = now_ms() if at_ms is None else at_ms | |
| 204 | + | |
| 205 | + def run(): | |
| 206 | + sha = _register() | |
| 207 | + return client().evalsha(sha, 2, k_req, k_rows, t, window_s, int(req_cost), int(limit_requests), | |
| 208 | + int(rows_cost), int(limit_rows), 1 if force else 0) | |
| 209 | + | |
| 210 | + res = _call(run) | |
| 211 | + if res is None: | |
| 212 | + return None | |
| 213 | + ok_r, rem_r, reset_r, ok_w, rem_w, reset_w, applied = (int(x) for x in res) | |
| 214 | + return Decision(bool(ok_r), rem_r, reset_r, bool(ok_w), rem_w, reset_w, bool(applied), | |
| 215 | + int(limit_requests), int(limit_rows), int(window_s)) | |
| 216 | + | |
| 217 | + | |
| 218 | +def apply_tier(principal: str, tier: Tier, *, req_cost: int = 1, rows_cost: int = 0, force: bool = False, | |
| 219 | + at_ms: int | None = None) -> Decision | None: | |
| 220 | + return apply(principal, window_s=tier.window_s, limit_requests=tier.requests, limit_rows=tier.rows, | |
| 221 | + req_cost=req_cost, rows_cost=rows_cost, force=force, at_ms=at_ms) | |
| 222 | + | |
| 223 | + | |
| 224 | +def peek_tier(principal: str, tier: Tier, at_ms: int | None = None) -> Decision | None: | |
| 225 | + return apply_tier(principal, tier, req_cost=0, rows_cost=0, at_ms=at_ms) | |
| 226 | + | |
| 227 | + | |
| 228 | +def reset_for_tests() -> None: | |
| 229 | + """Flush every counter (fakeredis only — never call against production Redis).""" | |
| 230 | + global _down_until | |
| 231 | + _down_until = 0.0 | |
| 232 | + if settings.redis_url.startswith("fakeredis://"): | |
| 233 | + client().flushall() | |
added
hfmarketdata/api/ratelimit/tiers.py
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +"""Tier table and cost rules (the single source of truth for /v1/limits, the docs and the middleware). | |
| 2 | + | |
| 3 | +| tier | scope | window | requests | rows | max rows / request | | |
| 4 | +|-------------|---------|--------|----------|------------|--------------------| | |
| 5 | +| keyless | per IP | 1 h | 30 | 100 000 | 5 000 | | |
| 6 | +| free | per key | 1 min | 120 | 1 000 000 | 50 000 | | |
| 7 | +| high_usage | per key | 1 min | 600 | 10 000 000 | 200 000 | | |
| 8 | + | |
| 9 | +Cost rules: | |
| 10 | +* every request costs `request.state.request_cost` requests (default 1; screener/frames set 2); | |
| 11 | +* every data row returned costs 1 row, Parquet responses cost ceil(rows / 2); | |
| 12 | +* `request.state.quota_exempt` (bulk) and HTTP 304 responses cost 0 rows; | |
| 13 | +* keyless principals cannot use endpoints tagged `stream` or `screener`, nor endpoints that set | |
| 14 | + `request.state.requires_key = True` (401 AUTH_REQUIRED). | |
| 15 | + | |
| 16 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 17 | +""" | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import math | |
| 21 | +from dataclasses import asdict, dataclass | |
| 22 | + | |
| 23 | +from core.config import settings | |
| 24 | + | |
| 25 | +PARQUET_MEDIA = "application/vnd.apache.parquet" | |
| 26 | +PARQUET_ROW_FACTOR = 0.5 | |
| 27 | +KEY_REQUIRED_TAGS = frozenset({"stream", "screener"}) | |
| 28 | +AUTH_WINDOW_S = 3600 | |
| 29 | +AUTH_REQUESTS_PER_WINDOW = 10 # /v1/auth/* per IP (signup, login, forgot, …) | |
| 30 | + | |
| 31 | + | |
| 32 | +@dataclass(frozen=True) | |
| 33 | +class Tier: | |
| 34 | + name: str | |
| 35 | + scope: str # "ip" | "key" | |
| 36 | + window_s: int | |
| 37 | + requests: int | |
| 38 | + rows: int | |
| 39 | + max_rows_per_request: int | |
| 40 | + | |
| 41 | + @property | |
| 42 | + def window_label(self) -> str: | |
| 43 | + return "hour" if self.window_s >= 3600 else "minute" | |
| 44 | + | |
| 45 | + @property | |
| 46 | + def requests_type(self) -> str: | |
| 47 | + return f"requests_per_{self.window_label}" | |
| 48 | + | |
| 49 | + @property | |
| 50 | + def rows_type(self) -> str: | |
| 51 | + return f"rows_per_{self.window_label}" | |
| 52 | + | |
| 53 | + def public(self) -> dict: | |
| 54 | + d = asdict(self) | |
| 55 | + d["window"] = "1h" if self.window_s >= 3600 else "1m" | |
| 56 | + return d | |
| 57 | + | |
| 58 | + | |
| 59 | +TIERS: dict[str, Tier] = { | |
| 60 | + "keyless": Tier("keyless", "ip", 3600, 30, 100_000, 5_000), | |
| 61 | + "free": Tier("free", "key", 60, 120, 1_000_000, 50_000), | |
| 62 | + "high_usage": Tier("high_usage", "key", 60, 600, 10_000_000, 200_000), | |
| 63 | +} | |
| 64 | +ACCOUNT_TIERS = ("free", "high_usage") | |
| 65 | +DEFAULT_ACCOUNT_TIER = "free" | |
| 66 | + | |
| 67 | + | |
| 68 | +def tier_for(name: str | None) -> Tier: | |
| 69 | + return TIERS.get(name or "", TIERS["free"]) | |
| 70 | + | |
| 71 | + | |
| 72 | +def row_cost(rows: int, *, status: int, media_type: str | None, quota_exempt: bool) -> int: | |
| 73 | + """Rows charged for a response (see module docstring).""" | |
| 74 | + if quota_exempt or status == 304 or status < 200 or status >= 300 or rows <= 0: | |
| 75 | + return 0 | |
| 76 | + if media_type and media_type.split(";")[0].strip().lower() == PARQUET_MEDIA: | |
| 77 | + return int(math.ceil(rows * PARQUET_ROW_FACTOR)) | |
| 78 | + return int(rows) | |
| 79 | + | |
| 80 | + | |
| 81 | +def upgrade_hint(tier: Tier) -> str: | |
| 82 | + """Human message appended to keyless errors.""" | |
| 83 | + if tier.name == "keyless": | |
| 84 | + free = TIERS["free"] | |
| 85 | + return (f"Create a free account at {settings.public_url}/signup for {free.requests} requests/min " | |
| 86 | + f"and {free.rows:,} rows/min with an API key.") | |
| 87 | + if tier.name == "free": | |
| 88 | + return f"Need more? Ask for the high-usage tier at {settings.contact_email}." | |
| 89 | + return f"Contact {settings.contact_email} if you need a higher limit." | |
| 90 | + | |
| 91 | + | |
| 92 | +def tiers_public() -> dict[str, dict]: | |
| 93 | + out = {name: t.public() for name, t in TIERS.items()} | |
| 94 | + out["high_usage"]["how_to_get"] = f"e-mail {settings.contact_email}" | |
| 95 | + out["free"]["how_to_get"] = f"{settings.public_url}/signup" | |
| 96 | + return out | |
added
hfmarketdata/api/ratelimit/usage.py
+261 −0
@@ -0,0 +1,261 @@ | ||
| 1 | +"""Usage accounting: per-minute counters in Redis, folded into SQLite every minute. | |
| 2 | + | |
| 3 | +Redis: HASH `usage:{principal}:{minute_epoch}` (requests, rows, rows_parquet, bytes, status_2xx, status_429), | |
| 4 | + TTL 8 days, plus a SET `usage:pending` indexing the hashes still to fold. | |
| 5 | +SQLite: `usage_minute` (7 rolling days, for the 24 h / 7 d charts) and `usage_daily` (forever). | |
| 6 | + | |
| 7 | +`start_background(loop)` schedules the folder every 60 s (once per process, guarded by a Redis lock so the | |
| 8 | +2 uvicorn workers never double count). `usage_series(principals, range)` serves the dashboard. | |
| 9 | + | |
| 10 | +Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 11 | +""" | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import asyncio | |
| 15 | +import logging | |
| 16 | +import time | |
| 17 | +from collections import defaultdict | |
| 18 | +from datetime import datetime, timedelta, timezone | |
| 19 | + | |
| 20 | +from sqlalchemy import delete, func, select | |
| 21 | + | |
| 22 | +from core.db import session | |
| 23 | + | |
| 24 | +from . import redis_limiter as rl | |
| 25 | + | |
| 26 | +log = logging.getLogger("hfmarketdata.usage") | |
| 27 | + | |
| 28 | +PENDING_SET = "usage:pending" | |
| 29 | +FOLD_LOCK = "usage:fold:lock" | |
| 30 | +TTL_S = 8 * 86400 | |
| 31 | +MINUTE_RETENTION_DAYS = 7 | |
| 32 | +FIELDS = ("requests", "rows", "rows_parquet", "bytes", "status_2xx", "status_429") | |
| 33 | +RANGES = {"24h": (timedelta(hours=24), 60), "7d": (timedelta(days=7), 3600), "30d": (timedelta(days=30), 86400)} | |
| 34 | + | |
| 35 | +_started_loops: set[int] = set() | |
| 36 | + | |
| 37 | + | |
| 38 | +def _minute(ts_s: float | None = None) -> int: | |
| 39 | + t = int(time.time() if ts_s is None else ts_s) | |
| 40 | + return t - t % 60 | |
| 41 | + | |
| 42 | + | |
| 43 | +def usage_key(principal: str, minute: int) -> str: | |
| 44 | + return f"usage:{principal}:{minute}" | |
| 45 | + | |
| 46 | + | |
| 47 | +def record(principal: str, *, requests: int = 1, rows: int = 0, rows_parquet: int = 0, bytes_: int = 0, | |
| 48 | + status: int = 200, ts_s: float | None = None) -> None: | |
| 49 | + """Increment the live minute counters for a principal (fails silently when Redis is down).""" | |
| 50 | + minute = _minute(ts_s) | |
| 51 | + key = usage_key(principal, minute) | |
| 52 | + | |
| 53 | + def run(): | |
| 54 | + p = rl.client().pipeline(transaction=False) | |
| 55 | + if requests: | |
| 56 | + p.hincrby(key, "requests", int(requests)) | |
| 57 | + if rows: | |
| 58 | + p.hincrby(key, "rows", int(rows)) | |
| 59 | + if rows_parquet: | |
| 60 | + p.hincrby(key, "rows_parquet", int(rows_parquet)) | |
| 61 | + if bytes_: | |
| 62 | + p.hincrby(key, "bytes", int(bytes_)) | |
| 63 | + if 200 <= status < 300: | |
| 64 | + p.hincrby(key, "status_2xx", 1) | |
| 65 | + elif status == 429: | |
| 66 | + p.hincrby(key, "status_429", 1) | |
| 67 | + p.expire(key, TTL_S) | |
| 68 | + p.sadd(PENDING_SET, key) | |
| 69 | + p.execute() | |
| 70 | + return True | |
| 71 | + | |
| 72 | + rl._call(run) | |
| 73 | + | |
| 74 | + | |
| 75 | +def _parse_key(key: str) -> tuple[str, int] | None: | |
| 76 | + parts = key.split(":") | |
| 77 | + if len(parts) < 3 or parts[0] != "usage": | |
| 78 | + return None | |
| 79 | + try: | |
| 80 | + return ":".join(parts[1:-1]), int(parts[-1]) | |
| 81 | + except ValueError: | |
| 82 | + return None | |
| 83 | + | |
| 84 | + | |
| 85 | +def _live_minutes(principals: set[str] | None = None) -> dict[tuple[str, int], dict[str, int]]: | |
| 86 | + """Not-yet-folded minute counters from Redis (all principals, or a subset).""" | |
| 87 | + def run(): | |
| 88 | + c = rl.client() | |
| 89 | + out: dict[tuple[str, int], dict[str, int]] = {} | |
| 90 | + for raw in c.smembers(PENDING_SET): | |
| 91 | + key = raw.decode() if isinstance(raw, bytes) else raw | |
| 92 | + parsed = _parse_key(key) | |
| 93 | + if not parsed: | |
| 94 | + c.srem(PENDING_SET, key) | |
| 95 | + continue | |
| 96 | + principal, minute = parsed | |
| 97 | + if principals is not None and principal not in principals: | |
| 98 | + continue | |
| 99 | + h = c.hgetall(key) | |
| 100 | + if not h: | |
| 101 | + c.srem(PENDING_SET, key) | |
| 102 | + continue | |
| 103 | + out[(principal, minute)] = {(k.decode() if isinstance(k, bytes) else k): int(v) for k, v in h.items()} | |
| 104 | + return out | |
| 105 | + | |
| 106 | + res = rl._call(run) | |
| 107 | + return res or {} | |
| 108 | + | |
| 109 | + | |
| 110 | +def fold(now_s: float | None = None) -> int: | |
| 111 | + """Move finished minutes from Redis into usage_minute / usage_daily. Returns folded minutes count.""" | |
| 112 | + now = time.time() if now_s is None else now_s | |
| 113 | + current = _minute(now) | |
| 114 | + | |
| 115 | + def lock(): | |
| 116 | + return rl.client().set(FOLD_LOCK, "1", nx=True, ex=50) | |
| 117 | + | |
| 118 | + got = rl._call(lock) | |
| 119 | + if not got: | |
| 120 | + return 0 | |
| 121 | + folded = 0 | |
| 122 | + try: | |
| 123 | + live = _live_minutes() | |
| 124 | + done = [(k, v) for k, v in live.items() if k[1] < current] | |
| 125 | + if done: | |
| 126 | + with session() as s: | |
| 127 | + for (principal, minute), counters in done: | |
| 128 | + m_dt = datetime.fromtimestamp(minute, tz=timezone.utc).replace(tzinfo=None) | |
| 129 | + day = m_dt.date() | |
| 130 | + um = s.get(_models().UsageMinute, (m_dt, principal)) | |
| 131 | + if um is None: | |
| 132 | + um = _models().UsageMinute(minute=m_dt, principal=principal, requests=0, rows=0) | |
| 133 | + s.add(um) | |
| 134 | + um.requests += counters.get("requests", 0) | |
| 135 | + um.rows += counters.get("rows", 0) | |
| 136 | + ud = s.get(_models().UsageDaily, (day, principal)) | |
| 137 | + if ud is None: | |
| 138 | + ud = _models().UsageDaily(day=day, principal=principal) | |
| 139 | + s.add(ud) | |
| 140 | + for f in FIELDS: | |
| 141 | + setattr(ud, f, (getattr(ud, f) or 0) + counters.get(f, 0)) | |
| 142 | + folded += 1 | |
| 143 | + cutoff = datetime.fromtimestamp(current, tz=timezone.utc).replace(tzinfo=None) - timedelta(days=MINUTE_RETENTION_DAYS) | |
| 144 | + s.execute(delete(_models().UsageMinute).where(_models().UsageMinute.minute < cutoff)) | |
| 145 | + | |
| 146 | + def cleanup(): | |
| 147 | + p = rl.client().pipeline(transaction=False) | |
| 148 | + for (principal, minute), _ in done: | |
| 149 | + key = usage_key(principal, minute) | |
| 150 | + p.delete(key) | |
| 151 | + p.srem(PENDING_SET, key) | |
| 152 | + p.execute() | |
| 153 | + return True | |
| 154 | + | |
| 155 | + rl._call(cleanup) | |
| 156 | + except Exception as exc: # never take the API down because of accounting | |
| 157 | + log.exception("usage fold failed: %s", exc) | |
| 158 | + finally: | |
| 159 | + rl._call(lambda: rl.client().delete(FOLD_LOCK)) | |
| 160 | + return folded | |
| 161 | + | |
| 162 | + | |
| 163 | +def _models(): | |
| 164 | + from accounts import models | |
| 165 | + return models | |
| 166 | + | |
| 167 | + | |
| 168 | +async def _folder_loop(interval_s: int = 60) -> None: | |
| 169 | + while True: | |
| 170 | + try: | |
| 171 | + await asyncio.sleep(interval_s) | |
| 172 | + await asyncio.to_thread(fold) | |
| 173 | + except asyncio.CancelledError: # pragma: no cover | |
| 174 | + return | |
| 175 | + except Exception as exc: # pragma: no cover | |
| 176 | + log.warning("usage folder iteration failed: %s", exc) | |
| 177 | + | |
| 178 | + | |
| 179 | +def start_background(interval_s: int = 60) -> None: | |
| 180 | + """Schedule the folder on the running loop (idempotent per loop).""" | |
| 181 | + try: | |
| 182 | + loop = asyncio.get_running_loop() | |
| 183 | + except RuntimeError: | |
| 184 | + return | |
| 185 | + if id(loop) in _started_loops: | |
| 186 | + return | |
| 187 | + _started_loops.add(id(loop)) | |
| 188 | + loop.create_task(_folder_loop(interval_s), name="hfmd-usage-folder") | |
| 189 | + | |
| 190 | + | |
| 191 | +def usage_series(principals: list[str], range_: str, now_s: float | None = None) -> dict: | |
| 192 | + """Series + totals for one or several principals over 24h (per minute), 7d (per hour) or 30d (per day).""" | |
| 193 | + if range_ not in RANGES: | |
| 194 | + from core.errors import ApiError | |
| 195 | + raise ApiError(400, "INVALID_PARAMETER", "range must be one of 24h, 7d, 30d") | |
| 196 | + span, step = RANGES[range_] | |
| 197 | + now = time.time() if now_s is None else now_s | |
| 198 | + end = int(now) - int(now) % step + step # exclusive end of the last bucket | |
| 199 | + start_ts = end - int(span.total_seconds()) | |
| 200 | + start_dt = datetime.fromtimestamp(start_ts, tz=timezone.utc).replace(tzinfo=None) | |
| 201 | + buckets: dict[int, dict[str, int]] = defaultdict(lambda: {"requests": 0, "rows": 0}) | |
| 202 | + models = _models() | |
| 203 | + pset = set(principals) | |
| 204 | + if pset: | |
| 205 | + with session() as s: | |
| 206 | + if range_ == "30d": | |
| 207 | + rows = s.execute(select(models.UsageDaily.day, func.sum(models.UsageDaily.requests), func.sum(models.UsageDaily.rows)) | |
| 208 | + .where(models.UsageDaily.principal.in_(pset), models.UsageDaily.day >= start_dt.date()) | |
| 209 | + .group_by(models.UsageDaily.day)).all() | |
| 210 | + for day, req, rw in rows: | |
| 211 | + ts = int(datetime(day.year, day.month, day.day, tzinfo=timezone.utc).timestamp()) | |
| 212 | + buckets[ts]["requests"] += int(req or 0) | |
| 213 | + buckets[ts]["rows"] += int(rw or 0) | |
| 214 | + else: | |
| 215 | + rows = s.execute(select(models.UsageMinute.minute, models.UsageMinute.requests, models.UsageMinute.rows) | |
| 216 | + .where(models.UsageMinute.principal.in_(pset), models.UsageMinute.minute >= start_dt)).all() | |
| 217 | + for m, req, rw in rows: | |
| 218 | + ts = int(m.replace(tzinfo=timezone.utc).timestamp()) | |
| 219 | + ts -= ts % step | |
| 220 | + buckets[ts]["requests"] += int(req or 0) | |
| 221 | + buckets[ts]["rows"] += int(rw or 0) | |
| 222 | + for (_, minute), counters in _live_minutes(pset).items(): | |
| 223 | + if minute >= start_ts: | |
| 224 | + ts = minute - minute % step | |
| 225 | + buckets[ts]["requests"] += counters.get("requests", 0) | |
| 226 | + buckets[ts]["rows"] += counters.get("rows", 0) | |
| 227 | + points = [] | |
| 228 | + t = start_ts | |
| 229 | + while t < end: | |
| 230 | + b = buckets.get(t, {"requests": 0, "rows": 0}) | |
| 231 | + points.append({"t": datetime.fromtimestamp(t, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), | |
| 232 | + "requests": b["requests"], "rows": b["rows"]}) | |
| 233 | + t += step | |
| 234 | + totals = {"requests": sum(p["requests"] for p in points), "rows": sum(p["rows"] for p in points)} | |
| 235 | + return {"range": range_, "step_seconds": step, "from": points[0]["t"] if points else None, | |
| 236 | + "to": datetime.fromtimestamp(end, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), | |
| 237 | + "points": points, "totals": totals} | |
| 238 | + | |
| 239 | + | |
| 240 | +def top_principals(days: int = 7, limit: int = 20) -> list[dict]: | |
| 241 | + """Admin: heaviest principals over the last N days (folded data only).""" | |
| 242 | + models = _models() | |
| 243 | + since = (datetime.now(timezone.utc) - timedelta(days=days)).date() | |
| 244 | + with session() as s: | |
| 245 | + rows = s.execute(select(models.UsageDaily.principal, func.sum(models.UsageDaily.requests), | |
| 246 | + func.sum(models.UsageDaily.rows), func.sum(models.UsageDaily.status_429)) | |
| 247 | + .where(models.UsageDaily.day >= since).group_by(models.UsageDaily.principal) | |
| 248 | + .order_by(func.sum(models.UsageDaily.requests).desc()).limit(limit)).all() | |
| 249 | + return [{"principal": p, "requests": int(r or 0), "rows": int(w or 0), "status_429": int(e or 0)} for p, r, w, e in rows] | |
| 250 | + | |
| 251 | + | |
| 252 | +def totals_per_day(days: int = 30) -> list[dict]: | |
| 253 | + models = _models() | |
| 254 | + since = (datetime.now(timezone.utc) - timedelta(days=days)).date() | |
| 255 | + with session() as s: | |
| 256 | + rows = s.execute(select(models.UsageDaily.day, func.sum(models.UsageDaily.requests), func.sum(models.UsageDaily.rows), | |
| 257 | + func.sum(models.UsageDaily.rows_parquet), func.sum(models.UsageDaily.status_429), | |
| 258 | + func.count(func.distinct(models.UsageDaily.principal))) | |
| 259 | + .where(models.UsageDaily.day >= since).group_by(models.UsageDaily.day).order_by(models.UsageDaily.day)).all() | |
| 260 | + return [{"day": d.isoformat(), "requests": int(r or 0), "rows": int(w or 0), "rows_parquet": int(pq or 0), | |
| 261 | + "status_429": int(e or 0), "principals": int(n or 0)} for d, r, w, pq, e, n in rows] | |
| 262 | ||