feat: let document calls treat deterministic parse failures as rejected input
The service wraps every failure, including its own 400s, as a 500 with a parsing-failed detail. Only a fixed set of known bad-input markers is narrowed to a rejected request, and only when the caller passes markers; the shared default classification is unchanged.
This commit is contained in:
@@ -56,6 +56,20 @@ _IMAGE_MAGICS: tuple[bytes, ...] = (
|
||||
b"GIF89a",
|
||||
)
|
||||
_PDF_HEADER = b"%PDF-"
|
||||
|
||||
# 文档路径把这些「确定性坏输入」的 500 改归 RequestRejected(#34 设计 §6)。小写子串匹配,
|
||||
# 取 2026-10-21 实测(截断 PDF / 伪 PDF 头 / 乱码 / 空文件 / 扩展名与内容不符)。
|
||||
# **不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,
|
||||
# 应响亮失败并计熔断。标记是 PyMuPDF 的界面文案,随服务版本漂移,故可经构造参数覆盖。
|
||||
_DEFAULT_REJECTED_MARKERS: tuple[str, ...] = (
|
||||
"failed to open stream",
|
||||
"cannot open empty stream",
|
||||
"cannot save with zero pages",
|
||||
"is no pdf",
|
||||
)
|
||||
# 服务把一切异常(含它自己抛的 400、`Model not initialized` 这类真故障)都包成同一前缀,
|
||||
# 所以前缀只是必要条件,必须同时命中具体标记
|
||||
_PARSING_FAILED_PREFIX = "parsing failed:"
|
||||
# Adobe 阅读器容忍头部前有垃圾字节(惯例而非 ISO 32000 正文);服务端实测可解析
|
||||
# 前缀 200 字节垃圾的 PDF(#34 设计 E3)
|
||||
_PDF_HEADER_WINDOW = 1024
|
||||
@@ -96,11 +110,14 @@ def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
||||
def _translate_http_errors(
|
||||
source_name: str, operation: str, *, rejected_markers: tuple[str, ...] = ()
|
||||
) -> Iterator[None]:
|
||||
"""httpx 异常 → 错误四分类(CHS `_translate_http_errors` 语义主体保真)。
|
||||
|
||||
429 细分(insufficient_quota/Retry-After)有意放弃——MonkeyOCR 无鉴权
|
||||
无计费(设计 §10.2);`from exc` 保留 __cause__ 供 RetryMW 归类 timeout。
|
||||
`rejected_markers` 非空时启用确定性坏输入窄化(仅文档路径传入,见 `_classify_status`)。
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
@@ -111,7 +128,7 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
||||
operation=operation,
|
||||
) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise _classify_status(exc, source_name, operation) from exc
|
||||
raise _classify_status(exc, source_name, operation, rejected_markers) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise TransientError(
|
||||
f"{source_name} OCR {operation} 网络错误: {exc}",
|
||||
@@ -120,16 +137,31 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_deterministic_failure(body: str, markers: tuple[str, ...]) -> bool:
|
||||
"""响应体同时含 `Parsing failed:` 前缀与任一已知确定性标记。"""
|
||||
text = body.lower()
|
||||
return _PARSING_FAILED_PREFIX in text and any(marker in text for marker in markers)
|
||||
|
||||
|
||||
def _classify_status(
|
||||
exc: httpx.HTTPStatusError, source_name: str, operation: str
|
||||
exc: httpx.HTTPStatusError,
|
||||
source_name: str,
|
||||
operation: str,
|
||||
rejected_markers: tuple[str, ...] = (),
|
||||
) -> TransientError | SourceDeadError | RequestRejectedError:
|
||||
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
|
||||
|
||||
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
|
||||
"只有一个分支用了响应体"正是 issue #10 的成因。
|
||||
|
||||
唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归
|
||||
`RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」
|
||||
记成功且不计窗口)。默认空元组 = 既有行为字节级不变——医院线对坏图的处置属公共行为,
|
||||
推广须另行设计(issue #35)。
|
||||
"""
|
||||
status = exc.response.status_code
|
||||
summary = summarize_body(response_body(exc.response))
|
||||
body = response_body(exc.response)
|
||||
summary = summarize_body(body)
|
||||
ctx: dict[str, Any] = {
|
||||
"source_name": source_name,
|
||||
"status_code": status,
|
||||
@@ -137,6 +169,8 @@ def _classify_status(
|
||||
"body_text": summary,
|
||||
}
|
||||
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
|
||||
if status == 500 and rejected_markers and _is_deterministic_failure(body, rejected_markers):
|
||||
return RequestRejectedError(message, **ctx)
|
||||
if status >= 500 or status == 429:
|
||||
return TransientError(message, **ctx)
|
||||
if status in (401, 403):
|
||||
@@ -217,8 +251,11 @@ class MonkeyOcrTransport:
|
||||
self,
|
||||
*,
|
||||
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
|
||||
deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS,
|
||||
) -> None:
|
||||
self._client_factory = client_factory or _default_client_factory
|
||||
# 仅文档路径使用(见 `_classify_status`);recognize_text / parse_layout 不传
|
||||
self._rejected_markers = deterministic_failure_markers
|
||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||
|
||||
def _client_for(self, source: SourceConfig) -> httpx.AsyncClient:
|
||||
|
||||
Reference in New Issue
Block a user