feat(errors): IXException + IXErrorCode (spec §8) #2

Merged
goldstein merged 1 commit from feat/errors into main 2026-04-18 08:46:19 +00:00
2 changed files with 162 additions and 0 deletions

90
src/ix/errors.py Normal file
View file

@ -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()

72
tests/unit/test_errors.py Normal file
View file

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