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:
+29
-2
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
||||
from loguru import logger
|
||||
|
||||
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
GovernanceBackendError,
|
||||
@@ -115,10 +116,15 @@ class OcrClient:
|
||||
circuit_open: str = "fail_fast",
|
||||
telemetry: TelemetryRecorder | None = None,
|
||||
text_cap: 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,
|
||||
) -> None:
|
||||
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
||||
self._call_deadline_s = ensure_call_deadline(
|
||||
call_deadline_s, "OcrClient(call_deadline_s=...)"
|
||||
)
|
||||
self._scope = scope
|
||||
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
||||
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||
@@ -171,10 +177,14 @@ class OcrClient:
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
call_deadline_s: float | None = None,
|
||||
) -> OcrTextResult:
|
||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
|
||||
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
|
||||
它治理的是**等待**——到期不等于未产出,返回时刻 = 期限 + 清理耗时。
|
||||
"""
|
||||
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
||||
@@ -189,6 +199,7 @@ class OcrClient:
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
call_deadline_s,
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrTextResult(
|
||||
@@ -209,10 +220,13 @@ class OcrClient:
|
||||
parent_call_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
call_deadline_s: float | None = None,
|
||||
) -> OcrLayoutResult:
|
||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
||||
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||
|
||||
`call_deadline_s` 同 `recognize_text`(issue #22): `None` = 继承装配值。
|
||||
"""
|
||||
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||
@@ -226,6 +240,7 @@ class OcrClient:
|
||||
parent_call_id,
|
||||
dimension_tenant_id,
|
||||
dimensions,
|
||||
call_deadline_s,
|
||||
)
|
||||
result = outcome.result
|
||||
return OcrLayoutResult(
|
||||
@@ -262,17 +277,28 @@ class OcrClient:
|
||||
parent_call_id: str | None,
|
||||
tenant_id: str | None,
|
||||
meta: dict[str, Any],
|
||||
call_deadline_s: float | None = None,
|
||||
) -> tuple[_AttemptOutcome, CallStats]:
|
||||
if not isinstance(image, bytes):
|
||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||
if not image:
|
||||
raise ValueError("image 不能为空")
|
||||
# 期限校验与 `image` 校验同列(仍在 `_CallContext` 之前、创建 awaitable 之前)
|
||||
deadline = (
|
||||
self._call_deadline_s
|
||||
if call_deadline_s is None
|
||||
else ensure_call_deadline(call_deadline_s, f"{operation}(call_deadline_s=...)")
|
||||
)
|
||||
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
||||
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
||||
context = _CallContext(now=self._now)
|
||||
try:
|
||||
return await self._run(
|
||||
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
||||
return await with_call_deadline(
|
||||
self._run(
|
||||
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
||||
),
|
||||
deadline_s=deadline,
|
||||
scope=self._scope,
|
||||
)
|
||||
except PolyGatewayError as exc:
|
||||
await self._emit_terminal(
|
||||
@@ -670,6 +696,7 @@ class OcrClient:
|
||||
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||
# 一半不受控(issue #12)
|
||||
text_cap=gw.telemetry_text_cap,
|
||||
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