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 @@ from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CallDeadlineExceeded,
|
||||
CircuitOpenError,
|
||||
RequestRejectedError,
|
||||
ResultInvalidError,
|
||||
@@ -669,3 +670,29 @@ class TestOcrLogicalCallStats:
|
||||
await client.recognize_text("not-bytes")
|
||||
with pytest.raises(ValueError):
|
||||
await client.recognize_text(b"")
|
||||
|
||||
|
||||
class TestOcrCallDeadline:
|
||||
"""OCR 两个公开入口的期限与 per-call 校验(计划 §3.4/§5 批次 D/E)。"""
|
||||
|
||||
async def test_expiry_on_a_hanging_transport(self):
|
||||
client, limiter, _ = _client([_src()], ["hang"])
|
||||
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||
await client.recognize_text(b"jpg", call_deadline_s=0.05)
|
||||
assert exc.value.scope == "ocr" and exc.value.deadline_s == 0.05
|
||||
# 清理照常在 finally 完成: 在途计数必须归零(OCR 无 token,结算恒 0)
|
||||
stats = await limiter.source_stats("m1")
|
||||
assert stats.inflight == 0 and stats.tpm_used == 0
|
||||
|
||||
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||
async def test_illegal_per_call_value_names_the_entry_it_came_from(self, method):
|
||||
"""两个入口各自报自己的名字: 多入口部署里才定位得到是哪次调用传错了。"""
|
||||
transport = ScriptedOcrTransport([])
|
||||
client, _, _ = _client([_src()], [], transport=transport)
|
||||
with pytest.raises(ValueError, match=rf"{method}\(call_deadline_s"):
|
||||
await getattr(client, method)(b"jpg", call_deadline_s=float("inf"))
|
||||
assert transport.calls == []
|
||||
|
||||
def test_illegal_constructor_value_is_rejected_at_assembly(self):
|
||||
with pytest.raises(ValueError, match=r"OcrClient\(call_deadline_s"):
|
||||
_client([_src()], [], call_deadline_s=0)
|
||||
|
||||
Reference in New Issue
Block a user