diff --git a/src/polygateway/client.py b/src/polygateway/client.py index fe288fb..6c884f8 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -634,6 +634,7 @@ async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) -> """有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。 语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。 + 期限计时从每次调用真正开始执行起算,信号量排队时长不在 `call_deadline_s` 之内。 """ if concurrency < 1: raise ValueError("concurrency 必须 ≥ 1") diff --git a/src/polygateway/deadline.py b/src/polygateway/deadline.py index 22e5433..3c7dcf0 100644 --- a/src/polygateway/deadline.py +++ b/src/polygateway/deadline.py @@ -65,7 +65,7 @@ async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, s # 体内(含清理路径)自抛的 TimeoutError 的**身份**,唯一可靠的区分依据 inner_timeout: BaseException | None = None # 先建对象再进上下文: `as cm` 只在 `__aenter__` 返回后才绑定, 而 except 块无条件 - # 读 `cm`——进入阶段一旦异常就会变成 NameError 掩盖真实错误 + # 读 `cm`——进入阶段一旦抛 TimeoutError 就会变成 NameError 掩盖真实错误 cm = asyncio.timeout(deadline_s) try: async with cm: diff --git a/tests/unit/test_deadline.py b/tests/unit/test_deadline.py index cce6abc..ab56536 100644 --- a/tests/unit/test_deadline.py +++ b/tests/unit/test_deadline.py @@ -5,6 +5,7 @@ """ import asyncio +import time import pytest @@ -124,14 +125,23 @@ async def test_narrow_success_returns_value_without_pending_cancellation(): async def test_domain_error_inside_window_propagates(): + """计时器已触发但取消尚未投递的窗口内,体内先抛领域异常 → 原样上抛,期限静默让位。 + + 忙等超过期限: 计时器回调已在 loop 上触发,但任务不挂起取消就投递不进来, + 此刻体内同步抛出的领域异常必须原样逃逸(设计 §5.2 形态四)。 + """ + class BoomError(RuntimeError): pass async def body(): + end = time.monotonic() + 0.1 + while time.monotonic() < end: + pass raise BoomError("boom") with pytest.raises(BoomError): - await with_call_deadline(body(), deadline_s=0.05, scope="llm") + await with_call_deadline(body(), deadline_s=0.02, scope="llm") async def test_none_deadline_takes_the_legacy_path(): diff --git a/tests/unit/test_embedding.py b/tests/unit/test_embedding.py index 0a2c825..430231d 100644 --- a/tests/unit/test_embedding.py +++ b/tests/unit/test_embedding.py @@ -700,7 +700,7 @@ class TestEmbedCallDeadline: elapsed = loop.time() - started assert exc.value.scope == "embed" # 按批计的话 20 批全都能跑完(根本不会抛),共享一份则跑不到头 - assert 2 <= len(transport.calls) < 20 + assert 1 <= len(transport.calls) < 20 assert elapsed < 20 * 0.05, f"总时长疑似随批数放大: {elapsed}s" async def test_empty_input_is_exempt_from_the_deadline(self): diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index efc7b81..214b487 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -585,6 +585,22 @@ class TestCancellationSettlement: await mw(_REQ) assert (await limiter.source_stats("a")).tpm_used == 0 + async def test_circuit_open_rejection_settles_zero_end_to_end(self): + """S1 端到端: 开路拒绝的 pick 预扣后按 0 结算, 不给 tpm_used 增加任何量。""" + clock = FakeClock() + script = [TransientError(str(i)) for i in range(9)] + mw, limiter, *_ = _harness( + [_src("a", max_concurrency=1, tpm=10000, est_tokens=400)], + script, + clock=clock, + max_attempts=99, + ) + # 3 次瞬时失败后 a 开路 → 第 4 次 pick 被拒绝 + with pytest.raises(CircuitOpenError): + await mw(_REQ) + # 三次瞬时失败各保留 est = 1200; 开路那次 pick 若漏了 settle(0) 会再 +400 + assert (await limiter.source_stats("a")).tpm_used == 1200 + class TestCancellation: async def test_cancel_mid_flight_releases_permit(self):