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:
+120
-1
@@ -76,6 +76,8 @@ class FakeTransport:
|
||||
self.script = list(script)
|
||||
self.calls = []
|
||||
self.efforts = []
|
||||
# 取消用例的确定性窗口: 进入 hang 分支即置位, 用例据此取消而非 sleep 猜时长
|
||||
self.entered = asyncio.Event()
|
||||
|
||||
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
||||
self.calls.append((source.name, call_id))
|
||||
@@ -84,10 +86,37 @@ class FakeTransport:
|
||||
if isinstance(action, Exception):
|
||||
raise action
|
||||
if action == "hang":
|
||||
self.entered.set()
|
||||
await asyncio.Event().wait()
|
||||
return action
|
||||
|
||||
|
||||
class HangingGate(InMemoryGate):
|
||||
"""在指定记账写回处永久挂起的门控: 把"取消落在某个 await 上"变成确定性事件。
|
||||
|
||||
只覆盖 `record_success` / `record_failure` 两个写回点, 其余行为沿用真实内存实现。
|
||||
"""
|
||||
|
||||
def __init__(self, *, hang_on, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._hang_on = hang_on
|
||||
self.entered = asyncio.Event()
|
||||
|
||||
async def _hang(self):
|
||||
self.entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def record_success(self, entry, *, count_attempt=True):
|
||||
if self._hang_on == "success":
|
||||
await self._hang()
|
||||
return await super().record_success(entry, count_attempt=count_attempt)
|
||||
|
||||
async def record_failure(self, entry, reason, force_open):
|
||||
if self._hang_on == "failure":
|
||||
await self._hang()
|
||||
return await super().record_failure(entry, reason, force_open)
|
||||
|
||||
|
||||
class FakeSleep:
|
||||
"""记录退避时长,立即返回(不真等)。"""
|
||||
|
||||
@@ -109,6 +138,7 @@ def _harness(
|
||||
rng=lambda: 0.0,
|
||||
selector=None,
|
||||
pacer=None,
|
||||
gate=None,
|
||||
):
|
||||
clock = clock or FakeClock()
|
||||
limiter = InMemoryLimiter(
|
||||
@@ -118,7 +148,7 @@ def _harness(
|
||||
lease_ttl_s=100.0,
|
||||
now=clock,
|
||||
)
|
||||
gate = InMemoryGate(config=_BREAKER, now=clock)
|
||||
gate = gate if gate is not None else InMemoryGate(config=_BREAKER, now=clock)
|
||||
transport = FakeTransport(script)
|
||||
sleep = FakeSleep()
|
||||
mw = RetryMW(
|
||||
@@ -467,6 +497,95 @@ class TestScopeUnavailable:
|
||||
assert resp.content == "ok" and released["done"]
|
||||
|
||||
|
||||
class TestCancellationSettlement:
|
||||
"""1.3.6 §6.3 结算矩阵: 取消时 `settle()` 的取值只由"该刻库知道什么"决定。
|
||||
|
||||
取消窗口一律用真实 `asyncio.Event` 钉死(不用 sleep 撞窗口), 否则红绿都不可信。
|
||||
"""
|
||||
|
||||
async def test_cancel_in_flight_keeps_the_reservation(self):
|
||||
"""S3: transport 在途被取消 → 端口已开始、用量未知 → 保留预扣(不凭空退款)。"""
|
||||
mw, limiter, _, transport, *_ = _harness(
|
||||
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], ["hang"]
|
||||
)
|
||||
task = asyncio.ensure_future(mw(_REQ))
|
||||
await transport.entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
stats = await limiter.source_stats("a")
|
||||
assert stats.tpm_used == 400 # est 保留, 而非退成 0
|
||||
assert stats.inflight == 0
|
||||
|
||||
async def test_cancel_after_usage_known_keeps_real_usage(self):
|
||||
"""S4: 真实 usage 已算出后被取消 → 结算仍是真实值, 不被 est 覆写。"""
|
||||
clock = FakeClock()
|
||||
gate = HangingGate(hang_on="success", config=_BREAKER, now=clock)
|
||||
mw, limiter, *_ = _harness(
|
||||
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||
[_ok()],
|
||||
clock=clock,
|
||||
gate=gate,
|
||||
)
|
||||
task = asyncio.ensure_future(mw(_REQ))
|
||||
await gate.entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert (await limiter.source_stats("a")).tpm_used == 15 # 10+5 实测
|
||||
|
||||
async def test_cancel_in_dead_failure_branch_keeps_full_refund(self):
|
||||
"""S5-dead: 源已判死时的既有 `0` 不得因取消退化成 est(不得继续占额度)。"""
|
||||
clock = FakeClock()
|
||||
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
|
||||
mw, limiter, *_ = _harness(
|
||||
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||
[SourceDeadError("401", source_name="a", status_code=401)],
|
||||
clock=clock,
|
||||
gate=gate,
|
||||
)
|
||||
task = asyncio.ensure_future(mw(_REQ))
|
||||
await gate.entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert (await limiter.source_stats("a")).tpm_used == 0
|
||||
|
||||
async def test_cancel_in_transient_failure_branch_keeps_the_reservation(self):
|
||||
"""S5-transient: 瞬时失败的结算决定在首个 await 之前定死, 取消拿到同一个 est。"""
|
||||
clock = FakeClock()
|
||||
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
|
||||
mw, limiter, *_ = _harness(
|
||||
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||
[TransientError("boom", source_name="a", status_code=500)],
|
||||
clock=clock,
|
||||
gate=gate,
|
||||
)
|
||||
task = asyncio.ensure_future(mw(_REQ))
|
||||
await gate.entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert (await limiter.source_stats("a")).tpm_used == 400
|
||||
|
||||
async def test_unclassified_exception_still_refunds_in_full(self):
|
||||
"""S8 防越界: 未分类异常(无 except 接住)仍逐字走 1.3.5 的全额退还。"""
|
||||
mw, limiter, *_ = _harness(
|
||||
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], [RuntimeError("boom")]
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
await mw(_REQ)
|
||||
stats = await limiter.source_stats("a")
|
||||
assert stats.tpm_used == 0 and stats.inflight == 0
|
||||
|
||||
async def test_real_zero_usage_success_settles_zero(self):
|
||||
"""防越界: 真实 usage 恰为 0 是**已知事实**, 不得被当成"未知"改按 est 结算。"""
|
||||
zero = dataclasses.replace(_ok(), prompt_tokens=0, completion_tokens=0)
|
||||
mw, limiter, *_ = _harness([_src("a", tpm=1000, est_tokens=400)], [zero])
|
||||
await mw(_REQ)
|
||||
assert (await limiter.source_stats("a")).tpm_used == 0
|
||||
|
||||
|
||||
class TestCancellation:
|
||||
async def test_cancel_mid_flight_releases_permit(self):
|
||||
mw, limiter, _, _, _, _ = _harness([_src("a", max_concurrency=1)], ["hang"])
|
||||
|
||||
Reference in New Issue
Block a user