"""限流闸门(文档 §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, ) #: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户 #: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。 LOGIN_WINDOW_SECONDS = 60 LOGIN_MAX_ATTEMPTS = 10 LOGIN_COUNTER_PREFIX = "login" async def enforce_login_rate_limit(request: Request) -> None: """登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。 为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context` (见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来 就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。 维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份), 用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录 端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。 降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警, 不因为限流组件故障把所有人挡在门外。 """ policy = RateLimitPolicy.from_settings(get_settings()) if not policy.enabled: return client = request.client.host if request.client is not None else "unknown" template = route_template(request) result = await get_counter_backend().increment( f"{LOGIN_COUNTER_PREFIX}:{client}:{request.method}:{template}", LOGIN_WINDOW_SECONDS, ) if result is None: logger.warning("限流后端不可用,降级放行 route=%s", template) return count, retry_after_seconds = result if count > LOGIN_MAX_ATTEMPTS: logger.warning( "登录限流 route=%s ip=%s count=%s limit=%s", template, client, count, LOGIN_MAX_ATTEMPTS, ) raise RateLimitExceededError( f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次," f"请在 {retry_after_seconds} 秒后重试", retry_after_seconds, )