Files
group_fqcd_jr/app/api/dependencies/rate_limit.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

164 lines
7.0 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,
)
#: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户
#: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。
LOGIN_WINDOW_SECONDS = 60
LOGIN_MAX_ATTEMPTS = 10
LOGIN_COUNTER_PREFIX = "login"
#: 访客令牌签发端点的限流参数。
#: ⚠️ 与登录**必须是独立计数器**(不同 `prefix`):共用会让两者互相挤占配额 ——
#: 正常访客刷几次页面就把别人挡在登录外,反之亦然。
#: 阈值比登录宽松(访客进站/刷新会正常签发),但足以把"脚本循环铸造身份"从
#: 毫秒级降到每分钟几十次:每个令牌都是一次可用的 LLM 调用额度,
#: 且访客的 `sub` 每次都是新随机值,**按 user_id 计的限流天然被绕过**。
VISITOR_WINDOW_SECONDS = 60
VISITOR_MAX_ATTEMPTS = 30
VISITOR_COUNTER_PREFIX = "visitor-token"
async def _enforce_ip_rate_limit(
request: Request,
*,
prefix: str,
window_seconds: int,
max_attempts: int,
label: str,
) -> 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"{prefix}:{client}:{request.method}:{template}",
window_seconds,
)
if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template)
return
count, retry_after_seconds = result
if count > max_attempts:
logger.warning(
"%s 限流 route=%s ip=%s count=%s limit=%s",
label, template, client, count, max_attempts,
)
raise RateLimitExceededError(
f"{label}过于频繁:每 {window_seconds} 秒最多 {max_attempts} 次,"
f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds,
)
async def enforce_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门(文案与阈值保持原样,见本模块既有契约测试)。"""
await _enforce_ip_rate_limit(
request,
prefix=LOGIN_COUNTER_PREFIX,
window_seconds=LOGIN_WINDOW_SECONDS,
max_attempts=LOGIN_MAX_ATTEMPTS,
label="登录尝试",
)
async def enforce_visitor_token_rate_limit(request: Request) -> None:
"""访客令牌签发端点的限流闸门。
该端点此前**零认证、零限流**:可以不限量地铸造有效 JWT,每个都能调
`/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流按 `user_id` 计、
访客 `sub` 每次都是新随机值 ⇒ 限流被天然绕过,等于免费刷模型额度并灌爆
`agent_run` / `conversation` 表。这里补上与登录同级别的闸门。
"""
await _enforce_ip_rate_limit(
request,
prefix=VISITOR_COUNTER_PREFIX,
window_seconds=VISITOR_WINDOW_SECONDS,
max_attempts=VISITOR_MAX_ATTEMPTS,
label="访客令牌签发",
)