fix(router): degrade to pure-Python detection on native panic (#1123) (#1260)

## 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:
Parideboy 2026-06-23 01:57:05 +02:00 committed by GitHub
parent a2159c0b66
commit a00fb6761e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 103 additions and 5 deletions

View file

@ -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:

View 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")