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:
@@ -13,6 +13,7 @@ import pytest
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.errors import (
|
||||
CallDeadlineExceeded,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
SourceDeadError,
|
||||
@@ -662,3 +663,70 @@ class TestEmbedLogicalCallStats:
|
||||
assert resp.call_stats.attempts == 0
|
||||
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
|
||||
assert rec.rows == [] # 零遥测行
|
||||
|
||||
|
||||
class _SlowEmbedTransport:
|
||||
"""假 embedding transport: 每批真实耗时 `delay` 秒。
|
||||
|
||||
"N 批共享一份期限"只能用真实等待来证——`asyncio.timeout` 认的是事件循环
|
||||
时钟,注入钟推不动它(计划 §5 批次 D)。
|
||||
"""
|
||||
|
||||
def __init__(self, *, delay):
|
||||
self._delay = delay
|
||||
self.calls = []
|
||||
|
||||
async def embed(self, *, texts, source, call_id):
|
||||
self.calls.append(list(texts))
|
||||
await asyncio.sleep(self._delay)
|
||||
return _vec_for(texts)
|
||||
|
||||
|
||||
class TestEmbedCallDeadline:
|
||||
"""embedding 的期限语义: 整次调用一份,空输入豁免(计划 §5 批次 D/E)。"""
|
||||
|
||||
async def test_one_deadline_is_shared_across_all_batches(self):
|
||||
"""按批各起一份会让期限被批数放大 N 倍: 单批 0.05s 远小于期限 0.5s 时将永不到期。
|
||||
|
||||
余量刷到 10 倍(单批 0.05s vs 期限 0.5s): 要报假结论得单批慢 10 倍,
|
||||
而不是机器抳一下就变色。
|
||||
"""
|
||||
transport = _SlowEmbedTransport(delay=0.05)
|
||||
client, _ = _embed_client([_src()], [], batch_size=1, transport=transport)
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||
await client.embed([str(i) for i in range(20)], call_deadline_s=0.5)
|
||||
elapsed = loop.time() - started
|
||||
assert exc.value.scope == "embed"
|
||||
# 按批计的话 20 批全都能跑完(根本不会抛),共享一份则跑不到头
|
||||
assert 2 <= len(transport.calls) < 20
|
||||
assert elapsed < 20 * 0.05, f"总时长疑似随批数放大: {elapsed}s"
|
||||
|
||||
async def test_empty_input_is_exempt_from_the_deadline(self):
|
||||
"""`texts == []` 早返回在 try 之外(零尝试、无等待可治),再小的期限也不该拦它。"""
|
||||
transport = ScriptedEmbedTransport([])
|
||||
client, _ = _embed_client([_src()], [], transport=transport)
|
||||
resp = await client.embed([], call_deadline_s=1e-6)
|
||||
assert resp.vectors == []
|
||||
assert resp.call_stats is not None and resp.call_stats.attempts == 0
|
||||
assert transport.calls == []
|
||||
|
||||
async def test_the_same_tiny_deadline_does_fire_on_a_non_empty_input(self):
|
||||
"""对照组: 上一条用的 1e-6 秒确实是会到期的值,豁免不是因为期限没生效。"""
|
||||
transport = _SlowEmbedTransport(delay=0.05)
|
||||
client, _ = _embed_client([_src()], [], transport=transport)
|
||||
with pytest.raises(CallDeadlineExceeded):
|
||||
await client.embed(["a"], call_deadline_s=1e-6)
|
||||
|
||||
async def test_illegal_per_call_value_is_rejected_at_the_entry(self):
|
||||
"""per-call 非法值当场 ValueError,且消息指向 `embed(...)` 而非某个 env 键。"""
|
||||
transport = ScriptedEmbedTransport([])
|
||||
client, _ = _embed_client([_src()], [], transport=transport)
|
||||
with pytest.raises(ValueError, match=r"embed\(call_deadline_s"):
|
||||
await client.embed(["a"], call_deadline_s=0)
|
||||
assert transport.calls == []
|
||||
|
||||
def test_illegal_constructor_value_is_rejected_at_assembly(self):
|
||||
with pytest.raises(ValueError, match=r"EmbeddingClient\(call_deadline_s"):
|
||||
_embed_client([_src()], [], call_deadline_s=-1)
|
||||
|
||||
Reference in New Issue
Block a user