Files
group_fqcd_jr/tests/unit/api/test_rate_limit.py
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 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(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

177 lines
6.5 KiB
Python

"""限流闸门契约测试(文档 §3.5 的 429、§3.6 的 `RATE_LIMITED`,标注为可重试)。
覆盖用户要求的三条:正常放行、超限拒绝且码与 `Retry-After` 正确、Redis 不可用时
降级放行;另加三条边界:
- 闸门关闭(`RATE_LIMIT_ENABLED=false`)时不访问后端;
- 认证先于限流(未带令牌仍是 401,不会被限流改写成 429);
- 计数维度按"用户 + 路由模板",而不是原始 URL——否则 `/{run_id}` 这类路径每个资源
都会各自计数,限流形同虚设。
全部进程内调用,不连 Redis(后端是替身),也不连数据库(放行路径用替身 Service)。
"""
from collections.abc import AsyncIterator
from typing import Any
import pytest
from fastapi import Request
from fastapi.testclient import TestClient
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.config import get_settings
from app.core.contracts import RequestContext
from app.main import create_app
MESSAGES = "/api/v1/conversations/session-1/messages"
RUN_DETAIL = "/api/v1/agent-runs/run-1"
class FakeBackend:
"""替身计数后端:固定返回给定的 `(计数, 剩余秒数)`,或 `None`(后端不可用)。"""
def __init__(self, result: tuple[int, int] | None) -> None:
self.result = result
self.calls: list[tuple[str, int]] = []
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
self.calls.append((key, window_seconds))
return self.result
class RecordingService:
def __init__(self, session: Any) -> None:
del session
async def messages(
self, session_id: str, context: RequestContext, limit: int, before: int | None = None
) -> dict[str, object]:
return {"data": []}
async def override_session() -> AsyncIterator[Any]:
yield object()
def build_client(
monkeypatch: pytest.MonkeyPatch,
*,
backend: FakeBackend | None,
user_id: str = "9001",
max_requests: int = 2,
enabled: bool = True,
) -> TestClient:
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_settings",
lambda: get_settings().model_copy(update={
"rate_limit_enabled": enabled,
"rate_limit_window_seconds": 60,
"rate_limit_max_requests": max_requests,
"rate_limit_key_prefix": "test:rate_limit",
}),
)
if backend is not None:
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_counter_backend", lambda: backend
)
monkeypatch.setattr("app.api.controllers.conversations.ConversationService", RecordingService)
application = create_app()
async def context(request: Request) -> RequestContext:
built = RequestContext(user_id=user_id, trace_id="trace-rate",
permissions=("conversation:read", "agent:run"))
request.state.request_context = built
return built
application.dependency_overrides[build_request_context] = context
application.dependency_overrides[get_session] = override_session
return TestClient(application)
def test_request_within_limit_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
backend = FakeBackend((2, 30)) # 恰好等于阈值:文档语义是"超过"才拒绝
expected_key = "test:rate_limit:9001:GET:/api/v1/conversations/{session_id}/messages"
with build_client(monkeypatch, backend=backend, max_requests=2) as client:
response = client.get(MESSAGES)
assert response.status_code == 200
assert backend.calls == [(expected_key, 60)]
def test_request_over_limit_returns_429_with_code_and_retry_after(
monkeypatch: pytest.MonkeyPatch
) -> None:
backend = FakeBackend((3, 17))
with build_client(monkeypatch, backend=backend, max_requests=2) as client:
response = client.get(MESSAGES)
assert response.status_code == 429
assert response.headers["Retry-After"] == "17"
body = response.json()
assert set(body) == {"error", "meta"}
assert body["error"]["code"] == "RATE_LIMITED"
assert body["error"]["retryable"] is True # 文档 §3.6:该码可重试
assert body["error"]["field_errors"] == []
assert body["meta"]["trace_id"] == "trace-rate"
def test_redis_unavailable_degrades_to_allow(monkeypatch: pytest.MonkeyPatch) -> None:
"""Redis 不可用时必须放行:保护措施不能变成全量拒绝。"""
backend = FakeBackend(None)
with build_client(monkeypatch, backend=backend) as client:
response = client.get(MESSAGES)
assert response.status_code == 200
assert len(backend.calls) == 1 # 确实尝试过判定,只是判定不出来
def test_disabled_limit_does_not_touch_backend(monkeypatch: pytest.MonkeyPatch) -> None:
backend = FakeBackend((99, 5))
with build_client(monkeypatch, backend=backend, enabled=False) as client:
response = client.get(MESSAGES)
assert response.status_code == 200
assert backend.calls == []
def test_authentication_precedes_rate_limiting(monkeypatch: pytest.MonkeyPatch) -> None:
"""未带令牌 + 已超限:必须仍是 401(限流不得掩盖鉴权失败)。"""
application = create_app()
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_settings",
lambda: get_settings().model_copy(update={"rate_limit_max_requests": 1}),
)
monkeypatch.setattr(
"app.api.dependencies.rate_limit.get_counter_backend", lambda: FakeBackend((99, 5))
)
with TestClient(application) as client:
response = client.get(MESSAGES)
assert response.status_code == 401
assert response.json()["error"]["code"] == "AUTHENTICATION_REQUIRED"
def test_counter_key_uses_route_template_and_user(monkeypatch: pytest.MonkeyPatch) -> None:
"""计数维度:同一路由模板下不同 `run_id` 共用一个计数器,不同用户互相隔离。"""
first = FakeBackend((1, 60))
second = FakeBackend((1, 60))
with build_client(monkeypatch, backend=first, user_id="9001") as client:
client.get("/api/v1/agent-runs/run-1")
client.get("/api/v1/agent-runs/run-2")
with build_client(monkeypatch, backend=second, user_id="9002") as client:
client.get(RUN_DETAIL)
assert first.calls == [
("test:rate_limit:9001:GET:/api/v1/agent-runs/{run_id}", 60),
("test:rate_limit:9001:GET:/api/v1/agent-runs/{run_id}", 60),
]
assert second.calls == [("test:rate_limit:9002:GET:/api/v1/agent-runs/{run_id}", 60)]