"""`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"