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
+25 -1
View File
@@ -20,6 +20,7 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.config import GatewaySettings
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import PolyGatewayError
from polygateway.middleware.base import compose
from polygateway.middleware.cache import CacheMW
@@ -235,10 +236,15 @@ class GatewayClient:
structured_strategy: StructuredOutputStrategy | None = None,
structured_escalation: StructuredOutputStrategy | None = None,
structured_max_retries: int = 1,
call_deadline_s: float | None = None,
now: Any = time.monotonic,
sleep: Any = asyncio.sleep,
rng: Any = random.random,
) -> None:
# 入口即校: 装配错误当场报,不等到第一次调用才炸
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "GatewayClient(call_deadline_s=...)"
)
emitter = (
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None
@@ -292,6 +298,8 @@ class GatewayClient:
self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal)
# 期限到期需要报出 scope(现之前只传给 RetryMW,未自存)
self._scope = scope
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
@@ -336,6 +344,7 @@ class GatewayClient:
reasoning_effort: Effort | str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
@@ -351,6 +360,11 @@ class GatewayClient:
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值,
正数 = 本次覆盖,**不提供"本次关闭"**。它治理的是**等待**: 到期抛
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
"""
if structured is not None and not self._structured_available:
raise ImportError(
@@ -377,6 +391,13 @@ class GatewayClient:
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
)
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 期限取值与校验必须在创建 awaitable **之前**: 否则非法值抛错时会遗留
# 未 await 的协程(RuntimeWarning + 资源不释放)
deadline = (
self._call_deadline_s
if call_deadline_s is None
else ensure_call_deadline(call_deadline_s, "chat(call_deadline_s=...)")
)
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now)
request = ChatRequest(
@@ -395,7 +416,9 @@ class GatewayClient:
call_context=context,
)
try:
response = await self._handler(request)
response = await with_call_deadline(
self._handler(request), deadline_s=deadline, scope=self._scope
)
except PolyGatewayError as exc:
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
@@ -485,6 +508,7 @@ class GatewayClient:
structured_strategy=strategy,
structured_escalation=escalation,
structured_max_retries=settings.structured_max_retries,
call_deadline_s=settings.call_deadline_s,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过