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
+45
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
from dotenv import dotenv_values
from loguru import logger
from polygateway.deadline import ensure_call_deadline
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
@@ -182,6 +183,11 @@ class GatewaySettings:
pricing_path: str | None
structured_max_retries: int
lease_ttl_s: float
# 一次逻辑调用的**可选**墙钟硬边界(issue #22)。缺省 None = 不启用,行为逐字
# 等于 1.3.5;有默认值故追加在末尾,不扰动既有位置构造。`EmbeddingSettings.gateway`
# 与 `OcrSettings.gateway` 自动继承。值域由 `_validate_call_deadline` 把关,
# 直接构造、`dataclasses.replace` 与 env 三条路一致
call_deadline_s: float | None = None
def __post_init__(self) -> None:
self._normalize()
@@ -192,6 +198,7 @@ class GatewaySettings:
self._validate_lease()
self._validate_stall()
self._validate_probe()
self._validate_call_deadline()
def _normalize(self) -> None:
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
@@ -345,6 +352,19 @@ class GatewaySettings:
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
)
def _validate_call_deadline(self) -> None:
"""期限值域守卫: 盖住直接构造与 `dataclasses.replace` 两条路(issue #22)。
env 路已在 `_load_call_deadline` 里带真实键名报过错, 此处对合法值是幂等空操作。
**不校验**它与 `timeout_s`/`stall_window_s` 的大小关系: 期限短于单次超时
是调用方的合法选择(要的就是“不让这次调用拖过 N 秒”)。
"""
object.__setattr__(
self,
"call_deadline_s",
ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"),
)
@classmethod
def from_env(
cls,
@@ -373,6 +393,7 @@ class GatewaySettings:
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
call_deadline_s=_load_call_deadline(scope_u, env),
**_load_pgw(env),
)
@@ -671,6 +692,30 @@ def _load_lease_ttl(env: Mapping[str, str]) -> float:
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
def _load_call_deadline(scope: str, env: Mapping[str, str]) -> float | None:
"""读 `{SCOPE}__CALL_DEADLINE_S`(issue #22);键未设即 None = 不启用。
用 `_first` 而非 `_require`: 后者会把"未设"当成配置缺失报错,对存量下游
就是破坏性变更。键名两段式(`split("__")` 长度 2 ≠ 4),故 `_load_sources`
天然跳过它,不必进 `_RESERVED_SEGMENTS`。
origin 传**实际命中的 env 键名**而非字段名: `_cast` 只接得住"不是数字",
`0`/负数/`inf` 会穿过它落到 `ensure_call_deadline`——那时报一条指向字段名的错误,
在多 scope 部署里无法定位是哪个键写错了。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
一次逻辑调用的墙钟期限(秒);键未设或为空串时返回 None(不启用)。
"""
found = _first(env, f"{scope}__CALL_DEADLINE_S")
if found is None:
return None
return ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])
@dataclass(frozen=True)
class EmbeddingSettings:
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。