fix: close the failure modes review found in the new code

Three of them were the same shape as the bug this branch exists to fix:
something goes wrong, the library swallows it, and the caller is left
with a number that means the opposite of what happened.

The throttle key had no source in it. Five sources on one model is the
normal case here, so the first one to break would warn once and silence
the other four for the life of the process, and the message never said
which gateway to look at.

An unknown verdict in a cached entry threw away the whole response. The
rehydrator tolerates unknown fields but not unknown values of a known
field, so two library versions sharing a Redis would each invalidate
the other's entries: halved hit rate, and the only log line says the
cache rebuild failed. A purely observational field should not be able
to void a response whose content is intact.

Normalising for telemetry now degrades instead of raising, both for a
bare string and for a value outside the domain. Either one used to
reach the same except and cost the whole row, which is exactly how
1.3.0 lost nineteen calls without anyone noticing.
This commit is contained in:
2026-08-26 02:37:24 -04:00
parent c0b544d233
commit 1307a02b92
9 changed files with 270 additions and 30 deletions
+27 -3
View File
@@ -28,6 +28,31 @@ _KEY_PREFIX = "pgw:cache:"
_RESPONSE_FIELDS = {f.name for f in dataclasses.fields(LLMResponse)}
def _coerce_observation(raw: Any) -> ThinkingObservation:
"""缓存里的三态取值 → 枚举;域外取值降级为 `UNKNOWN`,**不作废整条缓存**。
方向选择的理由: `_rehydrate` 对 JSON 里的**新字段**已经是宽容的(先按
`_RESPONSE_FIELDS` 过滤),对同一字段的**新取值**却不该是致命的。真实场景是
多个项目共用一个 Redis,先升级的那个写入了本版没有的取值,未升级的项目若把
这些条目判成未命中,就会每次真打网关、随后覆写回旧值,两个版本互相打对方的
缓存(表现是命中率莫名腰斩,而通用的"重建失败"文案给不出任何线索)。一个纯
可观测性字段不该有能力废掉内容完好的缓存响应——"整条作废"留给真正破坏内容
完整性的失败(JSON 坏了、结构化重建不过)。
降级到 `UNKNOWN` 而不是别的态: 它的语义恰好就是"本次判不出来",对一个本库
读不懂的取值,这是唯一诚实的说法。
"""
try:
return ThinkingObservation(raw)
except ValueError:
logger.warning(
"缓存条目的 thinking_observation 取值 {!r} 不在本版取值域内(多半由更新版本的"
"进程写入),已降级为 UNKNOWN;响应内容照常复活——可观测性字段不作废缓存",
raw,
)
return ThinkingObservation.UNKNOWN
def digest_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""多模态 content part 先各自 sha256 摘要再参与序列化;文本原文参与。
@@ -134,10 +159,9 @@ class CacheMW:
structured_data = self._rebuild_structured(fields.get("content", ""), request)
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值。域外值抛
# ValueError,由下方 except 吞成"按未命中回源"——降级方向正确。
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = ThinkingObservation(fields["thinking_observation"])
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
fields.update(
cache_hit=True,
latency_ms=0,