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
+24 -2
View File
@@ -28,6 +28,7 @@ from loguru import logger
from polygateway.client import _aclose_component, _telemetry_status_of
from polygateway.config import EmbeddingSettings
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import (
AllSourcesExhausted,
GovernanceBackendError,
@@ -112,6 +113,7 @@ class EmbeddingClient:
batch_size: int,
normalize: bool = False,
expected_dim: int | None = None,
call_deadline_s: float | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random,
@@ -120,6 +122,10 @@ class EmbeddingClient:
raise ValueError("batch_size 必须 ≥ 1")
if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1")
# 入口即校: 装配错误当场报,不等到第一次调用才炸
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "EmbeddingClient(call_deadline_s=...)"
)
self._scope = scope
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
@@ -174,11 +180,16 @@ class EmbeddingClient:
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> EmbeddingResponse:
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
本次调用而非某一批,故每批的遥测行都带同一份维度。
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
**整次调用共享一份**——N 批串行跑在同一条期限内,不按批数放大 N 倍。
`texts == []` 的早返回在期限之外(零尝试,无等待可治)。
"""
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
@@ -188,6 +199,12 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
)
# 期限取值与校验必须在创建 awaitable **之前**(否则遗留未 await 的协程)
deadline = (
self._call_deadline_s
if call_deadline_s is None
else ensure_call_deadline(call_deadline_s, "embed(call_deadline_s=...)")
)
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now)
if not texts:
@@ -206,8 +223,12 @@ class EmbeddingClient:
call_stats=context.snapshot(),
)
try:
return await self._embed_all(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
return await with_call_deadline(
self._embed_all(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
),
deadline_s=deadline,
scope=self._scope,
)
except PolyGatewayError as exc:
await self._emit_terminal(
@@ -634,6 +655,7 @@ class EmbeddingClient:
batch_size=settings.batch_size,
normalize=settings.normalize,
expected_dim=settings.expected_dim,
call_deadline_s=gw.call_deadline_s,
)
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
return client