Files
group_fqcd_jr/app/api/dependencies/rate_limit.py
T

72 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""限流闸门(文档 §3.6 `RATE_LIMITED`:429、可重试)。
为什么用依赖而不是全局 HTTP 中间件:
- 限流维度是"用户 + 接口",用户来自认证后的 `RequestContext`。全局中间件跑在路由匹配
之前,拿不到上下文,只能退化成按 IP 限流(本地/内网部署里所有客户端常常共用一个
出口 IP,等于没有限流),或者自己再解析一次 JWT(第二套鉴权实现,违反单一入口)。
- 依赖抛的是 `AgentError` 家族异常,直接复用 `app/main.py` 的统一错误信封处理器;
中间件抛出异常会绕过该处理器,只能手写一份响应体,容易与文档 §3.4 漂移。
- 挂在路由上(`APIRouter(dependencies=[...])`)而不是每个函数里手写,新增接口不会
漏掉闸门。
顺序保证:本依赖声明依赖 `build_request_context`,因此**认证永远先于限流**——未带令牌
的请求仍是 401,不会因为限流计数变成 429(否则限流会掩盖鉴权失败)。
降级:后端返回 `None` 表示无法判定(Redis 不可用/未安装/超时),此时**放行**。限流是
保护措施,不能因为 Redis 故障把正常请求全部拒掉;降级只写运行日志,按文档 §11.2 不进
审计(限流拒绝本身也只写日志或指标)。
"""
import logging
from fastapi import Depends, Request
from app.api.dependencies.auth import build_request_context
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.core.rate_limit import RateLimitExceededError, RateLimitPolicy
from app.infrastructure.rate_limiter import CounterBackend, default_counter_backend
logger = logging.getLogger(__name__)
def get_counter_backend() -> CounterBackend:
"""计数后端工厂:模块级函数是唯一的替换点(测试注入替身,不连 Redis)。"""
return default_counter_backend()
def route_template(request: Request) -> str:
"""计数维度里的"接口"取路由模板,而不是原始 URL。
否则 `GET /agent-runs/{run_id}` 会被拆成无数个独立计数器,限流形同虚设。
"""
path = getattr(request.scope.get("route"), "path", None)
return str(path) if path else request.url.path
async def enforce_rate_limit(
request: Request,
context: RequestContext = Depends(build_request_context), # noqa: B008
) -> None:
policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled:
return
template = route_template(request)
result = await get_counter_backend().increment(
policy.key(context.user_id, request.method, template), policy.window_seconds
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > policy.max_requests:
logger.warning(
"触发限流 route=%s count=%s limit=%s", template, count, policy.max_requests
)
raise RateLimitExceededError(
f"请求过于频繁:每 {policy.window_seconds} 秒最多 {policy.max_requests} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)