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:
@@ -10,6 +10,7 @@ from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
||||
from polygateway.embedding import EmbeddingClient
|
||||
from polygateway.errors import (
|
||||
AllSourcesExhausted,
|
||||
CallDeadlineExceeded,
|
||||
CircuitOpenError,
|
||||
GatewayUnavailableError,
|
||||
GovernanceBackendError,
|
||||
@@ -59,6 +60,7 @@ __all__ = [
|
||||
"Effort",
|
||||
"AllSourcesExhausted",
|
||||
"CallStats",
|
||||
"CallDeadlineExceeded",
|
||||
"CircuitOpenError",
|
||||
"EmbeddingClient",
|
||||
"EmbeddingResponse",
|
||||
|
||||
@@ -20,6 +20,7 @@ from polygateway.backends.memory.breaker import InMemoryGate
|
||||
from polygateway.backends.memory.cache import InMemoryCache
|
||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||
from polygateway.config import GatewaySettings
|
||||
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||
from polygateway.errors import PolyGatewayError
|
||||
from polygateway.middleware.base import compose
|
||||
from polygateway.middleware.cache import CacheMW
|
||||
@@ -235,10 +236,15 @@ class GatewayClient:
|
||||
structured_strategy: StructuredOutputStrategy | None = None,
|
||||
structured_escalation: StructuredOutputStrategy | None = None,
|
||||
structured_max_retries: int = 1,
|
||||
call_deadline_s: float | None = None,
|
||||
now: Any = time.monotonic,
|
||||
sleep: Any = asyncio.sleep,
|
||||
rng: Any = random.random,
|
||||
) -> None:
|
||||
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
||||
self._call_deadline_s = ensure_call_deadline(
|
||||
call_deadline_s, "GatewayClient(call_deadline_s=...)"
|
||||
)
|
||||
emitter = (
|
||||
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
|
||||
if telemetry is not None
|
||||
@@ -292,6 +298,8 @@ class GatewayClient:
|
||||
self._structured_available = structured_strategy is not None
|
||||
self._terminal = terminal # 内部引用: 装配自省/测试用
|
||||
self._handler = compose(middlewares, terminal)
|
||||
# 期限到期需要报出 scope(现之前只传给 RetryMW,未自存)
|
||||
self._scope = scope
|
||||
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
|
||||
self._now = now
|
||||
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
|
||||
@@ -336,6 +344,7 @@ class GatewayClient:
|
||||
reasoning_effort: Effort | str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
meta: Mapping[str, Any] | None = None,
|
||||
call_deadline_s: float | None = None,
|
||||
) -> LLMResponse:
|
||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||
|
||||
@@ -351,6 +360,11 @@ class GatewayClient:
|
||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||
|
||||
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值,
|
||||
正数 = 本次覆盖,**不提供"本次关闭"**。它治理的是**等待**: 到期抛
|
||||
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
|
||||
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
|
||||
"""
|
||||
if structured is not None and not self._structured_available:
|
||||
raise ImportError(
|
||||
@@ -377,6 +391,13 @@ class GatewayClient:
|
||||
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
||||
)
|
||||
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
|
||||
# 期限取值与校验必须在创建 awaitable **之前**: 否则非法值抛错时会遗留
|
||||
# 未 await 的协程(RuntimeWarning + 资源不释放)
|
||||
deadline = (
|
||||
self._call_deadline_s
|
||||
if call_deadline_s is None
|
||||
else ensure_call_deadline(call_deadline_s, "chat(call_deadline_s=...)")
|
||||
)
|
||||
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
|
||||
context = _CallContext(now=self._now)
|
||||
request = ChatRequest(
|
||||
@@ -395,7 +416,9 @@ class GatewayClient:
|
||||
call_context=context,
|
||||
)
|
||||
try:
|
||||
response = await self._handler(request)
|
||||
response = await with_call_deadline(
|
||||
self._handler(request), deadline_s=deadline, scope=self._scope
|
||||
)
|
||||
except PolyGatewayError as exc:
|
||||
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
|
||||
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
|
||||
@@ -485,6 +508,7 @@ class GatewayClient:
|
||||
structured_strategy=strategy,
|
||||
structured_escalation=escalation,
|
||||
structured_max_retries=settings.structured_max_retries,
|
||||
call_deadline_s=settings.call_deadline_s,
|
||||
)
|
||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
|
||||
|
||||
@@ -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 专用键。
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -217,3 +217,23 @@ class GovernanceBackendError(GatewayUnavailableError):
|
||||
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
|
||||
self.args = (message,)
|
||||
|
||||
|
||||
class CallDeadlineExceeded(PolyGatewayError): # noqa: N818 — 设计 §9 人类批准的公共名
|
||||
"""调用方设定的整体调用期限到期; 不是网关不可用、也不是源故障。
|
||||
|
||||
刻意**不属**四分类、**不进** `SCOPE_REASONS`、**不继承** `GatewayUnavailableError`:
|
||||
它描述的是调用方自己的耐心边界, 与"对方怎么了"正交——按四分类之一上报会让
|
||||
下游的重试/换源/熔断逻辑对着一次本地超时做治理决策(库铁律「错误分类驱动」)。
|
||||
|
||||
也刻意**没有** `retry_after_s`: 期限到期不含"何时可再试"的信息, 给 `0.0`
|
||||
会按既定语义指示下游立刻重打一条可能已经饱和的通道。
|
||||
|
||||
**到期不等于未产出、未计费**: 期限治理的是等待, 在途请求可能已经发出、
|
||||
已被上游计费, 清理仍在 `finally` 里完成, 故返回时刻 = 期限 + 清理耗时。
|
||||
"""
|
||||
|
||||
def __init__(self, *, scope: str, deadline_s: float) -> None:
|
||||
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
|
||||
self.scope = scope
|
||||
self.deadline_s = deadline_s
|
||||
|
||||
+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