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