feat(errors): IXException + IXErrorCode (spec §8) (#2)
Some checks are pending
tests / test (push) Waiting to run
Some checks are pending
tests / test (push) Waiting to run
Lands the single exception type and ten IX_* codes used throughout the pipeline.
This commit is contained in:
commit
ebdba99d9f
2 changed files with 162 additions and 0 deletions
90
src/ix/errors.py
Normal file
90
src/ix/errors.py
Normal 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
72
tests/unit/test_errors.py
Normal 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)
|
||||
Loading…
Reference in a new issue