From ae595c937aa00ce15d76b08abcb4bfa45543719d Mon Sep 17 00:00:00 2001 From: Dirk Riemann Date: Sat, 18 Apr 2026 10:46:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(errors):=20add=20IXException=20+=20IXError?= =?UTF-8?q?Code=20per=20spec=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the single exception type used throughout the pipeline. Every failure maps to one of the ten IX_* codes from the MVP spec §8 with a stable machine-readable code and an optional free-form detail. The `str()` form is log-scrapable with a single regex (`IX_xxx_xxx: (detail=...)`), so mammon-side reliability UX can classify failures without brittle string parsing. Enum values equal names so callers can serialise either. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ix/errors.py | 90 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_errors.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/ix/errors.py create mode 100644 tests/unit/test_errors.py diff --git a/src/ix/errors.py b/src/ix/errors.py new file mode 100644 index 0000000..d5dca56 --- /dev/null +++ b/src/ix/errors.py @@ -0,0 +1,90 @@ +"""Error codes + exception type for the infoxtractor pipeline. + +Every pipeline-emitted failure maps to one of the ``IX_*`` codes defined in +the MVP spec §8. Callers receive the code (stable, machine-readable) plus a +free-form ``detail`` field (human-readable, may contain the offending URL, +model name, schema snippet, etc.). The ``str()`` form embeds both so the +error is log-scrapable with a single regex. + +Example:: + + raise IXException(IXErrorCode.IX_000_007, detail="https://x/y.pdf: 404") + # -> "IX_000_007: File fetch failed (detail=https://x/y.pdf: 404)" +""" + +from __future__ import annotations + +from enum import Enum + + +class IXErrorCode(Enum): + """Stable error codes. Value == name so callers can serialise either. + + The MVP ships exactly the codes in spec §8. Do not add codes here ad-hoc; + every new trigger must be speccéd first so mammon-side reliability UX + stays predictable. + """ + + IX_000_000 = "IX_000_000" + IX_000_002 = "IX_000_002" + IX_000_004 = "IX_000_004" + IX_000_005 = "IX_000_005" + IX_000_006 = "IX_000_006" + IX_000_007 = "IX_000_007" + IX_001_000 = "IX_001_000" + IX_001_001 = "IX_001_001" + IX_002_000 = "IX_002_000" + IX_002_001 = "IX_002_001" + + @property + def default_message(self) -> str: + """Human-readable default message for this code (spec §8 wording).""" + return _DEFAULT_MESSAGES[self] + + +_DEFAULT_MESSAGES: dict[IXErrorCode, str] = { + IXErrorCode.IX_000_000: "request_ix is None", + IXErrorCode.IX_000_002: "No context provided (neither files nor texts)", + IXErrorCode.IX_000_004: ( + "OCR artifacts requested (include_geometries / include_ocr_text / " + "ocr_only) but context.files is empty" + ), + IXErrorCode.IX_000_005: "File MIME type not supported", + IXErrorCode.IX_000_006: "PDF page-count cap exceeded", + IXErrorCode.IX_000_007: "File fetch failed", + IXErrorCode.IX_001_000: "Extraction context empty after setup", + IXErrorCode.IX_001_001: "Use case name not found in registry", + IXErrorCode.IX_002_000: "Inference backend unavailable", + IXErrorCode.IX_002_001: "Structured output parse failed", +} + + +class IXException(Exception): + """Single exception type carrying an :class:`IXErrorCode` + optional detail. + + Raised by any pipeline step, adapter, or client when a recoverable-by-the- + caller failure happens. The pipeline orchestrator catches this, writes the + code into ``ResponseIX.error``, and lets the job terminate in ``status=error``. + + Parameters + ---------- + code: + One of the :class:`IXErrorCode` values. + detail: + Optional free-form string (URL, model name, snippet, etc.). Embedded + into ``str(exc)`` as ``(detail=...)`` when present. + """ + + def __init__(self, code: IXErrorCode, detail: str | None = None) -> None: + self.code = code + self.detail = detail + super().__init__(self._format()) + + def _format(self) -> str: + base = f"{self.code.value}: {self.code.default_message}" + if self.detail is None: + return base + return f"{base} (detail={self.detail})" + + def __str__(self) -> str: + return self._format() diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py new file mode 100644 index 0000000..abc115d --- /dev/null +++ b/tests/unit/test_errors.py @@ -0,0 +1,72 @@ +"""Tests for the IXException + IXErrorCode error model (spec §8).""" + +from __future__ import annotations + +import pytest + +from ix.errors import IXErrorCode, IXException + + +class TestIXErrorCode: + """Every spec §8 code is present with its documented default message.""" + + def test_all_spec_codes_exist(self) -> None: + expected = { + "IX_000_000", + "IX_000_002", + "IX_000_004", + "IX_000_005", + "IX_000_006", + "IX_000_007", + "IX_001_000", + "IX_001_001", + "IX_002_000", + "IX_002_001", + } + actual = {code.name for code in IXErrorCode} + assert expected == actual + + def test_code_value_matches_name(self) -> None: + # The enum value IS the string code. Tests at the call sites rely on + # e.g. `IXErrorCode.IX_000_000.value == "IX_000_000"`. + for code in IXErrorCode: + assert code.value == code.name + + def test_default_messages_cover_spec_triggers(self) -> None: + # Check a subset of the spec §8 trigger phrases show up in the default + # message. We don't pin exact wording — just that the message is + # recognisable for log-scraping. + assert "request_ix" in IXErrorCode.IX_000_000.default_message + assert "context" in IXErrorCode.IX_000_002.default_message + assert "files" in IXErrorCode.IX_000_004.default_message + assert "MIME" in IXErrorCode.IX_000_005.default_message + assert "page" in IXErrorCode.IX_000_006.default_message.lower() + assert "fetch" in IXErrorCode.IX_000_007.default_message.lower() + assert "empty" in IXErrorCode.IX_001_000.default_message.lower() + assert "use case" in IXErrorCode.IX_001_001.default_message.lower() + assert "inference" in IXErrorCode.IX_002_000.default_message.lower() + assert "structured output" in IXErrorCode.IX_002_001.default_message.lower() + + +class TestIXException: + def test_raise_returns_code_and_default_message(self) -> None: + with pytest.raises(IXException) as exc_info: + raise IXException(IXErrorCode.IX_000_000) + + exc = exc_info.value + assert exc.code is IXErrorCode.IX_000_000 + assert exc.detail is None + s = str(exc) + assert s.startswith("IX_000_000: ") + assert "request_ix is None" in s + assert "(detail=" not in s + + def test_exception_with_detail_includes_detail_in_str(self) -> None: + exc = IXException(IXErrorCode.IX_000_007, detail="https://example/x.pdf: 404") + s = str(exc) + assert s.startswith("IX_000_007: ") + assert "(detail=https://example/x.pdf: 404)" in s + + def test_exception_is_an_exception(self) -> None: + exc = IXException(IXErrorCode.IX_001_001) + assert isinstance(exc, Exception)