Files
PolyGateway/tests/unit/test_deadline.py
T

153 lines
4.8 KiB
Python

"""`deadline.py` 值域校验与五种形态区分测试(计划 §5 批次 A/B)。
用真实事件循环时钟(期限 0.05s、体 0.3s,4-10 倍余量),不标 slow:
被测对象是"哪一种 TimeoutError"的身份判据,注入钟无法覆盖 `asyncio.timeout`。
"""
import asyncio
import time
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():
"""计时器已触发但取消尚未投递的窗口内,体内先抛领域异常 → 原样上抛,期限静默让位。
忙等超过期限: 计时器回调已在 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.02, 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"