fix: settle cancelled attempts against the source estimate

取消发生在"端口已开始、结算尚未确定"时,原先按 settle(0) 把入场预扣整笔
退还,等于把可能已被上游计费的用量退回闸里;启用调用期限后库自身会常规性
触发该路径,故先修记账再启用。

改动只落在取消路径的取值上(设计 §6.3 矩阵 S3/S5/S7):
- actual 初值保持 0,另设函数内局部阶段变量 settlement_known(不进任何签名);
- 成功路径算出用量后置位,真实 usage 恰为 0 同样算"已知",不被取消覆写;
- 已处理领域失败分支把结算决定前移到其第一个 await 之前(同级
  except CancelledError 接不住本块 await 上的取消,它直穿 finally),
  故 SourceDead 的既有 0 在取消下被保住,瞬时失败仍是 est,值与 1.3.5 逐字相同;
- 未被四分类接住的异常不经上述分支,仍按 0 退全款(S8,本版不扩大语义);
- OCR 的 0 token 是事实而非未知,settle(0) 不变,只补注释。

取消窗口一律用真实 asyncio.Event 钉死(不再 sleep 撞窗口),并加防越界回归:
RuntimeError 逃逸仍结 0、真实 usage 为 0 的成功仍结 0。
This commit is contained in:
2026-09-10 00:48:07 -04:00
parent 6c640fcca3
commit 1ff83bbfe0
7 changed files with 213 additions and 8 deletions
@@ -72,10 +72,13 @@ class ScriptedTransport:
def __init__(self, hang: bool = False):
self.hang = hang
self.calls: list[str] = []
# 取消用例的确定性窗口(同 test_retry FakeTransport): 进入挂起即置位
self.entered = asyncio.Event()
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append(source.name)
if self.hang:
self.entered.set()
await asyncio.Event().wait()
return TransportResult(
content="ok",
@@ -225,7 +228,32 @@ async def test_cancel_in_flight_releases_lease(clients):
assert (await limiter.source_stats("s1")).inflight == 0
# —— 掉线方向(fail-closed 集成证据)——
async def test_cancel_in_flight_keeps_the_reservation(clients):
"""1.3.6 §6.3 S3 在**真实 Redis** 上: 端口已开始、用量未知 → 保留 est 预扣。
内存后端与 Lua 后端的 `settle(delta)` 算术必须同口径——取消时凭空退款
在分布式部署下就是几个 worker 一起击穿 TPM 闸。本用例不改 Lua、不改契约套件。
"""
a_cli, _ = clients
scope = f"t{uuid4().hex[:8]}"
sources = [make_source(max_concurrency=1, tpm=1000, est_tokens=400)]
limiter = _limiter(a_cli, scope, sources, GlobalLimits(0, 0, 0))
transport = ScriptedTransport(hang=True)
client = _client(
scope,
sources,
limiter,
RedisGate(config=_CFG, redis=a_cli, scope=scope),
transport,
)
task = asyncio.create_task(client.chat([{"role": "user", "content": "hi"}]))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stats = await limiter.source_stats("s1")
assert stats.tpm_used == 400 # 预扣保留, 不回退到 0
assert stats.inflight == 0
async def test_redis_down_admission_fails_closed():