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:
2026-10-21 23:43:02 -04:00
parent 3db7d9979c
commit 0357d5afb3
2 changed files with 149 additions and 4 deletions
+108
View File
@@ -20,10 +20,12 @@ from polygateway.errors import (
TransientError,
)
from polygateway.transports.monkey_ocr import (
_DEFAULT_REJECTED_MARKERS,
MonkeyOcrTransport,
_classify_status,
_parse_middle_json,
_sniff_media,
_translate_http_errors,
_upload_part,
)
from polygateway.types import SourceConfig
@@ -455,6 +457,112 @@ class TestErrorTranslation:
assert isinstance(ei.value.__cause__, httpx.TimeoutException)
def _http_error(status, body):
request = httpx.Request("POST", "http://ocr.example/parse")
return httpx.HTTPStatusError(
str(status),
request=request,
response=httpx.Response(status, content=body.encode(), request=request),
)
def _parsing_failed(reason):
# 真实形态(2026-10-21 实测): 服务把一切异常包成 500 + {"detail": "Parsing failed: …"}
return json.dumps({"detail": f"Parsing failed: {reason}"})
class TestRejectedMarkers:
"""#34 设计 §6: 确定性坏输入的 500 在文档路径归「请求被拒」,其余分类一概不动。
共享的 `_classify_status` 默认行为字节级不变——医院线对坏图的重试/熔断行为属公共行为,
推广须另行设计(issue #35)。
"""
@pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS)
def test_known_deterministic_failures_become_rejected_with_status_500(self, marker):
exc = _http_error(500, _parsing_failed(marker.capitalize()))
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
assert isinstance(err, RequestRejectedError)
assert err.status_code == 500 # 服务有响应: 熔断按「响应即健康」记成功
assert marker.capitalize() in err.body_text # 响应体摘要仍随该分支
def test_marker_matching_is_case_insensitive(self):
exc = _http_error(500, '{"detail": "PARSING FAILED: FAILED TO OPEN STREAM"}')
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
assert isinstance(err, RequestRejectedError)
@pytest.mark.parametrize(
"body",
[
pytest.param('{"detail": "Failed to open stream"}', id="no-parsing-failed-prefix"),
pytest.param(
_parsing_failed("500: Model not initialized"), id="real-fault-same-prefix"
),
pytest.param(
_parsing_failed("400: Unsupported file type: .webp"), id="unsupported-type"
),
pytest.param(_parsing_failed("CUDA out of memory"), id="oom"),
pytest.param("", id="empty-body"),
],
)
def test_everything_else_stays_transient(self, body):
"""`Parsing failed:` 前缀不能单独当判据: 服务把真故障也包成同一前缀。
`Unsupported file type` 有意不在白名单——嗅探后它只可能是契约变化或我方标签 bug。"""
err = _classify_status(
_http_error(500, body), "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS
)
assert isinstance(err, TransientError)
@pytest.mark.parametrize("body", [_parsing_failed(m) for m in _DEFAULT_REJECTED_MARKERS])
def test_default_call_without_markers_is_unchanged(self, body):
err = _classify_status(_http_error(500, body), "monkey_1", "parse")
assert isinstance(err, TransientError)
@pytest.mark.parametrize(
("status", "exc_type"),
[
(429, TransientError),
(502, TransientError),
(503, TransientError),
(401, SourceDeadError),
(403, SourceDeadError),
(404, RequestRejectedError),
],
)
def test_other_statuses_are_never_narrowed(self, status, exc_type):
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])
err = _classify_status(_http_error(status, body), "m", "parse", _DEFAULT_REJECTED_MARKERS)
assert type(err) is exc_type
def test_translate_http_errors_passes_markers_through(self):
with pytest.raises(RequestRejectedError), _translate_http_errors(
"monkey_1", "parse", rejected_markers=_DEFAULT_REJECTED_MARKERS
):
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
# 默认不传: 行为不变
with pytest.raises(TransientError), _translate_http_errors("monkey_1", "parse"):
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
def test_markers_are_configurable_per_transport(self):
assert MonkeyOcrTransport()._rejected_markers == _DEFAULT_REJECTED_MARKERS
custom = ("synthetic marker",)
assert MonkeyOcrTransport(deterministic_failure_markers=custom)._rejected_markers == custom
async def test_layout_and_text_paths_do_not_apply_the_markers(self):
"""传输实例持有标记,但只有文档路径使用;医院线的两个方法行为不变。"""
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]).encode()
t = _transport_for(
_routes(
text_resp=httpx.Response(500, content=body),
parse_resp=httpx.Response(500, content=body),
)
)
with pytest.raises(TransientError):
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
with pytest.raises(TransientError):
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
class TestCheckHealth:
async def test_healthy(self):
t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))