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