from __future__ import annotations import time from dataclasses import dataclass, field from typing import Dict, Tuple from fastapi import HTTPException, Request @dataclass class FixedWindowRateLimiter: """ 固定窗口限流(内存版)。 约束: - 适用于单进程/单实例;多进程/多实例下不共享计数(V1 可接受) - 窗口粒度:按分钟 bucket(window_seconds 建议为 60) """ limit: int window_seconds: int _counters: Dict[Tuple[str, int], int] = field(default_factory=dict) _last_gc_bucket: int = 0 def _bucket(self, now_ts: float) -> int: return int(now_ts // float(self.window_seconds)) def _gc(self, current_bucket: int) -> None: # 每隔一段时间清理一次,避免 dict 无限增长(保留最近 3 个 bucket) if self._last_gc_bucket == current_bucket: return self._last_gc_bucket = current_bucket keep_from = current_bucket - 2 to_delete = [k for k in self._counters.keys() if k[1] < keep_from] for k in to_delete: self._counters.pop(k, None) def allow(self, *, key: str, now_ts: float) -> None: bucket = self._bucket(now_ts) self._gc(bucket) k = (str(key), int(bucket)) n = int(self._counters.get(k, 0)) + 1 self._counters[k] = n if n > int(self.limit): raise HTTPException(status_code=429, detail="rate_limited") _reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60) _push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60) async def rate_limit_reco_by_ip(request: Request) -> None: """ 推荐接口限流:按 IP,1 分钟 10 次。 """ ip = "unknown" if request.client and request.client.host: ip = str(request.client.host) _reco_rate_limiter.allow(key=ip, now_ts=time.time()) async def rate_limit_push_by_ip(request: Request) -> None: """ 推送相关接口限流:按 IP,1 分钟 30 次。 说明: - register/preferences 等接口可能在客户端反复重试 - 本期先用内存固定窗口限流做基础保护 """ ip = "unknown" if request.client and request.client.host: ip = str(request.client.host) _push_rate_limiter.allow(key=ip, now_ts=time.time())