2026-09-10 15:55:54 +08:00
|
|
|
|
"""限流闸门(文档 §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,
|
|
|
|
|
|
)
|
2026-09-11 20:43:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#: 登录端点的限流参数。比普通接口严得多:普通接口的 `policy.max_requests` 是按"已登录用户
|
|
|
|
|
|
#: 的操作频率"定的,而这里是**密码爆破**的入口,必须独立收紧。
|
|
|
|
|
|
LOGIN_WINDOW_SECONDS = 60
|
|
|
|
|
|
LOGIN_MAX_ATTEMPTS = 10
|
|
|
|
|
|
LOGIN_COUNTER_PREFIX = "login"
|
|
|
|
|
|
|
2026-09-14 20:14:50 +08:00
|
|
|
|
#: 访客令牌签发端点的限流参数。
|
|
|
|
|
|
#: ⚠️ 与登录**必须是独立计数器**(不同 `prefix`):共用会让两者互相挤占配额 ——
|
|
|
|
|
|
#: 正常访客刷几次页面就把别人挡在登录外,反之亦然。
|
|
|
|
|
|
#: 阈值比登录宽松(访客进站/刷新会正常签发),但足以把"脚本循环铸造身份"从
|
|
|
|
|
|
#: 毫秒级降到每分钟几十次:每个令牌都是一次可用的 LLM 调用额度,
|
|
|
|
|
|
#: 且访客的 `sub` 每次都是新随机值,**按 user_id 计的限流天然被绕过**。
|
|
|
|
|
|
VISITOR_WINDOW_SECONDS = 60
|
|
|
|
|
|
VISITOR_MAX_ATTEMPTS = 30
|
|
|
|
|
|
VISITOR_COUNTER_PREFIX = "visitor-token"
|
2026-09-11 20:43:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 20:14:50 +08:00
|
|
|
|
async def _enforce_ip_rate_limit(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
*,
|
|
|
|
|
|
prefix: str,
|
|
|
|
|
|
window_seconds: int,
|
|
|
|
|
|
max_attempts: int,
|
|
|
|
|
|
label: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""按客户端 IP 限流的通用闸门,**不依赖认证上下文**。
|
2026-09-11 20:43:21 +08:00
|
|
|
|
|
2026-09-14 20:14:50 +08:00
|
|
|
|
适用于"请求到达时还没有身份"的端点:登录、访客令牌签发。
|
|
|
|
|
|
它们都不能用 `enforce_rate_limit` —— 那个声明依赖 `build_request_context`
|
|
|
|
|
|
(见本模块文档"顺序保证"),挂上去就变成"要令牌先有令牌"。
|
2026-09-11 20:43:21 +08:00
|
|
|
|
|
2026-09-14 20:14:50 +08:00
|
|
|
|
维度取客户端 IP + 路由模板:那时拿不到 `RequestContext.user_id`,IP 是唯一
|
|
|
|
|
|
可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但这些端点的价值
|
|
|
|
|
|
在于**挡住自动化脚本**,IP 维度足够,且不引入第二套鉴权解析。
|
|
|
|
|
|
|
|
|
|
|
|
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**
|
|
|
|
|
|
并告警,不因为限流组件故障把所有人挡在门外。
|
2026-09-11 20:43:21 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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(
|
2026-09-14 20:14:50 +08:00
|
|
|
|
f"{prefix}:{client}:{request.method}:{template}",
|
|
|
|
|
|
window_seconds,
|
2026-09-11 20:43:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
if result is None:
|
|
|
|
|
|
logger.warning("限流后端不可用,降级放行 route=%s", template)
|
|
|
|
|
|
return
|
|
|
|
|
|
count, retry_after_seconds = result
|
2026-09-14 20:14:50 +08:00
|
|
|
|
if count > max_attempts:
|
2026-09-11 20:43:21 +08:00
|
|
|
|
logger.warning(
|
2026-09-14 20:14:50 +08:00
|
|
|
|
"%s 限流 route=%s ip=%s count=%s limit=%s",
|
|
|
|
|
|
label, template, client, count, max_attempts,
|
2026-09-11 20:43:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
raise RateLimitExceededError(
|
2026-09-14 20:14:50 +08:00
|
|
|
|
f"{label}过于频繁:每 {window_seconds} 秒最多 {max_attempts} 次,"
|
2026-09-11 20:43:21 +08:00
|
|
|
|
f"请在 {retry_after_seconds} 秒后重试",
|
|
|
|
|
|
retry_after_seconds,
|
|
|
|
|
|
)
|
2026-09-14 20:14:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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="访客令牌签发",
|
|
|
|
|
|
)
|
|
|
|
|
|
|