diff --git a/nextcloud_mcp_server/document_processors/ocr.py b/nextcloud_mcp_server/document_processors/ocr.py index 7f08cb32..19ad4673 100644 --- a/nextcloud_mcp_server/document_processors/ocr.py +++ b/nextcloud_mcp_server/document_processors/ocr.py @@ -264,11 +264,13 @@ class OcrProcessor(DocumentProcessor): text, boundaries = await backend.ocr( content, content_type.split(";")[0].strip().lower() ) - except TimeoutError: - # anyio.fail_after / httpx read-timeout raise TimeoutError with an - # empty message; give it its own reason bucket and a useful log so a - # too-low DOCUMENT_OCR_TIMEOUT_SECONDS is distinguishable from a - # provider that's actually erroring. + except (TimeoutError, httpx.TimeoutException): + # Two timeout shapes reach here: the Mistral backend's + # anyio.fail_after raises the builtin TimeoutError, while the gateway + # backend's httpx.Timeout raises httpx.ReadTimeout (a + # httpx.TimeoutException, NOT a TimeoutError). Catch both so a + # too-low DOCUMENT_OCR_TIMEOUT_SECONDS lands in its own reason bucket + # rather than being conflated with provider errors. timeout = settings.document_ocr_timeout_seconds logger.warning( "OCR timed out for %s after %.1fs", filename or "", timeout diff --git a/tests/unit/test_ocr_processor.py b/tests/unit/test_ocr_processor.py index d13a42a9..51e00906 100644 --- a/tests/unit/test_ocr_processor.py +++ b/tests/unit/test_ocr_processor.py @@ -149,6 +149,25 @@ async def test_processor_timeout_returns_timeout_reason(monkeypatch): assert "timed out" in r.error +async def test_gateway_httpx_timeout_maps_to_timeout_reason(monkeypatch): + """A gateway httpx.ReadTimeout (not a builtin TimeoutError) must still map to + parse_failed_reason='timeout', not 'error'.""" + import httpx + + class _HttpxTimeoutBackend: + async def ocr(self, content, mime_type): + raise httpx.ReadTimeout("read timed out") + + monkeypatch.setattr( + ocr, "get_settings", lambda: _settings(document_ocr_timeout_seconds=5.0) + ) + monkeypatch.setattr(ocr, "build_ocr_backend", lambda s: _HttpxTimeoutBackend()) + r = await ocr.OcrProcessor().process(b"%PDF-1.7", "application/pdf") + assert r.success is False + assert r.metadata["parse_failed_reason"] == "timeout" + assert "timed out" in r.error + + async def test_gateway_backend_uses_configured_timeout(mocker, monkeypatch): """The gateway OCR call must use DOCUMENT_OCR_TIMEOUT_SECONDS (resolved per call), not the old hardcoded 180s constant."""