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:
2026-09-10 01:13:00 -04:00
parent 1ff83bbfe0
commit 9474c76ab0
13 changed files with 730 additions and 5 deletions
+212
View File
@@ -1,8 +1,10 @@
"""GatewayClient 装配与端到端(fake 后端 + MockTransport)测试(设计 §2.4)。"""
import asyncio
import gc
import json
import sys
import warnings
from pathlib import Path
import httpx
@@ -10,10 +12,12 @@ import pytest
from polygateway import (
AllSourcesExhausted,
CallDeadlineExceeded,
GatewayClient,
GatewaySettings,
RequestRejectedError,
ResultInvalidError,
TransientError,
gather_bounded,
)
from polygateway.backends.memory.breaker import InMemoryGate
@@ -33,6 +37,10 @@ from polygateway.types import (
SourceConfig,
)
# 复用 RetryMW 那份可编程 fake transport(含确定性 `entered` 窗口),不再造第二份;
# `tests/unit/test_backpressure.py:34` 已是同款复用
from tests.unit.test_retry import FakeTransport, _ok
_REPO = Path(__file__).resolve().parents[2]
_ENV = {
@@ -1717,3 +1725,207 @@ class TestTerminalEmitDegradation:
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
operation="chat",
)
class _ClockJumpTransport:
"""假 transport: 只推进**注入钟**,真实墙钟几乎不走。
用于把"期限读哪只钟""统计读哪只钟"两件事分开断言。
"""
def __init__(self, clock, *, jump):
self._clock = clock
self._jump = jump
self.calls = []
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
self.calls.append(call_id)
self._clock.advance(self._jump)
return _ok()
class _SlowRecorder:
"""假 recorder: 每写一行真实等待一段,用于量化"清理不被期限截断""""
def __init__(self, delay=0.15):
self._delay = delay
self.rows = []
async def record_llm_call(self, **fields):
await asyncio.sleep(self._delay)
self.rows.append(fields)
class _SlowSetCache:
"""假缓存后端: `set` 慢于期限,用于构造"已产出、已计费的成功被丢弃""""
def __init__(self, delay=0.5):
self._delay = delay
self.data = {}
self.sets = 0
async def get(self, key):
return self.data.get(key)
async def set(self, key, value, ttl_s):
self.sets += 1
await asyncio.sleep(self._delay)
self.data[key] = value
class TestChatCallDeadline:
"""chat 链路的期限覆盖面与到期代价(计划 §5 批次 D/D2)。
真实事件循环时钟: 期限 0.05s 对被治理的等待(退避 5s、轮询 300s、慢 IO 0.5s)
有 10 倍以上余量,故不标 slow。
"""
_MSG = [{"role": "user", "content": "hi"}]
_DEADLINE = 0.05
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
# —— 批次 D: 期限落点覆盖面 ——
async def test_deadline_fires_during_backoff_sleep(self):
"""退避 sleep 是等待的大头(429 序列可睡到小时级),期限必须能在它中间落地。"""
transport = FakeTransport([TransientError("boom", operation="chat"), _ok()])
async with _client(transport=transport, retry=RetryPolicy(3, 5.0, 30.0)) as client:
with pytest.raises(CallDeadlineExceeded) as exc:
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert exc.value.scope == "llm" and exc.value.deadline_s == self._DEADLINE
# 第二次尝试还压在 5s 退避里,期限确实落在 sleep 上而非 transport 上
assert len(transport.calls) == 1
async def test_deadline_fires_while_queued_for_quota(self):
"""准入排队(配额满轮询)是第二类长等待: 一次 transport 都没打出去也要能到期。"""
source = _source(tpm=1, est_tokens=1000) # 预扣量恒超本源 TPM → 六闸永不放行
limiter = InMemoryLimiter(
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(0, 0, 0)
)
transport = FakeTransport([_ok()])
async with _client([source], transport=transport, limiter=limiter) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert transport.calls == [] # 期限落在轮询里,尝试从未开始
async def test_deadline_fires_during_structured_re_ask(self):
"""结构化重问共享同一份期限: 阶梯不得按轮数各起一份,否则期限被放大 N 倍。
窗口用"第三轮挂起"构造而非 sleep 猜时长——前两轮瞬时返回坏 JSON,
期限只可能落在第三轮上,断言因此与机器负载无关。
"""
from pydantic import BaseModel
class Answer(BaseModel):
answer: int
transport = FakeTransport([_ok("not json at all"), _ok("not json at all"), "hang"])
async with _client(
transport=transport, structured_max_retries=5, structured_strategy=JsonRepairStrategy()
) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, structured=Answer, call_deadline_s=0.05)
# 到期发生在第三轮: 期限确实跨过了两次重问,而不是在首轮就截断
assert len(transport.calls) == 3
# —— 批次 D2: 到期代价 ——
async def test_expiry_writes_one_terminal_row_and_a_cancelled_attempt(self):
"""到期恰好一条终态行 + 被取消的 attempt 行,两行同一 logical_call_id。"""
recorder = _MemoryRecorder()
transport = FakeTransport(["hang"])
async with _client(transport=transport, telemetry=recorder) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(terminals) == 1
assert terminals[0]["error_type"] == "CallDeadlineExceeded"
assert len(attempts) == 1 and attempts[0]["error"] == "cancelled"
assert attempts[0]["logical_call_id"] == terminals[0]["logical_call_id"]
# 零新增遥测列: 期限终态行的列集合与既有失败路径的终态行逐字相同
baseline = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(400, json={"error": {"message": "bad"}}),
telemetry=baseline,
) as client:
with pytest.raises(RequestRejectedError):
await client.chat(self._MSG)
assert set(terminals[0]) == set(self._rows(baseline, "terminal_failure")[0])
async def test_cleanup_is_not_cut_short_by_the_expiry(self):
"""返回时刻 = 期限 + 清理耗时: 只断下界(> 期限 × 2),不断上界。"""
recorder = _SlowRecorder(delay=0.15) # attempt 行与终态行各付一次
transport = FakeTransport(["hang"])
loop = asyncio.get_running_loop()
started = loop.time()
async with _client(transport=transport, telemetry=recorder) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
elapsed = loop.time() - started
assert len(recorder.rows) == 2 # 清理照常写完两行,没被期限截断
assert elapsed > self._DEADLINE * 2, f"清理疑似被截断: {elapsed}s"
async def test_expiry_discards_a_success_that_was_already_billed(self):
"""到期 ≠ 未产出、未计费: transport 已成功一次,结果仍被丢弃。"""
transport = FakeTransport([_ok()])
cache = _SlowSetCache(delay=0.5) # 写缓存慢于期限 → 到期落在成功之后
async with _client(
transport=transport, cache=cache, cache_namespace="proj", cache_ttl_s=600
) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert len(transport.calls) == 1 # 上游已经产出并计费
assert cache.sets == 1 and cache.data == {} # 结果既没回给调用方也没落缓存
# —— 批次 E: 注入钟与期限正交 ——
async def test_injected_clock_jump_does_not_trigger_the_deadline(self):
"""期限只认真实墙钟: 注入钟跳 10^6 秒也不该凭空到期(不换算绝对截止时刻)。"""
clock = _StatsClock()
transport = _ClockJumpTransport(clock, jump=1_000_000.0)
async with _client(transport=transport, now=clock) as client:
resp = await client.chat(self._MSG, call_deadline_s=5.0)
assert resp.content == "ok"
# 而统计仍逐字读注入钟(10^6 s = 10^9 ms),两只钟各司其职
assert resp.call_stats is not None
assert resp.call_stats.total_latency_ms == 1_000_000_000
async def test_expiry_latency_still_reads_the_injected_clock(self):
"""期限由真实钟触发,终态行的耗时仍取自注入钟(真实耗时只有几十毫秒)。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=0.5)
transport = FakeTransport(["hang"])
async with _client(transport=transport, telemetry=recorder, now=clock) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
terminal = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"][0]
assert terminal["total_latency_ms"] == 500 # attempt 行那一次 tick,不是真实的 ~50ms
class TestChatCallDeadlineEntryGuards:
"""per-call 入口校验的两条硬红线(计划 §3.4/§5 批次 E)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_illegal_per_call_value_leaves_no_un_awaited_coroutine(self):
"""校验先于构造 awaitable: 否则非法值抛错时遗留未 await 的协程(资源不释放)。"""
transport = FakeTransport([_ok()])
async with _client(transport=transport) as client:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with pytest.raises(ValueError, match=r"chat\(call_deadline_s"):
await client.chat(self._MSG, call_deadline_s=0)
gc.collect() # 未 await 的协程在回收时才发 RuntimeWarning
assert [w for w in caught if "never awaited" in str(w.message)] == []
assert transport.calls == []
async def test_per_call_none_inherits_the_assembled_deadline(self):
"""`None` = 继承装配值(不提供"本次关闭"): 装配了期限就照样到期。"""
transport = FakeTransport(["hang"])
async with _client(transport=transport, call_deadline_s=0.05) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG)
+55
View File
@@ -1041,3 +1041,58 @@ def test_live_unknown_wire_assembly_is_local_only():
GatewayClient.from_settings(
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
)
class TestCallDeadlineConfig:
"""`{SCOPE}__CALL_DEADLINE_S` 与三个 client 入口参数的值域四条路(issue #22)。"""
def test_key_unset_means_disabled(self):
assert GatewaySettings.from_env("LLM", env=_env()).call_deadline_s is None
def test_env_key_parsed(self):
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "30"}))
assert s.call_deadline_s == 30.0
@pytest.mark.parametrize("bad", ["0", "-1", "nan", "inf", "abc"])
def test_env_illegal_value_reports_the_actual_key_name(self, bad):
"""origin 必须是实际命中的 env 键名,多 scope 部署里才定位得到是哪个键。"""
with pytest.raises(ValueError, match="LLM__CALL_DEADLINE_S"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": bad}))
def test_direct_construction_is_guarded(self):
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
dataclasses.replace(base, call_deadline_s=0)
def test_plain_constructor_call_is_guarded_too(self):
"""`dataclasses.replace` 与直接构造是两条路: 守卫在 `__post_init__` 才两条都盖住。
只在 `from_env` 里校验的话,直接 `GatewaySettings(...)` 装配的下游(测试/高级
注入路径,CLAUDE.md §4.5 的第二条装配路)会把非法期限一路带到第一次调用才炸。
"""
base = GatewaySettings.from_env("LLM", env=_env())
fields = {f.name: getattr(base, f.name) for f in dataclasses.fields(base)}
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
GatewaySettings(**{**fields, "call_deadline_s": float("inf")})
# 合法值走同一条路不受影响(守卫对合法值是幂等空操作)
assert GatewaySettings(**{**fields, "call_deadline_s": 7}).call_deadline_s == 7.0
def test_replace_with_legal_value_is_idempotent(self):
base = GatewaySettings.from_env("LLM", env=_env())
assert dataclasses.replace(base, call_deadline_s=5).call_deadline_s == 5.0
def test_deadline_shorter_than_timeout_is_legal(self):
"""期限短于单次 timeout_s 是调用方的合法选择,不做跨字段耦合校验。"""
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "1"}))
assert s.call_deadline_s == 1.0 and s.sources[0].timeout_s == 120.0
def test_from_settings_propagates_to_client(self):
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "12"}))
assert GatewayClient.from_settings(s)._call_deadline_s == 12.0
def test_client_init_validates_at_entry(self):
"""三个 client 的 `__init__` 直传非法值也当场报错(不经 settings 那道守卫)。"""
from tests.unit.test_client import _client
with pytest.raises(ValueError, match=r"GatewayClient\(call_deadline_s"):
_client(call_deadline_s=0)
+142
View File
@@ -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"
+68
View File
@@ -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)
+27
View File
@@ -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)