mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description When the native (Rust) content detector panicked, the pyo3 `PanicException` (a `BaseException`, not `Exception`) escaped `_detect_content` and surfaced as an HTTP 500 instead of degrading. This catches `BaseException` (excluding control-flow exceptions) around the native call and falls back to the pure-Python regex detector, logging a single warning. Closes #1123 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/transforms/content_router.py`: wrapped the native detect call in `_detect_content` so any `BaseException` (except `KeyboardInterrupt`/`SystemExit`/`GeneratorExit`) degrades to `_regex_detect_content_type`, warning once via a module-level `_detect_panic_warned` flag. - `tests/test_transforms/test_detect_fallback_1123.py`: new regression tests for RuntimeError fallback, BaseException-panic fallback, and KeyboardInterrupt propagation. ## Testing - [x] Unit tests pass (`pytest`) - [x] New tests added for new functionality ### Test Output ```text $ pytest tests/test_transforms/test_detect_fallback_1123.py tests/test_transforms/test_content_router.py -q 54 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0 - Exact command / steps: Monkeypatched the native detector to raise RuntimeError, a BaseException-derived fake panic, and KeyboardInterrupt, then called `_detect_content`. - Observed result: RuntimeError and the BaseException panic both degrade to a valid regex detection result; KeyboardInterrupt still propagates. 54 tests pass. - Not tested: Could not reproduce a real pyo3 panic in this build (`pyo3_runtime` is not importable here), so the fallback is exercised via simulated exceptions. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a2159c0b66
commit
a00fb6761e
2 changed files with 103 additions and 5 deletions
|
|
@ -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:
|
||||
|
|
|
|||
74
tests/test_transforms/test_detect_fallback_1123.py
Normal file
74
tests/test_transforms/test_detect_fallback_1123.py
Normal file
|
|
@ -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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue