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:
@@ -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"})))
|
||||
|
||||
Reference in New Issue
Block a user