fix: address independent verification findings

Classify empty completions as transient per human ruling (fixes flaky
real-gateway smoke and prevents caching empty responses), rename the
factory injection parameter gate to breaker per the frozen design,
rewrite the probe-entry cleanup without except BaseException, declare
python-dotenv explicitly, add a mid-backoff cancellation test, and
record all implementation errata in the design and architecture docs.
This commit is contained in:
2026-07-20 22:01:26 -04:00
parent 0f4ae5ee8b
commit 0b8460b6d5
12 changed files with 101 additions and 16 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
"""进程内限流后端: 与 Redis 版同一契约的六道闸实现(D3 双后端)。
语义蓝本 CHS `app/coordination/limiter.py`: 并发 = 带 TTL 的租约(持有者
死亡后过期回收);RPM/TPM = 分钟滑动窗口计数;TPM 入场按 est 预扣,settle
死亡后过期回收);RPM/TPM = 分钟**固定窗口**计数(`int(now/60)`,与 CHS
Lua 同款口径);TPM 入场按 est 预扣,settle
按实际结算多退少补且退款落 acquire 时的窗口。检查-占用在单次同步段内完成
(无 await 穿插),单事件循环下天然原子;本实现不跨进程,是单进程部署的
正确答案(多 worker 用 M2 Redis 后端)。
+6 -6
View File
@@ -62,7 +62,7 @@ class GatewayClient:
sources: list[SourceConfig],
selector: SourceSelector,
limiter: RateLimiter,
gate: ProviderGate,
breaker: ProviderGate,
transport: Transport,
retry: RetryPolicy,
backpressure: BackpressurePolicy,
@@ -84,7 +84,7 @@ class GatewayClient:
sources=sources,
selector=selector,
limiter=limiter,
gate=gate,
gate=breaker,
transport=transport,
retry=retry,
backpressure=backpressure,
@@ -183,7 +183,7 @@ class GatewayClient:
settings: GatewaySettings,
*,
limiter: RateLimiter | None = None,
gate: ProviderGate | None = None,
breaker: ProviderGate | None = None,
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
@@ -203,7 +203,7 @@ class GatewayClient:
global_limits=settings.global_limits,
lease_ttl_s=settings.lease_ttl_s,
),
gate=gate or InMemoryGate(config=settings.breaker),
breaker=breaker or InMemoryGate(config=settings.breaker),
transport=OpenAICompatTransport(registry=registry),
retry=settings.retry,
backpressure=settings.backpressure,
@@ -223,7 +223,7 @@ class GatewayClient:
scope: str = "LLM",
*,
limiter: RateLimiter | None = None,
gate: ProviderGate | None = None,
breaker: ProviderGate | None = None,
cache: CacheBackend | None = None,
telemetry: TelemetryRecorder | None = None,
registry: Mapping[str, ProviderProfile] | None = None,
@@ -233,7 +233,7 @@ class GatewayClient:
return cls.from_settings(
GatewaySettings.from_env(scope, env=env),
limiter=limiter,
gate=gate,
breaker=breaker,
cache=cache,
telemetry=telemetry,
registry=registry,
+5 -3
View File
@@ -154,11 +154,13 @@ class RetryMW:
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
except BaseException:
await self._settle_and_release(permit, 0)
raise
finally:
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
if entry is None:
await self._settle_and_release(permit, 0)
if entry.allowed:
return (cand, permit, entry), gate_rejections
gate_rejections += 1
@@ -252,6 +252,7 @@ class OpenAICompatTransport:
(content_parts if is_content else thinking_parts).append(text)
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(sink.get("usage") or {}, source)
if salvaged:
usage_source = "estimated" # 打捞路径强制 estimated(设计 §6)
@@ -283,6 +284,20 @@ class OpenAICompatTransport:
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
return True
def _reject_empty_completion(self, content: str, source: SourceConfig) -> None:
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
"""
if not content.strip():
raise TransientError(
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
source_name=source.name,
operation="chat",
)
def _finalize_text(
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
) -> tuple[str, str]:
@@ -321,6 +336,7 @@ class OpenAICompatTransport:
content, thinking = self._finalize_text(
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
)
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}, source)
return TransportResult(
content=content,