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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user