test(errors): pin __cause__ preservation on every fail-fast wrap
The six `raise TypedError(...) from exc` sites (manifest.py x3, materialize.py, connectors.py x2) turn leaked OSError / ValueError / sqlite3.Error / URLError into typed IngestErrors while keeping the original under __cause__ — but no test asserted the chain, so a stray bare `raise` could drop the cause unnoticed. Add a conformance suite, one test per site, each asserting the chained cause type. The module docstring documents the invariant. Companion to test_error_codes.py, which pins the `.code` on the same sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBbjgS5A55RVavoyjJC4FX
This commit is contained in:
parent
1747238a83
commit
3a85a4785e
1 changed files with 95 additions and 0 deletions
95
tests/test_exception_chaining.py
Normal file
95
tests/test_exception_chaining.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Exception chaining: every fail-fast wrap preserves its `__cause__`.
|
||||
|
||||
The library turns leaked stdlib failures (OSError, ValueError, sqlite3.Error,
|
||||
URLError) into typed IngestErrors at the boundary — but always with
|
||||
`raise TypedError(...) from exc`, never a bare `raise`. That `from exc` keeps
|
||||
the triggering exception under `__cause__`, so an operator reading a traceback
|
||||
still sees the underlying cause (which file, which OS error, which SQL fault)
|
||||
beneath the typed message. This file IS the conformance suite for that
|
||||
invariant: one test per `raise ... from exc` site, asserting the chained type.
|
||||
Companion to test_error_codes.py, which pins the `.code` on the same sites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import llm_ingestion_okf.connectors as connectors
|
||||
from llm_ingestion_okf.connectors import read_sql, safe_resolve
|
||||
from llm_ingestion_okf.errors import ManifestError, SourceError
|
||||
from llm_ingestion_okf.manifest import load_manifest, load_manifest_bytes
|
||||
from llm_ingestion_okf.materialize import materialize_bundle
|
||||
|
||||
INGESTED_AT = "2026-07-17T12:00:00Z"
|
||||
|
||||
|
||||
# --- ManifestError sites (manifest.py, materialize.py) ---
|
||||
|
||||
|
||||
def test_load_manifest_unreadable_chains_oserror(tmp_path: Path) -> None:
|
||||
# manifest.py: OSError on read_bytes -> ManifestError(manifest_unreadable).
|
||||
with pytest.raises(ManifestError) as excinfo:
|
||||
load_manifest(tmp_path / "nope.json")
|
||||
assert isinstance(excinfo.value.__cause__, OSError)
|
||||
|
||||
|
||||
def test_materialize_unreadable_manifest_chains_oserror(tmp_path: Path) -> None:
|
||||
# materialize.py: the separate read inside materialize_bundle wraps the
|
||||
# same OSError once ingested_at has validated.
|
||||
with pytest.raises(ManifestError) as excinfo:
|
||||
materialize_bundle(tmp_path / "nope.json", tmp_path / "bundle", INGESTED_AT)
|
||||
assert isinstance(excinfo.value.__cause__, OSError)
|
||||
|
||||
|
||||
def test_load_manifest_bytes_bad_utf8_chains_unicode_error() -> None:
|
||||
# manifest.py: bytes that are not UTF-8 -> ManifestError(manifest_invalid_json).
|
||||
with pytest.raises(ManifestError) as excinfo:
|
||||
load_manifest_bytes(b"\xff\xfe")
|
||||
assert isinstance(excinfo.value.__cause__, UnicodeDecodeError)
|
||||
|
||||
|
||||
def test_load_manifest_bytes_bad_json_chains_value_error() -> None:
|
||||
# manifest.py: valid UTF-8 that is not JSON -> the same code, a ValueError
|
||||
# cause (json.JSONDecodeError is a ValueError subclass).
|
||||
with pytest.raises(ManifestError) as excinfo:
|
||||
load_manifest_bytes(b"not json")
|
||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
||||
|
||||
|
||||
# --- SourceError sites (connectors.py) ---
|
||||
|
||||
|
||||
def test_safe_resolve_embedded_null_chains_value_error(tmp_path: Path) -> None:
|
||||
# connectors.py: a NUL byte makes the path syscalls raise ValueError before
|
||||
# any boundary check -> SourceError(path_escape).
|
||||
with pytest.raises(SourceError) as excinfo:
|
||||
safe_resolve(tmp_path, "bad\x00.md")
|
||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
||||
|
||||
|
||||
def test_read_sql_failure_chains_sqlite_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# connectors.py: a failing query -> SourceError(sql_failed) over sqlite3.Error.
|
||||
db = tmp_path / "fixture.db"
|
||||
sqlite3.connect(db).close()
|
||||
monkeypatch.setenv("OKF_TEST_DB", str(db))
|
||||
with pytest.raises(SourceError) as excinfo:
|
||||
read_sql("OKF_TEST_DB", "SELECT nope FROM missing", max_rows=1)
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.Error)
|
||||
|
||||
|
||||
def test_urllib_get_transport_failure_chains_urlerror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# connectors.py: a transport failure -> SourceError(http_transport) over URLError.
|
||||
def refuse(request: Any) -> Any:
|
||||
raise urllib.error.URLError("refused")
|
||||
|
||||
monkeypatch.setattr(connectors, "urlopen", refuse)
|
||||
with pytest.raises(SourceError) as excinfo:
|
||||
connectors.urllib_get("https://example.test/x", None)
|
||||
assert isinstance(excinfo.value.__cause__, urllib.error.URLError)
|
||||
Loading…
Add table
Add a link
Reference in a new issue