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:
@@ -0,0 +1,80 @@
|
||||
"""一次逻辑调用的**可选**墙钟硬边界(issue #22;1.3.6 设计 §3 方案 A)。
|
||||
|
||||
只依赖标准库与 `errors.py`(依赖铁律最内层),供三个公开边界各包一次:
|
||||
期限治理的是**等待**,不是"到期即无副作用"——在途请求可能已发出、已被上游
|
||||
计费,清理照旧在 `finally` 完成,故返回时刻 = 期限 + 清理耗时。
|
||||
|
||||
缺省 `None` 时**完全不进上下文管理器**,行为逐字等于 1.3.5。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from polygateway.errors import CallDeadlineExceeded
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
def ensure_call_deadline(value: object, origin: str) -> float | None:
|
||||
"""全装配路径共用的期限值域校验: `None` 或**有限正数秒**,否则当场 `ValueError`。
|
||||
|
||||
装配错误不属降级面(缺失/非法配置直接报错,不静默取默认值)。`bool` 必须先判:
|
||||
`isinstance(True, int)` 为真,放行会让 `call_deadline_s=True` 变成"1 秒期限"
|
||||
这种没人写得出来的意图。巨大 int(如 `10**400`)超出 float 值域,`float()` 会抛
|
||||
`OverflowError`——它不是 `ValueError` 的子类,泄漏出去会绕过调用方的
|
||||
`except ValueError`,故在此统一成同一种装配错误。
|
||||
|
||||
`origin` 写进消息,用于在多 scope 部署里定位到底是哪个键/哪个参数非法。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}")
|
||||
try:
|
||||
seconds = float(value)
|
||||
except OverflowError:
|
||||
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") from None
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}")
|
||||
return seconds
|
||||
|
||||
|
||||
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
|
||||
"""给一个 awaitable 加一层可选期限;到期抛 `CallDeadlineExceeded`。
|
||||
|
||||
三条实现红线:
|
||||
|
||||
1. **校验先于构造 awaitable**——调用方必须先 `ensure_call_deadline`,否则非法值
|
||||
抛错时会遗留未 await 的协程(`RuntimeWarning` + 资源不释放)。
|
||||
2. 只用**相对时长**,绝不把注入的 `now` 换算成绝对截止时刻: 注入钟跳变
|
||||
10^6 秒不该凭空触发期限。
|
||||
3. 判据必须是**局部变量身份比较**,不可退化成只看 `cm.expired()`:
|
||||
到期后清理路径自抛的 `TimeoutError` 也发生在 `expired()` 为真时,只看它
|
||||
会把别人的超时改标成本层期限;`__cause__` 启发式同样失效(内层
|
||||
`asyncio.timeout` 抛出的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。
|
||||
|
||||
不新增后台任务、不 `shield`、不改异常对象:外部取消照常以 `CancelledError` 穿透。
|
||||
"""
|
||||
if deadline_s is None:
|
||||
# 未启用: 不进上下文管理器,逐字走 1.3.5 旧路径
|
||||
return await aw
|
||||
# 体内(含清理路径)自抛的 TimeoutError 的**身份**,唯一可靠的区分依据
|
||||
inner_timeout: BaseException | None = None
|
||||
# 先建对象再进上下文: `as cm` 只在 `__aenter__` 返回后才绑定, 而 except 块无条件
|
||||
# 读 `cm`——进入阶段一旦异常就会变成 NameError 掩盖真实错误
|
||||
cm = asyncio.timeout(deadline_s)
|
||||
try:
|
||||
async with cm:
|
||||
try:
|
||||
return await aw
|
||||
except TimeoutError as exc:
|
||||
inner_timeout = exc
|
||||
raise
|
||||
except TimeoutError as exc:
|
||||
if cm.expired() and exc is not inner_timeout:
|
||||
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
|
||||
raise
|
||||
Reference in New Issue
Block a user