|
|
|
@@ -6,6 +6,7 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
import pytest
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
@@ -21,20 +22,34 @@ from polygateway.errors import (
|
|
|
|
|
TransientError,
|
|
|
|
|
)
|
|
|
|
|
from polygateway.ocr import OcrClient
|
|
|
|
|
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
|
|
|
|
|
from polygateway.types import (
|
|
|
|
|
BackpressurePolicy,
|
|
|
|
|
BreakerConfig,
|
|
|
|
|
GlobalLimits,
|
|
|
|
|
OcrDocumentElement,
|
|
|
|
|
OcrDocumentTransportResult,
|
|
|
|
|
OcrLayoutElement,
|
|
|
|
|
OcrLayoutTransportResult,
|
|
|
|
|
OcrTextTransportResult,
|
|
|
|
|
RetryPolicy,
|
|
|
|
|
SourceConfig,
|
|
|
|
|
)
|
|
|
|
|
from tests.unit import _document_fixtures as fx
|
|
|
|
|
|
|
|
|
|
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
|
|
|
|
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
|
|
|
|
|
_TEXT_OK = OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
|
|
|
|
|
_DOC_OK = OcrDocumentTransportResult(
|
|
|
|
|
elements=[
|
|
|
|
|
OcrDocumentElement(
|
|
|
|
|
type="text", bbox=(1.0, 2.0, 3.0, 4.0), page_index=0, text="SYNTH-DOC-BODY"
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
page_sizes=[(612.0, 792.0)],
|
|
|
|
|
markdown="# SYNTH-MD",
|
|
|
|
|
raw={"success": True},
|
|
|
|
|
)
|
|
|
|
|
_LAYOUT_OK = OcrLayoutTransportResult(
|
|
|
|
|
elements=[OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)],
|
|
|
|
|
page_sizes=[(759.0, 540.0)],
|
|
|
|
@@ -72,7 +87,11 @@ class ScriptedOcrTransport:
|
|
|
|
|
if action == "hang":
|
|
|
|
|
self.entered.set()
|
|
|
|
|
await asyncio.Event().wait()
|
|
|
|
|
return _TEXT_OK if action == "text" else _LAYOUT_OK
|
|
|
|
|
if action == "text":
|
|
|
|
|
return _TEXT_OK
|
|
|
|
|
if action == "document":
|
|
|
|
|
return _DOC_OK
|
|
|
|
|
return _LAYOUT_OK
|
|
|
|
|
|
|
|
|
|
async def recognize_text(self, *, image, source, call_id):
|
|
|
|
|
return await self._next("text", source, call_id)
|
|
|
|
@@ -80,6 +99,9 @@ class ScriptedOcrTransport:
|
|
|
|
|
async def parse_layout(self, *, image, source, call_id):
|
|
|
|
|
return await self._next("layout", source, call_id)
|
|
|
|
|
|
|
|
|
|
async def parse_document(self, *, document, source, call_id):
|
|
|
|
|
return await self._next("document", source, call_id)
|
|
|
|
|
|
|
|
|
|
async def check_health(self, *, source):
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
@@ -548,7 +570,7 @@ class TestOcrCallerDimensions:
|
|
|
|
|
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
|
|
|
|
|
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
|
|
|
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"])
|
|
|
|
|
async def test_invalid_meta_rejected_before_any_telemetry(self, method):
|
|
|
|
|
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
@@ -558,6 +580,14 @@ class TestOcrCallerDimensions:
|
|
|
|
|
assert recorder.rows == []
|
|
|
|
|
assert client._transport.calls == [] # 连调用都没发出
|
|
|
|
|
|
|
|
|
|
async def test_parse_document_row_carries_dimensions(self):
|
|
|
|
|
"""第三个入口: 只测前两个会漏掉它的透传缺口。"""
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
|
|
|
|
|
await client.parse_document(b"%PDF-1.4", tenant_id="t3", meta={"batch": "b-44"})
|
|
|
|
|
assert recorder.rows[0]["tenant_id"] == "t3"
|
|
|
|
|
assert recorder.rows[0]["meta"] == '{"batch": "b-44"}'
|
|
|
|
|
|
|
|
|
|
async def test_defaults_land_as_sentinels(self):
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
client, _, _ = _client([_src()], ["text"], telemetry=recorder)
|
|
|
|
@@ -566,6 +596,334 @@ class TestOcrCallerDimensions:
|
|
|
|
|
assert recorder.rows[0]["meta"] == "{}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _LegacyOcrTransport:
|
|
|
|
|
"""只实现旧 `OcrTransport` 三方法的下游 fake: 不满足 `OcrDocumentTransport`。"""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.calls = []
|
|
|
|
|
|
|
|
|
|
async def recognize_text(self, *, image, source, call_id):
|
|
|
|
|
self.calls.append("text")
|
|
|
|
|
return _TEXT_OK
|
|
|
|
|
|
|
|
|
|
async def parse_layout(self, *, image, source, call_id):
|
|
|
|
|
self.calls.append("layout")
|
|
|
|
|
return _LAYOUT_OK
|
|
|
|
|
|
|
|
|
|
async def check_health(self, *, source):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseDocument:
|
|
|
|
|
"""#34 设计 §4.2/§6: 与 parse_layout 对称的整份文档入口,治理循环原样复用。"""
|
|
|
|
|
|
|
|
|
|
async def test_result_fields(self):
|
|
|
|
|
client, _, gate = _client([_src()], ["document"])
|
|
|
|
|
r = await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert [e.text for e in r.elements] == ["SYNTH-DOC-BODY"]
|
|
|
|
|
assert r.markdown == "# SYNTH-MD" and r.page_sizes == [(612.0, 792.0)]
|
|
|
|
|
assert r.source_name == "m1"
|
|
|
|
|
assert r.usage.prompt_tokens == 0 and r.usage.completion_tokens == 0
|
|
|
|
|
assert r.latency_ms >= 0 and r.call_id and r.raw == {"success": True}
|
|
|
|
|
assert r.call_stats is not None and r.call_stats.attempts == 1
|
|
|
|
|
assert gate.successes == [("m1", True)]
|
|
|
|
|
assert client._selector.outcomes == [("m1", True)]
|
|
|
|
|
|
|
|
|
|
async def test_input_validation_names_the_document(self):
|
|
|
|
|
client, _, _ = _client([_src()], [])
|
|
|
|
|
with pytest.raises(TypeError, match="document 必须是 bytes"):
|
|
|
|
|
await client.parse_document("not-bytes")
|
|
|
|
|
with pytest.raises(ValueError, match="document 不能为空"):
|
|
|
|
|
await client.parse_document(b"")
|
|
|
|
|
|
|
|
|
|
async def test_legacy_entrances_keep_their_error_text(self):
|
|
|
|
|
"""私有形参改名为 payload 不得改变旧入口的报错文案(含 D9 的业务边界提示)。"""
|
|
|
|
|
client, _, _ = _client([_src()], [])
|
|
|
|
|
with pytest.raises(TypeError, match=r"image 必须是 bytes\(路径读取/批量拼帧留业务侧,D9\)"):
|
|
|
|
|
await client.recognize_text("not-bytes")
|
|
|
|
|
with pytest.raises(ValueError, match="image 不能为空"):
|
|
|
|
|
await client.parse_layout(b"")
|
|
|
|
|
|
|
|
|
|
async def test_transport_without_document_support_is_an_assembly_error(self):
|
|
|
|
|
"""装配错误当场报,且早于统计边界: 没有遥测行、没有任何 transport 调用。"""
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
legacy = _LegacyOcrTransport()
|
|
|
|
|
client, limiter, _ = _client([_src(rpm=100)], [], transport=legacy, telemetry=recorder)
|
|
|
|
|
with pytest.raises(TypeError, match="OcrDocumentTransport"):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert recorder.rows == [] and legacy.calls == []
|
|
|
|
|
# 早于准入: 从未向限流后端申请过许可(否则 rpm 已被占用一次)
|
|
|
|
|
assert (await limiter.source_stats("m1")).rpm_used == 0
|
|
|
|
|
|
|
|
|
|
async def test_invalid_call_deadline_is_rejected_like_the_other_entrances(self):
|
|
|
|
|
client, _, _ = _client([_src()], ["document"])
|
|
|
|
|
with pytest.raises(ValueError, match="call_deadline_s"):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4", call_deadline_s=-1)
|
|
|
|
|
|
|
|
|
|
async def test_transient_retries_then_succeeds(self):
|
|
|
|
|
client, _, _ = _client(
|
|
|
|
|
[_src()],
|
|
|
|
|
[TransientError("boom", status_code=500), "document"],
|
|
|
|
|
sleep=lambda _d: asyncio.sleep(0),
|
|
|
|
|
)
|
|
|
|
|
r = await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert r.markdown == "# SYNTH-MD"
|
|
|
|
|
assert client._selector.outcomes == [("m1", False), ("m1", True)]
|
|
|
|
|
|
|
|
|
|
async def test_result_invalid_counts_no_attempt_and_no_feed(self):
|
|
|
|
|
client, _, gate = _client([_src()], [ResultInvalidError("bad package")])
|
|
|
|
|
with pytest.raises(ResultInvalidError):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert gate.successes == [("m1", False)]
|
|
|
|
|
assert client._selector.outcomes == []
|
|
|
|
|
|
|
|
|
|
async def test_deterministic_rejection_with_status_500_counts_as_a_live_service(self):
|
|
|
|
|
"""服务有响应(500 + 确定性坏输入标记)= 服务活着: 记成功不入窗口,不喂选源器。"""
|
|
|
|
|
client, _, gate = _client(
|
|
|
|
|
[_src()], [RequestRejectedError("Parsing failed: is no PDF", status_code=500)]
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(RequestRejectedError):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert gate.successes == [("m1", False)]
|
|
|
|
|
assert client._selector.outcomes == []
|
|
|
|
|
|
|
|
|
|
async def test_local_rejection_does_not_touch_the_breaker(self):
|
|
|
|
|
client, _, gate = _client([_src()], [RequestRejectedError("unrecognizable")])
|
|
|
|
|
with pytest.raises(RequestRejectedError):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert gate.successes == []
|
|
|
|
|
|
|
|
|
|
async def test_rate_limited_responses_spend_retry_budget_but_not_breaker_failures(self):
|
|
|
|
|
"""429 是背压不是故障(熔断两通道均不计): 三次 429 耗尽重试后,熔断仍是 CLOSED。"""
|
|
|
|
|
client, _, gate = _client(
|
|
|
|
|
[_src()],
|
|
|
|
|
[TransientError("busy", status_code=429)] * 3,
|
|
|
|
|
sleep=lambda _d: asyncio.sleep(0),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(AllSourcesExhausted):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
entry = await gate._inner.try_enter("m1", "check")
|
|
|
|
|
assert entry.allowed and not entry.is_probe
|
|
|
|
|
|
|
|
|
|
async def test_read_timeouts_are_counted_as_source_failures(self):
|
|
|
|
|
"""对照: 文档路径的 httpx 超时仍是瞬时故障、计熔断(`timeout_s` 不是免责额度)。"""
|
|
|
|
|
|
|
|
|
|
def timeout_exc():
|
|
|
|
|
exc = TransientError("timed out", operation="parse")
|
|
|
|
|
exc.__cause__ = httpx.ReadTimeout("read timed out")
|
|
|
|
|
return exc
|
|
|
|
|
|
|
|
|
|
client, _, gate = _client(
|
|
|
|
|
[_src()], [timeout_exc() for _ in range(3)], sleep=lambda _d: asyncio.sleep(0)
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(AllSourcesExhausted) as ei:
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert ei.value.per_source_reasons == {"m1": "timeout"}
|
|
|
|
|
entry = await gate._inner.try_enter("m1", "check")
|
|
|
|
|
assert not entry.allowed # 阈值 3 的熔断已开路
|
|
|
|
|
|
|
|
|
|
async def test_cancel_releases_permit_and_probe(self):
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
inner = InMemoryGate(config=_BREAKER, now=clock)
|
|
|
|
|
gate = RecordingGate(inner)
|
|
|
|
|
transport = ScriptedOcrTransport(["hang"])
|
|
|
|
|
client, limiter, _ = _client(
|
|
|
|
|
[_src(max_concurrency=1)], [], now=clock, breaker=gate, transport=transport
|
|
|
|
|
)
|
|
|
|
|
entry = await inner.try_enter("m1", "setup")
|
|
|
|
|
await inner.record_failure(entry, "source_dead", True)
|
|
|
|
|
clock.advance(61.0) # 越过冷却 → 半开探针
|
|
|
|
|
task = asyncio.create_task(client.parse_document(b"%PDF-1.4"))
|
|
|
|
|
await transport.entered.wait()
|
|
|
|
|
task.cancel()
|
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
|
|
|
await task
|
|
|
|
|
assert gate.probe_releases >= 1
|
|
|
|
|
assert (await limiter.source_stats("m1")).inflight == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNoLatencyJudgement:
|
|
|
|
|
"""长文档不会被判慢(设计 §7): 库内没有任何基于延迟的熔断/选源判定。
|
|
|
|
|
|
|
|
|
|
时钟只在 transport 内前进(`ClockAdvancingOcrTransport`),门与 client 共用同一注入钟。
|
|
|
|
|
probe_ttl_s 取极大值,避免把探针租约接管混进"慢"的断言。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_CFG = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=1e12)
|
|
|
|
|
|
|
|
|
|
async def test_slow_success_leaves_breaker_closed_with_no_failures(self):
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
gate = RecordingGate(InMemoryGate(config=self._CFG, now=clock))
|
|
|
|
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
|
|
|
|
client, _, _ = _client(
|
|
|
|
|
[_src(timeout_s=120.0)], [], now=clock, breaker=gate, transport=transport
|
|
|
|
|
)
|
|
|
|
|
r = await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert r.call_stats.attempts == 1 and r.call_stats.generation_ms >= 100_000_000
|
|
|
|
|
assert gate._inner._gates["m1"].fails == 0
|
|
|
|
|
assert gate.successes == [("m1", True)]
|
|
|
|
|
|
|
|
|
|
async def test_slow_success_clears_pre_existing_failure_count(self):
|
|
|
|
|
"""前置状态不是初始 CLOSED: 已有 2 次失败(阈值 3),一次很慢的成功也只会清零。"""
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
inner = InMemoryGate(config=self._CFG, now=clock)
|
|
|
|
|
for _ in range(2):
|
|
|
|
|
entry = await inner.try_enter("m1", "setup")
|
|
|
|
|
await inner.record_failure(entry, "network_error", False)
|
|
|
|
|
assert inner._gates["m1"].fails == 2
|
|
|
|
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
|
|
|
|
client, _, _ = _client(
|
|
|
|
|
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
|
|
|
|
|
)
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert inner._gates["m1"].fails == 0
|
|
|
|
|
|
|
|
|
|
async def test_slow_success_on_the_half_open_probe_closes_the_breaker(self):
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
inner = InMemoryGate(config=self._CFG, now=clock)
|
|
|
|
|
entry = await inner.try_enter("m1", "setup")
|
|
|
|
|
await inner.record_failure(entry, "source_dead", True) # OPEN
|
|
|
|
|
clock.advance(61.0) # → HALF_OPEN
|
|
|
|
|
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
|
|
|
|
|
client, _, _ = _client(
|
|
|
|
|
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
|
|
|
|
|
)
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
after = await inner.try_enter("m1", "check")
|
|
|
|
|
assert after.allowed and not after.is_probe # 探针成功 → CLOSED,而不是被慢耗尽
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDocumentTelemetry:
|
|
|
|
|
async def test_success_row(self):
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
|
|
|
|
|
await client.parse_document(b"%PDF-RAW-BYTES")
|
|
|
|
|
(row,) = recorder.rows
|
|
|
|
|
assert row["operation"] == "parse_document"
|
|
|
|
|
assert "<ocr:document image_bytes=14>" in row["messages"]
|
|
|
|
|
assert "RAW-BYTES" not in row["messages"] # 文档字节绝不入库
|
|
|
|
|
assert row["response"] == "<document elements=1 pages=1 md_chars=10>"
|
|
|
|
|
assert "SYNTH-MD" not in row["response"] and "SYNTH-DOC-BODY" not in row["response"]
|
|
|
|
|
assert row["reasoning_effort"] is None # `reasoning_applies=False` 的落库形态
|
|
|
|
|
assert row["error"] is None and row["prompt_tokens"] == 0
|
|
|
|
|
|
|
|
|
|
async def test_summary_distinguishes_the_three_result_kinds(self):
|
|
|
|
|
"""新结果类型必须在 `_summarize` 里显式区分,否则会被静默套成 layout 摘要而漏记 md_chars。"""
|
|
|
|
|
assert OcrClient._summarize(_DOC_OK) == "<document elements=1 pages=1 md_chars=10>"
|
|
|
|
|
assert OcrClient._summarize(_LAYOUT_OK) == "<elements n=1 pages=1>"
|
|
|
|
|
assert OcrClient._summarize(_TEXT_OK) == "LINE-1"
|
|
|
|
|
|
|
|
|
|
async def test_failed_attempt_row_carries_the_class_prefix(self):
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
client, _, _ = _client([_src()], [ResultInvalidError("bad package")], telemetry=recorder)
|
|
|
|
|
with pytest.raises(ResultInvalidError):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4")
|
|
|
|
|
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
|
|
|
|
|
assert {r["operation"] for r in recorder.rows} == {"parse_document"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _real_transport(handler):
|
|
|
|
|
return MonkeyOcrTransport(
|
|
|
|
|
client_factory=lambda src: httpx.AsyncClient(
|
|
|
|
|
base_url=src.base_url, transport=httpx.MockTransport(handler)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _doc_handler(package: bytes | None = None, *, download=None):
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
if request.url.path == "/parse":
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={"success": True, "message": "ok", "download_url": "/static/doc.zip"},
|
|
|
|
|
)
|
|
|
|
|
if download is not None:
|
|
|
|
|
return download()
|
|
|
|
|
return httpx.Response(200, content=package)
|
|
|
|
|
|
|
|
|
|
return handler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDocumentThroughTheRealTransport:
|
|
|
|
|
"""真实 `MonkeyOcrTransport` + `MockTransport` + 真实 `OcrClient`: 证明异常/期限贯穿整条链。
|
|
|
|
|
|
|
|
|
|
MockTransport 不执行 httpcore 的逐次读超时,所以本组只证明**治理期限与异常分类**,
|
|
|
|
|
不证明 read timeout 的真实行为(后者留给活体用例)。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
async def test_happy_path_end_to_end(self):
|
|
|
|
|
package = fx._package([fx._page([fx._text_block("SYNTH-E2E")])])
|
|
|
|
|
client, _, _ = _client([_src()], [], transport=_real_transport(_doc_handler(package)))
|
|
|
|
|
r = await client.parse_document(b"%PDF-1.4 synthetic")
|
|
|
|
|
assert [e.text for e in r.elements] == ["SYNTH-E2E"]
|
|
|
|
|
assert r.markdown == "# synth\n"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("package_kind", ["huge-int", "encrypted"])
|
|
|
|
|
async def test_parser_exceptions_become_result_invalid_with_full_bookkeeping(
|
|
|
|
|
self, package_kind
|
|
|
|
|
):
|
|
|
|
|
"""真实解析器抛出的异常(OverflowError / RuntimeError)经真实客户端: 终态遥测行已写、
|
|
|
|
|
半开探针已归还、熔断窗口不变——不能只用桩直接抛预制的 `ResultInvalidError`。"""
|
|
|
|
|
if package_kind == "huge-int":
|
|
|
|
|
block = fx._text_block()
|
|
|
|
|
block["bbox"] = [0, 0, 10**400, 1]
|
|
|
|
|
package = fx._package([fx._page([block])])
|
|
|
|
|
else:
|
|
|
|
|
package = fx._encrypted_package()
|
|
|
|
|
clock = FakeClock()
|
|
|
|
|
inner = InMemoryGate(
|
|
|
|
|
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
|
|
|
|
|
)
|
|
|
|
|
entry = await inner.try_enter("m1", "setup")
|
|
|
|
|
await inner.record_failure(entry, "source_dead", True)
|
|
|
|
|
clock.advance(61.0) # 半开: 本次调用持有探针
|
|
|
|
|
recorder = _MemoryRecorder()
|
|
|
|
|
client, _, gate = _client(
|
|
|
|
|
[_src()],
|
|
|
|
|
[],
|
|
|
|
|
now=clock,
|
|
|
|
|
breaker=RecordingGate(inner),
|
|
|
|
|
telemetry=recorder,
|
|
|
|
|
transport=_real_transport(_doc_handler(package)),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ResultInvalidError):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
|
|
|
|
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
|
|
|
|
|
after = await inner.try_enter("m1", "check")
|
|
|
|
|
assert after.allowed and not after.is_probe # 探针已按"坏结果≠坏服务"结算,没有悬挂
|
|
|
|
|
assert inner._gates["m1"].fails == 0
|
|
|
|
|
|
|
|
|
|
async def test_sustained_slow_download_hits_the_call_deadline_not_a_transient_error(self):
|
|
|
|
|
"""每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。"""
|
|
|
|
|
closed = []
|
|
|
|
|
produced = []
|
|
|
|
|
|
|
|
|
|
async def slow_body():
|
|
|
|
|
try:
|
|
|
|
|
for _ in range(200):
|
|
|
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
produced.append(1)
|
|
|
|
|
yield b"x" * 8
|
|
|
|
|
finally:
|
|
|
|
|
closed.append(True)
|
|
|
|
|
|
|
|
|
|
handler = _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
|
|
|
|
|
client, limiter, gate = _client(
|
|
|
|
|
[_src(max_concurrency=1, timeout_s=120.0)],
|
|
|
|
|
[],
|
|
|
|
|
transport=_real_transport(handler),
|
|
|
|
|
call_deadline_s=0.1,
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(CallDeadlineExceeded):
|
|
|
|
|
await client.parse_document(b"%PDF-1.4 synthetic")
|
|
|
|
|
assert len(produced) >= 2 # 确实进入了多块下载,而不是一开始就超时
|
|
|
|
|
assert closed == [True] # 流已关闭
|
|
|
|
|
assert (await limiter.source_stats("m1")).inflight == 0 # permit 释放
|
|
|
|
|
assert gate.successes == [] # 期限不是服务故障也不是成功
|
|
|
|
|
entry = await gate._inner.try_enter("m1", "check")
|
|
|
|
|
assert entry.allowed and not entry.is_probe # 熔断窗口不变
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAssembly:
|
|
|
|
|
_ENV = {
|
|
|
|
|
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
|
|
|
|
|