feat: add an optional per-call wall-clock deadline
Give one logical call an optional hard wall-clock boundary (issue #22). Leaving it unset keeps 1.3.5 behaviour verbatim: the timeout context is never entered when deadline_s is None. - new deadline.py: ensure_call_deadline() range check (None or a finite positive number; bool/0/nan/inf and out-of-range ints are rejected as ValueError so OverflowError never leaks) plus with_call_deadline(), which distinguishes an expiry from a TimeoutError raised by the body or its cleanup via a local-variable identity comparison rather than cm.expired() alone - new CallDeadlineExceeded: deliberately outside the four categories and not a GatewayUnavailableError, and carries no retry_after_s - new {SCOPE}__CALL_DEADLINE_S key, guarded on the env, direct construction and dataclasses.replace paths - three clients take a call_deadline_s constructor argument and a keyword-only per-call override on chat/embed/recognize_text/ parse_layout; None inherits the assembled value - validation runs before the awaitable is created, so an illegal value cannot strand an un-awaited coroutine - one embed call shares a single deadline across all of its batches - import-linter gains a polygateway.deadline layer - cover where the deadline lands: backoff sleep, admission polling, the structured re-ask ladder and embedding's batch loop, plus the empty-texts early return that stays outside it - cover what an expiry costs: exactly one terminal_failure row carrying error_type=CallDeadlineExceeded, a cancelled attempt row sharing its logical_call_id, cleanup that outlives the deadline (lower bound only) and an already-billed success being discarded - pin the injected clock as orthogonal: a 10^6 second jump never expires a call, yet total_latency_ms still reads that clock
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"""`deadline.py` 值域校验与五种形态区分测试(计划 §5 批次 A/B)。
|
||||
|
||||
用真实事件循环时钟(期限 0.05s、体 0.3s,4-10 倍余量),不标 slow:
|
||||
被测对象是"哪一种 TimeoutError"的身份判据,注入钟无法覆盖 `asyncio.timeout`。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||
from polygateway.errors import CallDeadlineExceeded
|
||||
|
||||
# —— 批次 A: 值域 ——
|
||||
|
||||
|
||||
def test_ensure_call_deadline_accepts_none_and_positive():
|
||||
assert ensure_call_deadline(None, "origin") is None
|
||||
assert ensure_call_deadline(3, "origin") == 3.0
|
||||
assert ensure_call_deadline(0.5, "origin") == 0.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[0, 0.0, -1, -0.5, float("nan"), float("inf"), float("-inf"), "1", True, False, object(), []],
|
||||
)
|
||||
def test_ensure_call_deadline_rejects_out_of_range(bad):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
ensure_call_deadline(bad, "GatewayClient(call_deadline_s=...)")
|
||||
assert "GatewayClient(call_deadline_s=...)" in str(exc.value)
|
||||
|
||||
|
||||
def test_ensure_call_deadline_rejects_huge_int_without_leaking_overflow():
|
||||
"""超出 float 值域的巨大 int 也统一 ValueError,不泄漏 OverflowError。"""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
ensure_call_deadline(10**400, "origin")
|
||||
assert "origin" in str(exc.value)
|
||||
|
||||
|
||||
# —— 批次 B: 五种形态 ——
|
||||
|
||||
|
||||
async def test_deadline_expiry_raises_call_deadline_exceeded():
|
||||
async def body():
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
|
||||
assert exc.value.scope == "llm"
|
||||
assert exc.value.deadline_s == 0.05
|
||||
|
||||
|
||||
async def test_inner_timeout_before_expiry_propagates_as_is():
|
||||
async def body():
|
||||
async with asyncio.timeout(0.01):
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
with pytest.raises(TimeoutError) as exc:
|
||||
await with_call_deadline(body(), deadline_s=5.0, scope="llm")
|
||||
assert not isinstance(exc.value, CallDeadlineExceeded)
|
||||
|
||||
|
||||
async def test_cleanup_timeout_after_expiry_is_not_relabelled():
|
||||
"""到期后清理路径自抛 TimeoutError → 原样上抛(钉住身份比较,不看 expired())。"""
|
||||
|
||||
async def body():
|
||||
try:
|
||||
await asyncio.sleep(0.3)
|
||||
except asyncio.CancelledError:
|
||||
raise TimeoutError("cleanup") from None
|
||||
|
||||
with pytest.raises(TimeoutError) as exc:
|
||||
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
|
||||
assert not isinstance(exc.value, CallDeadlineExceeded)
|
||||
assert str(exc.value) == "cleanup"
|
||||
|
||||
|
||||
async def test_external_cancel_before_expiry_propagates_cancelled():
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def body():
|
||||
entered.set()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
task = asyncio.create_task(with_call_deadline(body(), deadline_s=5.0, scope="llm"))
|
||||
await entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_external_cancel_after_expiry_propagates_cancelled():
|
||||
"""到期已在途、外部又取消 → 仍是 CancelledError(取消优先,不被改标)。"""
|
||||
|
||||
started = asyncio.Event()
|
||||
|
||||
async def body():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.sleep(0.3)
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.sleep(0.2) # 清理期,期间遭外部取消
|
||||
raise
|
||||
|
||||
task = asyncio.create_task(with_call_deadline(body(), deadline_s=0.05, scope="llm"))
|
||||
await started.wait()
|
||||
await asyncio.sleep(0.1) # 让期限先到期,进入清理
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_narrow_success_returns_value_without_pending_cancellation():
|
||||
async def body():
|
||||
await asyncio.sleep(0.01)
|
||||
return "ok"
|
||||
|
||||
async def runner():
|
||||
return await with_call_deadline(body(), deadline_s=0.2, scope="llm")
|
||||
|
||||
task = asyncio.create_task(runner())
|
||||
assert await task == "ok"
|
||||
assert task.cancelling() == 0
|
||||
|
||||
|
||||
async def test_domain_error_inside_window_propagates():
|
||||
class BoomError(RuntimeError):
|
||||
pass
|
||||
|
||||
async def body():
|
||||
raise BoomError("boom")
|
||||
|
||||
with pytest.raises(BoomError):
|
||||
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
|
||||
|
||||
|
||||
async def test_none_deadline_takes_the_legacy_path():
|
||||
async def body():
|
||||
await asyncio.sleep(0.05)
|
||||
return "ok"
|
||||
|
||||
assert await with_call_deadline(body(), deadline_s=None, scope="llm") == "ok"
|
||||
Reference in New Issue
Block a user