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:
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user