diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index 2292a6ec6..daf70b681 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -34,6 +34,7 @@ Pipeline Usage: from __future__ import annotations +import asyncio import hashlib import json import logging @@ -63,6 +64,7 @@ logger = logging.getLogger(__name__) _detect_backend_warned = False +_detect_panic_warned = False def _router_debug_dumps(value: Any) -> str: @@ -156,11 +158,33 @@ def _detect_content(content: str) -> DetectionResult: from headroom._core import detect_content_type as _rust_detect - rust_result = _rust_detect(content) - # Rust's `content_type` is the lowercase string tag (e.g. - # "json_array"); translate to the Python `ContentType` enum so - # downstream mapping keys match. - content_type = ContentType(rust_result.content_type) + global _detect_panic_warned + try: + rust_result = _rust_detect(content) + # Rust's `content_type` is the lowercase string tag (e.g. + # "json_array"); translate to the Python `ContentType` enum so + # downstream mapping keys match. + content_type = ContentType(rust_result.content_type) + except (KeyboardInterrupt, SystemExit, GeneratorExit): + raise + except BaseException as exc: # noqa: BLE001 + # A native Rust panic surfaces as pyo3_runtime.PanicException, which + # derives from BaseException — so ``except Exception`` would miss it and + # the panic would propagate out as an HTTP 500. Any detector failure + # (panic, or an unrecognized content-type tag) degrades to the + # pure-Python detector instead of aborting the request. See #1123. + # Guard: don't swallow cancellation/control-flow BaseExceptions such + # as asyncio.CancelledError — keep them propagating. + if isinstance(exc, asyncio.CancelledError): + raise + if not _detect_panic_warned: + _detect_panic_warned = True + logger.warning( + "Native content detector failed (%s); falling back to pure-Python detection.", + type(exc).__name__, + ) + return _regex_detect_content_type(content) + if content_type is ContentType.PLAIN_TEXT: regex_result = _regex_detect_content_type(content) if regex_result.content_type is not ContentType.PLAIN_TEXT: diff --git a/tests/test_transforms/test_detect_fallback_1123.py b/tests/test_transforms/test_detect_fallback_1123.py new file mode 100644 index 000000000..6602eeabc --- /dev/null +++ b/tests/test_transforms/test_detect_fallback_1123.py @@ -0,0 +1,74 @@ +"""Native (Rust) content-detector failures must degrade to the pure-Python +detector instead of propagating out as an HTTP 500. Regression test for #1123.""" + +from __future__ import annotations + +import asyncio + +import headroom._core as core +from headroom.transforms import content_router as cr + + +def test_falls_back_on_rust_exception(monkeypatch): + """An ordinary exception from the native detector degrades to regex.""" + + def _boom(_content): + raise RuntimeError("simulated native failure") + + monkeypatch.setenv("HEADROOM_DETECT_BACKEND", "rust") + monkeypatch.setattr(core, "detect_content_type", _boom) + monkeypatch.setattr(cr, "_detect_panic_warned", False, raising=False) + + # Must not raise; returns a usable detection result from the regex path. + result = cr._detect_content('{"a": 1, "b": [1, 2, 3]}') + assert result is not None + assert result.content_type is not None + + +def test_falls_back_on_baseexception_panic(monkeypatch): + """A BaseException-derived panic (like pyo3's PanicException) is caught too.""" + + class FakePanic(BaseException): + pass + + def _panic(_content): + raise FakePanic("simulated pyo3 panic") + + monkeypatch.setenv("HEADROOM_DETECT_BACKEND", "rust") + monkeypatch.setattr(core, "detect_content_type", _panic) + monkeypatch.setattr(cr, "_detect_panic_warned", False, raising=False) + + result = cr._detect_content("some plain text content here") + assert result is not None + + +def test_control_flow_exceptions_propagate(monkeypatch): + """KeyboardInterrupt/SystemExit must not be swallowed by the fallback.""" + + def _interrupt(_content): + raise KeyboardInterrupt + + monkeypatch.setenv("HEADROOM_DETECT_BACKEND", "rust") + monkeypatch.setattr(core, "detect_content_type", _interrupt) + monkeypatch.setattr(cr, "_detect_panic_warned", False, raising=False) + + import pytest + + with pytest.raises(KeyboardInterrupt): + cr._detect_content("content") + + +def test_cancelled_error_propagates(monkeypatch): + """asyncio.CancelledError must propagate, not be swallowed as a fallback.""" + + def _cancel(_content): + raise asyncio.CancelledError() + + monkeypatch.setenv("HEADROOM_DETECT_BACKEND", "rust") + monkeypatch.setattr(core, "detect_content_type", _cancel) + monkeypatch.setattr(cr, "_detect_panic_warned", False, raising=False) + + import pytest + + with pytest.raises(asyncio.CancelledError): + cr._detect_content("content")