相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
72 lines
3.2 KiB
Python
72 lines
3.2 KiB
Python
"""限流闸门(文档 §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,
|
||
)
|