test: add integration suite for governance stack and redis cache

This commit is contained in:
2026-07-20 07:51:32 -04:00
parent 7b9815f4bc
commit 893707eb32
2 changed files with 380 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""全栈治理组合集成测试: 完整洋葱(遥测→缓存→结构化→重试)+ 真实内存后端。
不依赖外部服务;熔断恢复全链用注入时钟推进,取消穿透用真实 asyncio 取消。
"""
import asyncio
import json
import sqlite3
import httpx
import pytest
from polygateway import CircuitOpenError, GatewayClient, TransientError
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.sources import RoundRobinSelector
from polygateway.structured.json_repair import JsonRepairStrategy
from polygateway.telemetry.sqlite import SQLiteRecorder
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
GlobalLimits,
RetryPolicy,
SourceConfig,
)
from tests.contracts.conftest import FakeClock
_BREAKER = BreakerConfig(fail_threshold=2, cooldown_s=60.0, probe_ttl_s=120.0)
def _source(name="qwen_1"):
return SourceConfig(
name=name,
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk",
model="qwen-max",
timeout_s=5.0,
)
def _sse(content="ok"):
chunk = json.dumps({"choices": [{"delta": {"content": content}}]})
usage = json.dumps({"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}})
body = f"data: {chunk}\n\ndata: {usage}\n\ndata: [DONE]\n\n"
return httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})
async def _noop_sleep(seconds):
return None
def _full_client(handler, *, clock=None, telemetry=None, cache=None):
clock = clock or FakeClock()
src = _source()
return GatewayClient(
scope="llm",
sources=[src],
selector=RoundRobinSelector(),
limiter=InMemoryLimiter(
scope="llm",
sources={src.name: src},
global_limits=GlobalLimits(0, 0, 0),
now=clock,
),
gate=InMemoryGate(config=_BREAKER, now=clock),
transport=OpenAICompatTransport(
client_factory=lambda s: httpx.AsyncClient(transport=httpx.MockTransport(handler))
),
retry=RetryPolicy(2, 2.0, 30.0),
backpressure=BackpressurePolicy(300.0, 0.01),
telemetry=telemetry,
cache=cache,
cache_namespace="itest" if cache else None,
cache_ttl_s=3600 if cache else None,
structured_strategy=JsonRepairStrategy(),
now=clock,
sleep=_noop_sleep,
)
class TestBreakerRecoveryFullChain:
async def test_open_cooldown_probe_close_cycle(self):
"""开路 → 冷却 → 半开探针 → 恢复闭路,经完整 client 洋葱走通。"""
clock = FakeClock()
state = {"fail": True}
def handler(request):
if state["fail"]:
return httpx.Response(503, content=b"{}")
return _sse("recovered")
client = _full_client(handler, clock=clock)
# 2 次尝试全 503 → retry_exhausted;熔断计 2 次失败达阈值开路
with pytest.raises(Exception) as ei:
await client.chat([{"role": "user", "content": "hi"}])
assert "retry_exhausted" in str(ei.value)
# 开路期间: 直接 CircuitOpenError,不打网关
with pytest.raises(CircuitOpenError) as open_err:
await client.chat([{"role": "user", "content": "hi"}])
assert open_err.value.retry_after_s > 0
# 冷却到期 + 网关恢复 → 探针成功闭路
clock.advance(_BREAKER.cooldown_s + 1)
state["fail"] = False
resp = await client.chat([{"role": "user", "content": "hi"}])
assert resp.content == "recovered"
# 闭路后正常服务
resp2 = await client.chat([{"role": "user", "content": "hi2"}])
assert resp2.content == "recovered"
class TestCancellationThroughStack:
async def test_cancel_mid_request_releases_and_records(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
entered = asyncio.Event()
async def hanging_handler(request):
entered.set()
await asyncio.sleep(30)
limiter_probe = {}
client = _full_client(hanging_handler, telemetry=recorder)
limiter_probe["limiter"] = client._handler # noqa: SLF001 — 仅为断言持引用
task = asyncio.ensure_future(client.chat([{"role": "user", "content": "hi"}]))
await entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
recorder.close()
rows = sqlite3.connect(tmp_path / "t.db").execute("SELECT error FROM llm_calls").fetchall()
# 尽力而为遥测: 取消路径留痕(尝试级与最外层各一行,均 error=cancelled)
assert rows and all(r[0] == "cancelled" for r in rows)
class TestTelemetryAcrossPaths:
async def test_success_cache_hit_and_failure_rows(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
recorder.close()
conn = sqlite3.connect(tmp_path / "t.db")
(hits,) = conn.execute("SELECT COUNT(*) FROM llm_calls WHERE cache_hit=1").fetchone()
(total,) = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()
assert hits == 1 and total == 2
async def test_transient_attempts_each_recorded(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db")
calls = {"n": 0}
def flaky(request):
calls["n"] += 1
if calls["n"] == 1:
return httpx.Response(503, content=b"{}")
return _sse()
client = _full_client(flaky, telemetry=recorder)
resp = await client.chat([{"role": "user", "content": "hi"}])
assert resp.content == "ok"
recorder.close()
rows = (
sqlite3.connect(tmp_path / "t.db")
.execute("SELECT error IS NULL, call_id FROM llm_calls ORDER BY created_at")
.fetchall()
)
assert len(rows) == 2 # 失败尝试 + 成功尝试各一行
assert {ok for ok, _ in rows} == {0, 1}
assert len({cid for _, cid in rows}) == 2 # call_id 逐次独立
class TestStructuredThroughStack:
async def test_feedback_reask_passes_through_governance(self):
"""重问经过内层治理: 第二次真实请求同样被限流/熔断记账。"""
from pydantic import BaseModel
class Out(BaseModel):
answer: int
contents = ['{"answer": "bad"}', '{"answer": 7}']
def handler(request):
return _sse(contents.pop(0))
client = _full_client(handler)
resp = await client.chat([{"role": "user", "content": "hi"}], structured=Out)
assert resp.structured_data.answer == 7
class TestTransientErrorExport:
async def test_business_side_catches_top_level_errors(self):
"""迁移承诺: 业务侧 (TimeoutError, OSError) 元组换成库异常后可捕获。"""
def always_503(request):
return httpx.Response(503, content=b"{}")
client = _full_client(always_503)
with pytest.raises(Exception) as ei:
await client.chat([{"role": "user", "content": "hi"}])
import polygateway
assert isinstance(ei.value, polygateway.AllSourcesExhausted)
assert isinstance(ei.value.__cause__, TransientError)
+174
View File
@@ -0,0 +1,174 @@
"""真实 Redis 缓存集成测试(实验室远程实例;CLAUDE.md: Redis 相关不 mock)。
前置: `.env`/环境变量提供 `REDIS_URL`;缺失时整组 skip(T13 门在验收前必须真跑)。
隔离: 所有 key 落在一次性命名空间 `pgw:test:{uuid}`,teardown 精确清理,
绝不 SCAN/FLUSH 全库,不触碰在用数据。
"""
import json
import os
import uuid
import httpx
import pytest
from dotenv import dotenv_values
from polygateway.backends.redis_cache import RedisCache
from polygateway.middleware.cache import CacheMW, build_cache_key
from polygateway.types import ChatRequest, LLMResponse
_REDIS_URL = os.environ.get("REDIS_URL") or dotenv_values(".env").get("REDIS_URL")
pytestmark = pytest.mark.skipif(
not _REDIS_URL, reason="需实验室远程 Redis: 在 .env 设置 REDIS_URL(M1 验收前必须真跑)"
)
_MSGS = [{"role": "user", "content": "integration-hi"}]
def _resp(content="cached"):
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
@pytest.fixture
async def redis_cache():
import redis.asyncio as aioredis
client = aioredis.from_url(
_REDIS_URL, decode_responses=True, socket_connect_timeout=3.0, socket_timeout=3.0
)
cache = RedisCache(client)
used_keys: list[str] = []
original_set = cache.set
async def tracking_set(key, value, ttl_s):
used_keys.append(key)
await original_set(key, value, ttl_s)
cache.set = tracking_set # 记录写入的 key,teardown 精确删除
yield cache
if used_keys:
await client.delete(*used_keys)
await client.aclose()
@pytest.fixture
def namespace():
return f"pgw:test:{uuid.uuid4().hex}"
class TestRealRedisRoundtrip:
async def test_set_get_ttl(self, redis_cache, namespace):
key = build_cache_key("m", _MSGS, namespace, None)
assert await redis_cache.get(key) is None
await redis_cache.set(key, json.dumps({"v": 1}), 60)
assert json.loads(await redis_cache.get(key)) == {"v": 1}
async def test_cache_mw_miss_then_hit(self, redis_cache, namespace):
mw = CacheMW(
backend=redis_cache,
model_fingerprint="m",
default_namespace=namespace,
ttl_s=60,
)
calls = {"n": 0}
async def terminal(request):
calls["n"] += 1
return _resp()
first = await mw(ChatRequest(messages=_MSGS), terminal)
second = await mw(ChatRequest(messages=_MSGS), terminal)
assert first.cache_hit is False and second.cache_hit is True
assert calls["n"] == 1
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
other = f"pgw:test:{uuid.uuid4().hex}"
k1 = build_cache_key("m", _MSGS, namespace, None)
k2 = build_cache_key("m", _MSGS, other, None)
assert k1 != k2
await redis_cache.set(k1, json.dumps({"ns": 1}), 60)
assert await redis_cache.get(k2) is None
class TestDegradationAgainstDeadRedis:
async def test_unreachable_redis_degrades_to_miss(self):
"""断连方向: 缓存后端挂 → 静默降级,调用照常(铁律,与限流相反)。"""
import redis.asyncio as aioredis
dead = RedisCache(
aioredis.from_url(
"redis://127.0.0.1:1/0",
socket_connect_timeout=0.2,
socket_timeout=0.2,
decode_responses=True,
)
)
mw = CacheMW(
backend=dead, model_fingerprint="m", default_namespace="pgw:test:dead", ttl_s=60
)
async def terminal(request):
return _resp("alive")
resp = await mw(ChatRequest(messages=_MSGS), terminal)
assert resp.content == "alive" and resp.cache_hit is False
def _sse_response():
chunk = json.dumps({"choices": [{"delta": {"content": "hello"}}]})
usage = json.dumps({"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}})
body = f"data: {chunk}\n\ndata: {usage}\n\ndata: [DONE]\n\n"
return httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})
class TestClientWithRealRedis:
async def test_from_settings_style_assembly_with_redis_cache(self, redis_cache, namespace):
"""GatewayClient 组装 + 真实 Redis 缓存的端到端命中。"""
from polygateway import GatewayClient
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.sources import RoundRobinSelector
from polygateway.structured.json_repair import JsonRepairStrategy
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
GlobalLimits,
RetryPolicy,
SourceConfig,
)
src = SourceConfig(
name="qwen_1",
provider="qwen",
base_url="https://gw.example/v1",
api_key="sk",
model="qwen-max",
timeout_s=5.0,
)
client = GatewayClient(
scope="llm",
sources=[src],
selector=RoundRobinSelector(),
limiter=InMemoryLimiter(
scope="llm", sources={src.name: src}, global_limits=GlobalLimits(0, 0, 0)
),
gate=InMemoryGate(config=BreakerConfig(5, 60.0, 120.0)),
transport=OpenAICompatTransport(
client_factory=lambda s: httpx.AsyncClient(
transport=httpx.MockTransport(lambda req: _sse_response())
)
),
retry=RetryPolicy(3, 2.0, 30.0),
backpressure=BackpressurePolicy(300.0, 0.01),
cache=redis_cache,
cache_namespace=namespace,
cache_ttl_s=60,
structured_strategy=JsonRepairStrategy(),
)
first = await client.chat(_MSGS)
second = await client.chat(_MSGS)
assert first.cache_hit is False and second.cache_hit is True
assert second.content == "hello"