ratelimit: principal `site` — les requêtes de données du site (/charts) sont hors quota (3 critères cumulés X-HFMD-Client + Sec-Fetch-Site + Origin), max_rows 200 000, garde anti-abus 1 200 req/min/IP, stats sous `site`
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +336 −7
modified
docs/accounts-ratelimit.md
+32 −0
@@ -59,6 +59,38 @@ Other quota-related errors: `400 ROW_LIMIT_EXCEEDED` when `limit` exceeds the ti | ||
| 59 | 59 | (`details.max_rows`), `401 INVALID_API_KEY` (unknown/revoked key — the request is NOT downgraded to keyless), |
| 60 | 60 | `403 ACCOUNT_DISABLED`, `401 AUTH_REQUIRED`. |
| 61 | 61 | |
| 62 | +### The site's own requests (`/charts`) have no quota — principal `site` | |
| 63 | + | |
| 64 | +The charts page must work without an account and **without any limit**. The middleware recognises the | |
| 65 | +site's own data requests and takes them out of the quota system entirely (`ratelimit/middleware.py`, | |
| 66 | +`is_site_request`). A request is the site's own when **all three** hold, on a **GET** of a charts data path | |
| 67 | +(`/v1/bars/*`, `/v1/futures/*`, `/v1/{asset}/tickers`, `/v1/options/*`): | |
| 68 | + | |
| 69 | +1. `X-HFMD-Client: charts` (or `web`) — set by the page's data layer (`web/src/charts/data/*.js`); | |
| 70 | +2. `Sec-Fetch-Site: same-origin` (or `same-site`) — set by the browser, never by a `fetch()` from another origin; | |
| 71 | +3. `Origin` (or `Referer`) whose host is the host of `HFMD_PUBLIC_URL` (with/without `www.`); `localhost`, | |
| 72 | + `127.0.0.1`, `::1` are also accepted when `HFMD_ENV != production`. | |
| 73 | + | |
| 74 | +A Bearer key / `?api_key=` always wins (the caller asked for its own principal). Anything short of the three | |
| 75 | +criteria — a `curl`, a third-party site, a missing header — is a regular keyless / key / session request. | |
| 76 | + | |
| 77 | +What changes for a `site` request: | |
| 78 | + | |
| 79 | +* **not counted, not row-charged, never 429 for quota reasons**; no `X-RateLimit-*` header at all; | |
| 80 | +* `request.state.principal = "site"`, `principal_kind = "site"`, `max_rows = 200 000` | |
| 81 | + (`tiers.SITE_MAX_ROWS`; the page asks for 5 000–20 000 bars at once; the JSON ceiling of the legacy bars | |
| 82 | + endpoint, 50 000, still applies through `bound_limit`); | |
| 83 | +* usage is recorded under the `site` principal for internal statistics (requests, rows, bytes) — **never under | |
| 84 | + the visitor's IP**, whose keyless counters stay untouched; | |
| 85 | +* the only guard is a **DoS protection**, not a product limit: `tiers.SITE_BURST_PER_MINUTE_PER_IP` = 1 200 | |
| 86 | + requests per minute per IP on this path (Redis sliding window, fail-open). Above it the API answers | |
| 87 | + `429 RATE_LIMIT_EXCEEDED` with a dedicated message ("an abuse guard, not a quota: the charts have no data | |
| 88 | + limit"), `Retry-After`, `details.guard = "site_burst"` and no `X-RateLimit-*` header. A human on the page | |
| 89 | + cannot reach it (a full 1-minute history load is ~10 requests). | |
| 90 | + | |
| 91 | +`site` is not a tier: it is absent from `/v1/limits` (which, not being a data path, still answers as keyless / | |
| 92 | +key / session). Tests: `tests/test_ratelimit_site.py`. | |
| 93 | + | |
| 62 | 94 | ## 3. Authentication model |
| 63 | 95 | |
| 64 | 96 | * **API key**: `Authorization: Bearer hfmd_live_…` or `?api_key=…` — for data endpoints and programmatic |
modified
hfmarketdata/api/ratelimit/middleware.py
+103 −7
@@ -21,23 +21,33 @@ value is the N-th hop from the END (`HFMD_TRUSTED_PROXY_HOPS`, default 1), never | ||
| 21 | 21 | controls. The header is ignored altogether when the direct peer is not a loopback / private address. |
| 22 | 22 | |
| 23 | 23 | A signed-in browser (session cookie, no Bearer) is the `user:<id>` principal with the account's tier — the |
| 24 | −site (charts, playground) gets account limits without pasting a key. Account endpoints (`/v1/limits`, `/v1/me/*`, | |
| 24 | +site (playground) gets account limits without pasting a key. Account endpoints (`/v1/limits`, `/v1/me/*`, | |
| 25 | 25 | `/v1/admin/*`) are not charged. `/health`, `/openapi.json` |
| 26 | 26 | and the SPA are exempt. Redis down → data endpoints fail open (no headers), the API never goes down because of |
| 27 | 27 | quotas. |
| 28 | 28 | |
| 29 | +Site principal (`site`, kind "site") — the /charts page has NO quota. A data GET (`/v1/bars/*`, `/v1/futures/*`, | |
| 30 | +`/v1/{asset}/tickers`, `/v1/options/*`) is the site's own when ALL of: `X-HFMD-Client: charts|web`, | |
| 31 | +`Sec-Fetch-Site: same-origin|same-site`, and an `Origin` (or `Referer`) whose host is `settings.public_url`'s | |
| 32 | +(localhost outside production). Such requests are not counted, not row-charged, never 429 for quota reasons and | |
| 33 | +carry no `X-RateLimit-*` header; `request.state.max_rows` is `SITE_MAX_ROWS`; usage is recorded under the | |
| 34 | +`site` principal (internal stats), never under the visitor's IP. The one guard is `SITE_BURST_PER_MINUTE_PER_IP` | |
| 35 | +(DoS protection, fail-open, dedicated 429 message). Anything short of the three criteria is a regular request. | |
| 36 | + | |
| 29 | 37 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 30 | 38 | """ |
| 31 | 39 | from __future__ import annotations |
| 32 | 40 | |
| 33 | 41 | import ipaddress |
| 34 | 42 | import logging |
| 43 | +import re | |
| 35 | 44 | import threading |
| 36 | 45 | import time |
| 37 | 46 | from collections import OrderedDict |
| 38 | 47 | from dataclasses import dataclass |
| 39 | 48 | from datetime import datetime, timezone |
| 40 | 49 | from typing import Any |
| 50 | +from urllib.parse import urlsplit | |
| 41 | 51 | |
| 42 | 52 | from fastapi import FastAPI |
| 43 | 53 | from starlette.datastructures import Headers, MutableHeaders, QueryParams |
@@ -48,9 +58,11 @@ from core.config import settings | ||
| 48 | 58 | from core.errors import ApiError |
| 49 | 59 | |
| 50 | 60 | from . import redis_limiter as rl |
| 61 | +from . import tiers as _tiers | |
| 51 | 62 | from . import usage |
| 52 | 63 | from .tiers import (AUTH_DEFAULT_LIMIT, AUTH_FAIL_CLOSED, AUTH_FAIL_CLOSED_RETRY_S, AUTH_LIMITS, AUTH_WINDOW_S, |
| 53 | − KEY_LOOKUPS_PER_MINUTE_PER_IP, KEY_REQUIRED_TAGS, TIERS, Tier, row_cost, tier_for, upgrade_hint) | |
| 64 | + KEY_LOOKUPS_PER_MINUTE_PER_IP, KEY_REQUIRED_TAGS, SITE_TIER, TIERS, Tier, row_cost, tier_for, | |
| 65 | + upgrade_hint) | |
| 54 | 66 | |
| 55 | 67 | log = logging.getLogger("hfmarketdata.ratelimit") |
| 56 | 68 | |
@@ -62,6 +74,15 @@ UNCHARGED_PREFIXES = ("/v1/limits", "/v1/me", "/v1/admin") | ||
| 62 | 74 | AUTH_PREFIX = "/v1/auth/" |
| 63 | 75 | AUTH_UNTHROTTLED = ("/v1/auth/logout",) |
| 64 | 76 | |
| 77 | +# Site principal (charts): the three cumulative criteria + the data paths they apply to. | |
| 78 | +SITE_PRINCIPAL = "site" | |
| 79 | +SITE_CLIENT_HEADER = "x-hfmd-client" | |
| 80 | +SITE_CLIENTS = frozenset({"charts", "web"}) | |
| 81 | +SITE_FETCH_SITES = frozenset({"same-origin", "same-site"}) | |
| 82 | +SITE_DATA_PREFIXES = ("/v1/bars/", "/v1/futures/", "/v1/options/") | |
| 83 | +SITE_TICKERS_RE = re.compile(r"^/v1/[a-z]+/tickers$") | |
| 84 | +SITE_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"}) | |
| 85 | + | |
| 65 | 86 | |
| 66 | 87 | # ---------------------------------------------------------------------------------------------------- |
| 67 | 88 | # Principal resolution |
@@ -289,6 +310,62 @@ def resolve_principal(scope: dict) -> Principal: | ||
| 289 | 310 | return Principal(f"ip:{hash_ip(ip)}", "keyless", TIERS["keyless"]) |
| 290 | 311 | |
| 291 | 312 | |
| 313 | +# ---------------------------------------------------------------------------------------------------- | |
| 314 | +# Site principal (the /charts page has no quota) | |
| 315 | +# ---------------------------------------------------------------------------------------------------- | |
| 316 | + | |
| 317 | +def _site_hosts() -> frozenset[str]: | |
| 318 | + """Hosts accepted in Origin / Referer: the public host with and without `www.`, plus localhost outside production.""" | |
| 319 | + host = (urlsplit(settings.public_url).hostname or "").lower() | |
| 320 | + hosts = {host} if host else set() | |
| 321 | + if host.startswith("www."): | |
| 322 | + hosts.add(host[4:]) | |
| 323 | + elif host: | |
| 324 | + hosts.add("www." + host) | |
| 325 | + if settings.env != "production": | |
| 326 | + hosts |= SITE_LOCAL_HOSTS | |
| 327 | + return frozenset(hosts) | |
| 328 | + | |
| 329 | + | |
| 330 | +def is_site_data_path(path: str) -> bool: | |
| 331 | + return path.startswith(SITE_DATA_PREFIXES) or bool(SITE_TICKERS_RE.match(path)) | |
| 332 | + | |
| 333 | + | |
| 334 | +def is_site_request(scope: dict, headers: Headers) -> bool: | |
| 335 | + """True when a data GET carries the three cumulative proofs of being issued by the site itself. | |
| 336 | + | |
| 337 | + A Bearer key (or `?api_key=`) always wins: the caller explicitly asked for its own principal.""" | |
| 338 | + if scope.get("method", "GET").upper() != "GET" or not is_site_data_path(scope.get("path", "")): | |
| 339 | + return False | |
| 340 | + if headers.get(SITE_CLIENT_HEADER, "").strip().lower() not in SITE_CLIENTS: | |
| 341 | + return False | |
| 342 | + if headers.get("sec-fetch-site", "").strip().lower() not in SITE_FETCH_SITES: | |
| 343 | + return False | |
| 344 | + if extract_key(headers, QueryParams(scope.get("query_string", b""))): | |
| 345 | + return False | |
| 346 | + origin = headers.get("origin") or headers.get("referer") or "" | |
| 347 | + try: | |
| 348 | + host = (urlsplit(origin).hostname or "").lower() | |
| 349 | + except ValueError: | |
| 350 | + return False | |
| 351 | + return bool(host) and host in _site_hosts() | |
| 352 | + | |
| 353 | + | |
| 354 | +def site_guard(ip: str, *, now_s: int) -> ApiError | None: | |
| 355 | + """Per-IP burst guard on the site path (DoS protection, not a product limit). Fail-open when Redis is down.""" | |
| 356 | + limit = _tiers.SITE_BURST_PER_MINUTE_PER_IP | |
| 357 | + d = rl.throttle(f"site:ip:{hash_ip(ip)}", limit=limit, window_s=60) | |
| 358 | + if d is None or d.allowed_requests: | |
| 359 | + return None | |
| 360 | + retry = max(1, d.reset_requests - now_s) | |
| 361 | + return ApiError(429, "RATE_LIMIT_EXCEEDED", | |
| 362 | + f"Too many chart requests from this address ({limit} per minute — an abuse guard, not a quota: " | |
| 363 | + f"the charts have no data limit). Retry in {retry} s.", | |
| 364 | + type="requests_per_minute", headers={"Retry-After": str(retry)}, | |
| 365 | + details={"limit": limit, "window_seconds": 60, "reset": d.reset_requests, "principal": SITE_PRINCIPAL, | |
| 366 | + "guard": "site_burst"}) | |
| 367 | + | |
| 368 | + | |
| 292 | 369 | # ---------------------------------------------------------------------------------------------------- |
| 293 | 370 | # Helpers |
| 294 | 371 | # ---------------------------------------------------------------------------------------------------- |
@@ -406,6 +483,21 @@ class RateLimitMiddleware: | ||
| 406 | 483 | ip = client_ip(scope, headers) |
| 407 | 484 | state["client_ip_hash"] = hash_ip(ip) |
| 408 | 485 | |
| 486 | + # the site's own data requests (charts): no quota, no headers, burst guard only, accounted under `site` | |
| 487 | + if is_site_request(scope, headers): | |
| 488 | + err = site_guard(ip, now_s=now_s) | |
| 489 | + if err is not None: | |
| 490 | + usage.record(SITE_PRINCIPAL, requests=0, status=429) | |
| 491 | + return await err.response()(scope, receive, send) | |
| 492 | + principal = Principal(SITE_PRINCIPAL, "site", SITE_TIER) | |
| 493 | + state.update(principal=principal.id, principal_kind=principal.kind, tier=SITE_TIER.name, | |
| 494 | + max_rows=SITE_TIER.max_rows_per_request, api_key_id=None, user_id=None) | |
| 495 | + if route_tags(scope) & KEY_REQUIRED_TAGS: | |
| 496 | + return await _auth_required_error().response()(scope, receive, send) | |
| 497 | + state["ratelimit"] = {"principal": principal.id, "kind": principal.kind, "tier": SITE_TIER.name, "exempt": True, | |
| 498 | + "max_rows_per_request": SITE_TIER.max_rows_per_request} | |
| 499 | + return await self._forward(scope, receive, send, principal, SITE_TIER, None, charged=False, account_as=SITE_PRINCIPAL) | |
| 500 | + | |
| 409 | 501 | try: |
| 410 | 502 | principal = resolve_principal(scope) |
| 411 | 503 | except ApiError as exc: |
@@ -439,7 +531,8 @@ class RateLimitMiddleware: | ||
| 439 | 531 | return await self._forward(scope, receive, send, principal, tier, decision, charged=charged) |
| 440 | 532 | |
| 441 | 533 | async def _forward(self, scope, receive, send, principal: Principal, tier: Tier, decision: rl.Decision | None, |
| 442 | − *, charged: bool, extra_headers: dict[str, str] | None = None): | |
| 534 | + *, charged: bool, extra_headers: dict[str, str] | None = None, account_as: str | None = None): | |
| 535 | + """`account_as`: record usage under that principal even though the request is not charged (site stats).""" | |
| 443 | 536 | state: dict = scope["state"] |
| 444 | 537 | started = False |
| 445 | 538 | replaced = False |
@@ -449,7 +542,7 @@ class RateLimitMiddleware: | ||
| 449 | 542 | nonlocal started, replaced, final |
| 450 | 543 | if message["type"] == "http.response.start": |
| 451 | 544 | started = True |
| 452 | − if principal.keyless and state.get("requires_key"): | |
| 545 | + if (principal.keyless or principal.kind == "site") and state.get("requires_key"): | |
| 453 | 546 | replaced = True |
| 454 | 547 | err = _auth_required_error() |
| 455 | 548 | for k, v in (final.headers() if final else {}).items(): |
@@ -475,13 +568,16 @@ class RateLimitMiddleware: | ||
| 475 | 568 | headers[k] = v |
| 476 | 569 | for k, v in (extra_headers or {}).items(): |
| 477 | 570 | headers[k] = v |
| 478 | − if charged: | |
| 571 | + if charged or account_as: | |
| 479 | 572 | try: |
| 480 | 573 | size = int(headers.get("content-length") or 0) |
| 481 | 574 | except ValueError: |
| 482 | 575 | size = 0 |
| 483 | − usage.record(principal.id, requests=1 + extra_req, rows=cost, rows_parquet=cost if is_parquet else 0, | |
| 484 | − bytes_=size, status=status) | |
| 576 | + if charged: | |
| 577 | + usage.record(principal.id, requests=1 + extra_req, rows=cost, rows_parquet=cost if is_parquet else 0, | |
| 578 | + bytes_=size, status=status) | |
| 579 | + else: # site: internal statistics only (rows as served, nothing is charged) | |
| 580 | + usage.record(account_as, requests=1, rows=rows if 200 <= status < 300 else 0, bytes_=size, status=status) | |
| 485 | 581 | elif replaced: |
| 486 | 582 | return |
| 487 | 583 | await send(message) |
modified
hfmarketdata/api/ratelimit/tiers.py
+10 −0
@@ -13,6 +13,11 @@ Cost rules: | ||
| 13 | 13 | * keyless principals cannot use endpoints tagged `stream` or `screener`, nor endpoints that set |
| 14 | 14 | `request.state.requires_key = True` (401 AUTH_REQUIRED). |
| 15 | 15 | |
| 16 | +Site principal (`site`): data GET requests issued by the web site itself (the /charts page — see | |
| 17 | +`middleware.is_site_request`) are NOT a tier: no request / row quota, no `X-RateLimit-*` headers, up to | |
| 18 | +`SITE_MAX_ROWS` rows per request. The only guard is `SITE_BURST_PER_MINUTE_PER_IP`, a DoS protection invisible to a | |
| 19 | +human user, not a product limit. | |
| 20 | + | |
| 16 | 21 | Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 17 | 22 | """ |
| 18 | 23 | from __future__ import annotations |
@@ -76,6 +81,11 @@ ACCOUNT_TIERS = ("free", "high_usage", "unlimited") | ||
| 76 | 81 | INTERNAL_TIERS = ("unlimited",) |
| 77 | 82 | DEFAULT_ACCOUNT_TIER = "free" |
| 78 | 83 | |
| 84 | +# The site's own data requests (charts): not in TIERS on purpose — never listed by /v1/limits, never charged. | |
| 85 | +SITE_MAX_ROWS = 200_000 | |
| 86 | +SITE_BURST_PER_MINUTE_PER_IP = 1200 # DoS guard only; a human on /charts stays far below (fail-open) | |
| 87 | +SITE_TIER = Tier("site", "site", 60, 1_000_000_000, 1_000_000_000_000, SITE_MAX_ROWS) | |
| 88 | + | |
| 79 | 89 | |
| 80 | 90 | def tier_for(name: str | None) -> Tier: |
| 81 | 91 | return TIERS.get(name or "", TIERS["free"]) |
added
tests/test_ratelimit_site.py
+191 −0
@@ -0,0 +1,191 @@ | ||
| 1 | +"""Site principal: the /charts page has no quota (ratelimit/middleware.py `is_site_request`). | |
| 2 | + | |
| 3 | +Three cumulative criteria (`X-HFMD-Client`, `Sec-Fetch-Site`, same-host `Origin`/`Referer`) on data GETs → | |
| 4 | +principal `site`: no X-RateLimit-* headers, no 429 for quota reasons, max_rows 200 000, usage under `site` | |
| 5 | +(never under the visitor's IP), and a per-IP burst guard that is a DoS protection, not a product limit. | |
| 6 | +""" | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +from dataclasses import replace | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | +from fastapi import FastAPI, Request # module level: `from __future__ import annotations` + a fixture-local import | |
| 13 | + # would leave `Request` unresolvable in the echo handler (→ query param, 422) | |
| 14 | + | |
| 15 | +SITE = {"X-HFMD-Client": "charts", "Sec-Fetch-Site": "same-origin", "Origin": "https://www.hfmarketdata.io"} | |
| 16 | +RL_HDRS = ("X-RateLimit-Limit-Requests", "X-RateLimit-Remaining-Requests", "X-RateLimit-Limit-Rows", | |
| 17 | + "X-RateLimit-Remaining-Rows", "X-RateLimit-Reset") | |
| 18 | + | |
| 19 | + | |
| 20 | +def ip(n: int) -> dict: | |
| 21 | + return {"X-Forwarded-For": f"172.16.0.1, 10.0.0.{n}"} | |
| 22 | + | |
| 23 | + | |
| 24 | +def site(n: int, **over) -> dict: | |
| 25 | + return {**ip(n), **SITE, **over} | |
| 26 | + | |
| 27 | + | |
| 28 | +def no_rl_headers(r) -> bool: | |
| 29 | + return not any(h in r.headers for h in RL_HDRS) | |
| 30 | + | |
| 31 | + | |
| 32 | +# ---------------------------------------------------------------------------------------------------- | |
| 33 | +# Full app (synthetic lake) | |
| 34 | +# ---------------------------------------------------------------------------------------------------- | |
| 35 | + | |
| 36 | +def test_site_requests_are_exempt_and_leave_the_ip_counters_untouched(client): | |
| 37 | + for _ in range(31): # keyless would 429 after 30 | |
| 38 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=5", headers=site(101)) | |
| 39 | + assert r.status_code == 200, r.text | |
| 40 | + assert no_rl_headers(r) | |
| 41 | + assert r.headers["X-Row-Count"] == "5" | |
| 42 | + # the same IP, as a plain keyless client, still has its full hourly budget | |
| 43 | + r = client.get("/v1/status", headers=ip(101)) | |
| 44 | + assert r.status_code == 200 and r.headers["X-RateLimit-Remaining-Requests"] == "29" | |
| 45 | + assert r.headers["X-RateLimit-Remaining-Rows"] == "100000" | |
| 46 | + | |
| 47 | + | |
| 48 | +@pytest.mark.parametrize("path", ["/v1/stock/tickers?limit=3", "/v1/futures/roots", "/v1/options/quarters", | |
| 49 | + "/v1/bars/stock/AAPL?timeframe=1min&limit=10"]) | |
| 50 | +def test_every_charts_data_path_is_covered(client, path): | |
| 51 | + r = client.get(path, headers=site(102)) | |
| 52 | + assert r.status_code == 200, r.text | |
| 53 | + assert no_rl_headers(r) | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_web_client_label_and_referer_fallback_are_accepted(client): | |
| 57 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=2", headers=site(103, **{"X-HFMD-Client": "web"})) | |
| 58 | + assert r.status_code == 200 and no_rl_headers(r) | |
| 59 | + hdrs = {**ip(103), "X-HFMD-Client": "charts", "Sec-Fetch-Site": "same-site", "Referer": "https://hfmarketdata.io/charts?s=AAPL"} | |
| 60 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=2", headers=hdrs) | |
| 61 | + assert r.status_code == 200 and no_rl_headers(r) | |
| 62 | + | |
| 63 | + | |
| 64 | +@pytest.mark.parametrize("missing", [ | |
| 65 | + {"Sec-Fetch-Site": None}, # no Sec-Fetch-Site → keyless | |
| 66 | + {"Sec-Fetch-Site": "cross-site"}, | |
| 67 | + {"Origin": "https://evil.example"}, # wrong host | |
| 68 | + {"Origin": None}, # no Origin, no Referer | |
| 69 | + {"X-HFMD-Client": None}, | |
| 70 | + {"X-HFMD-Client": "curl"}, | |
| 71 | +]) | |
| 72 | +def test_any_missing_criterion_falls_back_to_keyless(client, missing): | |
| 73 | + hdrs = {**ip(104), **SITE} | |
| 74 | + for k, v in missing.items(): | |
| 75 | + if v is None: | |
| 76 | + hdrs.pop(k) | |
| 77 | + else: | |
| 78 | + hdrs[k] = v | |
| 79 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=2", headers=hdrs) | |
| 80 | + assert r.status_code == 200 | |
| 81 | + assert r.headers["X-RateLimit-Limit-Requests"] == "30", dict(r.headers) | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_site_headers_on_a_non_data_path_or_a_non_get_are_ignored(client): | |
| 85 | + r = client.get("/v1/status", headers=site(105)) | |
| 86 | + assert r.headers["X-RateLimit-Limit-Requests"] == "30" # /v1/status is not a charts data path | |
| 87 | + r = client.get("/v1/limits", headers=site(105)) | |
| 88 | + assert r.json()["data"]["principal"]["kind"] == "keyless" | |
| 89 | + | |
| 90 | + | |
| 91 | +def test_bearer_key_wins_over_the_site_criteria(client, make_user): | |
| 92 | + _, key, _, _ = make_user() | |
| 93 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=2", headers=site(106, Authorization=f"Bearer {key}")) | |
| 94 | + assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120" | |
| 95 | + | |
| 96 | + | |
| 97 | +def test_usage_is_recorded_under_site_not_under_the_ip(client): | |
| 98 | + from ratelimit import middleware, usage | |
| 99 | + client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=7", headers=site(107)) | |
| 100 | + live = usage._live_minutes() | |
| 101 | + by_principal: dict[str, dict] = {} | |
| 102 | + for (principal, _minute), fields in live.items(): | |
| 103 | + acc = by_principal.setdefault(principal, {}) | |
| 104 | + for k, v in fields.items(): | |
| 105 | + acc[k] = acc.get(k, 0) + v | |
| 106 | + assert by_principal["site"]["requests"] >= 1 and by_principal["site"]["rows"] >= 7 | |
| 107 | + assert f"ip:{middleware.hash_ip('10.0.0.107')}" not in by_principal | |
| 108 | + | |
| 109 | + | |
| 110 | +def test_burst_guard_is_a_dedicated_429_without_ratelimit_headers(client, monkeypatch): | |
| 111 | + from ratelimit import tiers | |
| 112 | + monkeypatch.setattr(tiers, "SITE_BURST_PER_MINUTE_PER_IP", 3) | |
| 113 | + for _ in range(3): | |
| 114 | + assert client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=1", headers=site(108)).status_code == 200 | |
| 115 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=1", headers=site(108)) | |
| 116 | + assert r.status_code == 429 | |
| 117 | + body = r.json()["error"] | |
| 118 | + assert body["code"] == "RATE_LIMIT_EXCEEDED" and body["details"]["guard"] == "site_burst" | |
| 119 | + assert "not a quota" in body["message"] and "free account" not in body["message"] | |
| 120 | + assert int(r.headers["Retry-After"]) >= 1 and no_rl_headers(r) | |
| 121 | + # another IP is unaffected, and the guarded IP keeps its keyless budget intact | |
| 122 | + assert client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=1", headers=site(109)).status_code == 200 | |
| 123 | + assert client.get("/v1/status", headers=ip(108)).headers["X-RateLimit-Remaining-Requests"] == "29" | |
| 124 | + | |
| 125 | + | |
| 126 | +def test_burst_guard_fails_open_when_redis_is_down(client, monkeypatch): | |
| 127 | + from ratelimit import redis_limiter as rl | |
| 128 | + from ratelimit import tiers | |
| 129 | + monkeypatch.setattr(tiers, "SITE_BURST_PER_MINUTE_PER_IP", 1) | |
| 130 | + monkeypatch.setattr(rl, "throttle", lambda *a, **k: None) | |
| 131 | + for _ in range(3): | |
| 132 | + r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=1", headers=site(110)) | |
| 133 | + assert r.status_code == 200 and no_rl_headers(r) | |
| 134 | + | |
| 135 | + | |
| 136 | +# ---------------------------------------------------------------------------------------------------- | |
| 137 | +# Minimal app: what the handler sees (max_rows, principal), localhost rule | |
| 138 | +# ---------------------------------------------------------------------------------------------------- | |
| 139 | + | |
| 140 | +@pytest.fixture | |
| 141 | +def mini(app): | |
| 142 | + """Tiny FastAPI app with only the rate-limit middleware, echoing request.state on a charts data path.""" | |
| 143 | + from fastapi.testclient import TestClient | |
| 144 | + | |
| 145 | + from core.responses import json_response | |
| 146 | + from ratelimit import middleware | |
| 147 | + | |
| 148 | + m = FastAPI() | |
| 149 | + | |
| 150 | + def echo(request: Request, asset: str, ticker: str): | |
| 151 | + st = request.state | |
| 152 | + return json_response({"principal": st.principal, "kind": st.principal_kind, "max_rows": st.max_rows, | |
| 153 | + "ratelimit": st.ratelimit}) | |
| 154 | + | |
| 155 | + m.add_api_route("/v1/bars/{asset}/{ticker}", echo, methods=["GET"]) | |
| 156 | + middleware.install(m) | |
| 157 | + with TestClient(m) as c: | |
| 158 | + yield c | |
| 159 | + | |
| 160 | + | |
| 161 | +def test_site_state_max_rows_and_principal(mini): | |
| 162 | + r = mini.get("/v1/bars/stock/AAPL", headers=site(111)) | |
| 163 | + assert r.status_code == 200 | |
| 164 | + body = r.json()["data"] | |
| 165 | + assert body == {"principal": "site", "kind": "site", "max_rows": 200_000, | |
| 166 | + "ratelimit": {"principal": "site", "kind": "site", "tier": "site", "exempt": True, "max_rows_per_request": 200_000}} | |
| 167 | + r = mini.get("/v1/bars/stock/AAPL", headers=ip(111)).json()["data"] | |
| 168 | + assert r["kind"] == "keyless" and r["max_rows"] == 5000 | |
| 169 | + | |
| 170 | + | |
| 171 | +def test_localhost_origin_only_outside_production(mini, monkeypatch): | |
| 172 | + from ratelimit import middleware | |
| 173 | + dev = site(112, Origin="http://localhost:5173") | |
| 174 | + assert mini.get("/v1/bars/stock/AAPL", headers=dev).json()["data"]["kind"] == "site" # HFMD_ENV=test | |
| 175 | + monkeypatch.setattr(middleware, "settings", replace(middleware.settings, env="production")) | |
| 176 | + assert mini.get("/v1/bars/stock/AAPL", headers=dev).json()["data"]["kind"] == "keyless" | |
| 177 | + assert mini.get("/v1/bars/stock/AAPL", headers=site(112)).json()["data"]["kind"] == "site" # public host still fine | |
| 178 | + | |
| 179 | + | |
| 180 | +def test_is_site_request_unit(): | |
| 181 | + from starlette.datastructures import Headers | |
| 182 | + | |
| 183 | + from ratelimit.middleware import is_site_data_path, is_site_request | |
| 184 | + assert is_site_data_path("/v1/bars/stock/AAPL") and is_site_data_path("/v1/fx/tickers") and is_site_data_path("/v1/futures/ES/contracts") | |
| 185 | + assert not is_site_data_path("/v1/status") and not is_site_data_path("/v1/fundamentals/screen") and not is_site_data_path("/v1/stock/tickers/x") | |
| 186 | + scope = {"method": "GET", "path": "/v1/bars/stock/AAPL", "query_string": b""} | |
| 187 | + good = Headers({"x-hfmd-client": "charts", "sec-fetch-site": "same-origin", "origin": "https://www.hfmarketdata.io"}) | |
| 188 | + assert is_site_request(scope, good) | |
| 189 | + assert not is_site_request({**scope, "method": "POST"}, good) | |
| 190 | + assert not is_site_request({**scope, "query_string": b"api_key=hfmd_live_x"}, good) | |
| 191 | + assert not is_site_request(scope, Headers({"x-hfmd-client": "charts", "sec-fetch-site": "same-origin", "origin": "https://www.hfmarketdata.io.evil.example"})) | |
| 192 | ||