fix: degrade terminal telemetry failures instead of masking domain errors

终态出口 `emit_terminal_once` 此前只让 `_record` 内的 except 兜住落库,而
诊断字段的提取(`_error_fields` → `_structured_detail` → `format_bounded_errors`)
在降级 try **之外**求值。下游经公共端口(自实现 `StructuredOutputStrategy`
或 transport)构造出 `ResultInvalidError(validation_errors=(非 str,))` 时,提取期
抛的 `TypeError` 会顶替调用方本该收到的领域异常——错误四分类被击穿(下游
`except ResultInvalidError` 落空),且 `claim_terminal()` 已消耗故终态行照样丢,
同时违反"遥测写失败降级不冒泡"。

改法与 RetryMW 的 attempt 出口(`retry.py::_emit`)同款: 把快照冻结与 await
整段包进 try,`CancelledError` 原样上抛、其余落一条 warning。终态行按已批准的
best effort(兜底命中时该次逻辑调用 0 条终态行,不补写)。异常类型校验与
`ResultInvalidError` 的既有设计均未改动。

顺带同步审查报告的 Minor 项: SQLite recorder docstring 26 → 36 字段、
research-wiki 索引重建、ARCHITECTURE 的 `sampling` 段落终态调用点口径,
并删除 `TelemetryMW` 迁移后无读取点的 `self._now` 死字段(保留形参,
避免平白打断既有装配写法)。
This commit is contained in:
2026-09-09 12:50:51 -04:00
parent 067b15be48
commit b4812e12c8
6 changed files with 105 additions and 16 deletions
+72
View File
@@ -1645,3 +1645,75 @@ class TestChatTerminalFailureRows:
operation="chat",
)
assert len(self._rows(recorder, "terminal_failure")) == 1
@pytest.fixture
def captured_warnings():
"""捕获库发出的 WARNING;loguru 不经标准 logging,pytest 的 caplog 抓不到。
名字避开裸 `warnings`: 那会遮蔽标准库模块名,本文件将来任何一次
`import warnings` 都会与它静默互相顶掉,而报错点离真因很远。
"""
from loguru import logger
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
yield messages
logger.remove(sink_id)
class TestTerminalEmitDegradation:
"""终态出口的降级方向: 诊断字段的**提取**同样在降级范围内(铁律"遥测写失败降级不冒泡")。
改前 `_record` 的 except 只包住写入本身,而错误诊断列在它之外求值——
经公共扩展点(自实现 `StructuredOutputStrategy`/transport)构造出的
`ResultInvalidError(validation_errors=(非 str,))` 会让提取期抛 `TypeError`
顶替调用方本该收到的领域异常,错误四分类当场被击穿。
"""
_MSG = [{"role": "user", "content": "hi"}]
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
async def test_broken_validation_errors_keep_the_domain_error(self, captured_warnings):
"""自实现策略给出非 str 的 `validation_errors`: 领域异常必须原样上抛。"""
class _BadStrategy:
"""公共端口 `StructuredOutputStrategy` 的下游实现(库外没有类型执法)。"""
def request_overlay(self, schema):
return {}
def parse(self, text):
raise ResultInvalidError("模型返回不可解析", validation_errors=(object(),))
recorder = _MemoryRecorder()
async with _client(telemetry=recorder, structured_strategy=_BadStrategy()) as client:
with pytest.raises(ResultInvalidError): # 不是 TypeError
await client.chat(self._MSG, structured="json")
# 降级有声: 静默吞掉等于遥测缺口无人知道
assert [m for m in captured_warnings if "终态遥测记录失败" in m]
# 尝试行不受影响;终态行按 best effort 允许 0 条,但绝不能重复
assert len(self._rows(recorder, "attempt")) == 1
assert len(self._rows(recorder, "terminal_failure")) <= 1
async def test_cancellation_is_never_swallowed_by_the_degradation(self):
"""降级不得吞取消: 写入那一次 await 上被取消,`CancelledError` 照常传播。"""
from polygateway.middleware.telemetry import emit_terminal_once
from polygateway.types import _CallContext
class _CancellingEmitter:
async def emit_terminal_failure(self, **kwargs):
raise asyncio.CancelledError
context = _CallContext(now=asyncio.get_running_loop().time)
request = ChatRequest(messages=self._MSG, call_context=context)
with pytest.raises(asyncio.CancelledError):
await emit_terminal_once(
_CancellingEmitter(),
request=request,
context=context,
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
operation="chat",
)