Files
trumpsignal-backend/app/api/prices.py
T
k 520bd7243d fix(BUG-02): rate limiter now keys on real client IP + enforces default_limits
The frontend proxy fix alone was incomplete. Backend slowapi used the default
get_remote_address (request.client.host), which is the proxy's IP because
uvicorn runs without --proxy-headers — so the relayed x-forwarded-for was
ignored and all users still shared one rate-limit bucket.

- Add app/ratelimit.py: shared `limiter` + `client_ip_key` that reads
  x-forwarded-for[0] → x-real-ip → peer. Replaces the three independent
  Limiter(get_remote_address) instances in main.py / posts.py / prices.py
  (which also had separate, non-shared storage).
- Register SlowAPIMiddleware so default_limits ("60/minute") applies to EVERY
  route. Previously only the 2 decorated read endpoints were limited; all
  signed-mutation routes had no rate limit at all (the "20/min per-route"
  comment was aspirational — no such decorator existed).
- Add tests/test_ratelimit.py (7 tests): XFF precedence, fallbacks, two users
  behind one proxy get distinct keys, middleware-registered guard.

72 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:55:00 +08:00

85 lines
2.7 KiB
Python

import logging
from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from app.ratelimit import limiter
from app.config import settings
from app.schemas import Candle
from app.services.price_store import price_store
router = APIRouter()
logger = logging.getLogger(__name__)
VALID_ASSETS = {"BTC", "ETH"}
VALID_TIMEFRAMES = {"5m", "15m", "1H", "4H", "1D", "1W"}
BINANCE_INTERVAL = {
"5m": "5m",
"15m": "15m",
"1H": "1h",
"4H": "4h",
"1D": "1d",
"1W": "1w",
}
SYMBOL_MAP = {"BTC": "BTCUSDT", "ETH": "ETHUSDT"}
async def fetch_binance_candles(asset: str, tf: str, limit: int) -> List[Candle]:
symbol = SYMBOL_MAP[asset]
interval = BINANCE_INTERVAL[tf]
url = f"{settings.binance_rest_url}/api/v3/klines?symbol={symbol}&interval={interval}&limit={limit}"
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
rows = resp.json()
return [
Candle(
time=row[0] // 1000, # ms → seconds
open=float(row[1]),
high=float(row[2]),
low=float(row[3]),
close=float(row[4]),
volume=float(row[5]),
)
for row in rows
]
@router.get("/prices/{asset}", response_model=List[Candle])
@limiter.limit("30/minute")
async def get_prices(
request: Request,
asset: str,
tf: str = Query(default="4H"),
limit: int = Query(default=200, ge=1, le=1000),
response: Response = None,
):
asset = asset.upper()
if asset not in VALID_ASSETS:
raise HTTPException(status_code=400, detail=f"Asset must be one of {VALID_ASSETS}")
if tf not in VALID_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"Timeframe must be one of {VALID_TIMEFRAMES}")
# 1m: use in-memory store (updated in real-time) — don't cache at CDN level
if tf == "1m":
candles = price_store.get_candles(asset, "1m", limit)
if response is not None:
response.headers["Cache-Control"] = "public, max-age=5, stale-while-revalidate=10"
return [Candle(**c) for c in candles]
# Larger timeframes change infrequently — safe to cache longer
if response is not None:
ttl = 60 if tf in ("5m", "15m") else 300 # 1m for short TFs, 5m for 1H+
response.headers["Cache-Control"] = f"public, max-age={ttl}, stale-while-revalidate={ttl * 2}"
try:
return await fetch_binance_candles(asset, tf, limit)
except Exception as exc:
logger.error("Binance REST fetch failed for %s %s: %s", asset, tf, exc)
raise HTTPException(status_code=502, detail="Failed to fetch price data")