Skip to content

Errors & tracing

The typed error taxonomy every Provider maps native faults into, and the thin, OTel-free tracing seam (ADR-0034).

errors

Typed error taxonomy — a small, stable exception hierarchy for the framework (#73).

Before this module the framework raised bare builtins (TypeError for a mis-wired Pipeline, ValueError for a bad config, RuntimeError for an endpoint/storage fault) with no way for an operator — or the Quality Report — to tell a Provider fault apart from a wiring mistake apart from a Storage failure. The Hardening bar (#73, DEFINITION-OF-DONE "Observability: … typed error taxonomy") asks for exactly that: a narrow hierarchy the Runner surfaces in the report + logs, so a failed run says which kind of thing broke.

The hierarchy is deliberately shallow — one root and a handful of categories — and lives in latence-core with zero dependencies (ADR-0016). It carries no heavy tracing machinery; that is the separate, optional :mod:latence_core.tracing seam.

LatenceError is the root every framework-raised error subclasses, so a caller can except LatenceError to catch anything the framework itself raised (and let a genuinely unexpected bug — a real KeyError in provider code — propagate untyped). Each category also multiply-inherits the builtin it replaces so existing except ValueError / except RuntimeError call sites (and the ADR-0009 Storage-scheme guard, the S11 Corpus Store, the S12 endpoint retry) keep working unchanged — the taxonomy is additive, it narrows the type without breaking the old contract:

  • :class:ContractError (also a TypeError) — an inter-Stage contract / DAG-wiring violation: a Stage wired to the wrong carrier, a missing depends_on edge, a Provider that does not satisfy its Capability. The Pipeline is malformed; retrying will not help.
  • :class:ConfigError (also a ValueError) — an operator-supplied config is invalid: a disallowed Storage scheme, a ReDoS-signature entity pattern, a malformed gold set. Fix the config, not the code.
  • :class:ProviderError (also a RuntimeError) — a Provider (typically an endpoint) failed at runtime. retryable marks a transient fault (a dropped connection, a 5xx) the Runner's retry may recover from, vs a terminal one.
  • :class:StorageError (also a RuntimeError) — a Storage / Corpus-Version operation failed or is inconsistent (an uncommitted Version inspected, a torn write).
  • :class:ScreeningQuarantineError — a typed disposition, not a crash: raised only where a caller opts to treat a Quarantine as fatal (Screening's normal path never raises — it Quarantines and continues, CONTEXT Quarantine). It carries the record id + reason so the disposition is inspectable. Counts/ids only, never raw record content — the same no-PII discipline the Quality Report keeps.

The category class attribute is the stable, lowercase tag the Quality Report + tracing spans stamp ("contract", "config", "provider", "storage", "screening", or "unknown" for a non-taxonomy error), so an operator greps one field, not a Python type name that could be refactored. :func:error_category maps any caught exception to that tag — a taxonomy error to its own category, a stray builtin to "unknown" — so the Runner can label a failed Stage without importing every provider's error type.

LatenceError

Bases: Exception

Root of the framework's typed error taxonomy (#73).

Everything the framework itself raises subclasses this, so except LatenceError catches a framework-originated failure while letting a genuinely unexpected bug in provider code propagate untyped. The category class attribute is the stable, lowercase tag the Quality Report + tracing spans record.

ContractError

Bases: LatenceError, TypeError

An inter-Stage contract or DAG-wiring violation (a malformed Pipeline).

A Stage received the wrong carrier, a required depends_on edge is missing, or a Provider does not satisfy the Capability its Stage declares. Retrying cannot fix it — the Pipeline is wired wrong. Also a TypeError so the pre-taxonomy except TypeError wiring guards (and their tests) keep catching it.

ConfigError

Bases: LatenceError, ValueError

An operator-supplied configuration is invalid.

A disallowed Storage URI scheme (ADR-0009 allowlist), a ReDoS-signature entity pattern (#28), a malformed gold set. Fix the config, not the code. Also a ValueError so the pre-taxonomy except ValueError config guards keep catching it.

ProviderError

ProviderError(message: str, *, retryable: bool = False)

Bases: LatenceError, RuntimeError

A Provider failed at runtime (typically a remote inference endpoint).

retryable marks a transient fault (a dropped connection, a rate-limit, a 5xx) the Runner's ported retry may recover from, vs a terminal one (a 4xx, a bad response) that fails fast. Also a RuntimeError so the pre-taxonomy endpoint-retry code path — which caught RuntimeError — keeps working.

Source code in packages/latence-core/src/latence_core/errors.py
def __init__(self, message: str, *, retryable: bool = False) -> None:
    super().__init__(message)
    self._retryable = retryable

StorageError

Bases: LatenceError, RuntimeError

A Storage or Corpus-Version operation failed or is inconsistent.

An uncommitted Corpus Version inspected, a torn write, a read of a missing run artifact. Also a RuntimeError so the S11 Corpus Store's pre-taxonomy except RuntimeError call sites keep working.

PageSliceMissingError

PageSliceMissingError(record_id: str)

Bases: ContractError

A chunk was asked for a page and carries no :class:~latence_core.contracts.PageSlice.

Since v19 every chunk is self-describing: the Chunk Stage stamps the page spans overlapping that chunk onto the chunk itself, so resolution needs nothing but the record in hand. Reaching this error means the record was produced by a Chunker that supplied no page spans at all — a wiring/producer defect, which is why it is a :class:ContractError.

It is an ERROR rather than a None on purpose. The mechanism this replaced returned None and let every call site degrade to the chunk's inherited [page_start, page_end] range, which is a plausible-looking wrong page — indistinguishable from a correct one in the output, and counted only in an aggregate nobody reads. ADR-0034: a failure is observable or it is not handled. Carries the chunk's record id (an identifier, never content — the same no-PII discipline as :class:ScreeningQuarantineError).

Source code in packages/latence-core/src/latence_core/errors.py
def __init__(self, record_id: str) -> None:
    super().__init__(
        f"chunk {record_id!r} carries no page_slice, so the source page of an offset "
        f"inside it cannot be resolved. The Chunk Provider that produced it must stamp "
        f"PageSlice.for_span(document.page_map, chunk.char_start, chunk.char_end)."
    )
    self.record_id = record_id

PageMapSidecarError

Bases: ContractError

A document's page-map sidecar could not be honoured — malformed, stale, or missing.

The out-of-band handoff (ADR-0060) has one failure that matters: the sidecar and the markdown drift apart. Its spans then still validate — contiguous, ascending, plausibly sized — and still resolve every offset to a page, just the wrong one. That is the same silent-plausible-wrong failure :class:PageSliceMissingError exists to prevent, one Stage earlier, so it gets the same posture: a typed error rather than a best-effort map. The absent-but-REQUIRED case (a Parser configured for a corpus the operator declared paginated, handed a document with no sidecar) raises the same error, because to a consumer the two are the same thing — page boundaries this document was supposed to have and does not.

A Parser catches this and emits a PARSE_ERROR :class:~latence_core.contracts.DocumentRecord — the document is counted in the Quality Report with the reason attached, and the run continues (ADR-0020). It carries no document content, only the shape mismatch (the same no-PII discipline as :class:ScreeningQuarantineError).

ScreeningQuarantineError

ScreeningQuarantineError(
    message: str, *, record_id: str, reason: str
)

Bases: LatenceError

A Quarantine disposition surfaced as a typed error (an opt-in fatal Screening).

Screening's normal path does not raise — it Quarantines a record and the run continues (CONTEXT Quarantine); this exists for a caller that chooses to treat a Quarantine as fatal (a strict batch that must abort on the first dangerous file). It carries the quarantined record's id and the reason so the disposition stays inspectable, never the raw record content — the same no-PII discipline the Quality Report keeps.

Source code in packages/latence-core/src/latence_core/errors.py
def __init__(self, message: str, *, record_id: str, reason: str) -> None:
    super().__init__(message)
    self.record_id = record_id
    self.reason = reason

error_category

error_category(exc: BaseException) -> str

The stable, lowercase category tag for any exception ("unknown" off-taxonomy).

A :class:LatenceError maps to its own category; anything else (a stray builtin, a provider bug) maps to :data:CATEGORY_UNKNOWN. This is what lets the Runner label a failed Stage in the Quality Report + tracing span without importing every Provider's error type — it reads one field off the caught exception.

Source code in packages/latence-core/src/latence_core/errors.py
def error_category(exc: BaseException) -> str:
    """The stable, lowercase category tag for *any* exception (``"unknown"`` off-taxonomy).

    A :class:`LatenceError` maps to its own ``category``; anything else (a stray builtin, a
    provider bug) maps to :data:`CATEGORY_UNKNOWN`. This is what lets the Runner label a
    failed Stage in the Quality Report + tracing span without importing every Provider's
    error type — it reads one field off the caught exception.
    """
    if isinstance(exc, LatenceError):
        return exc.category
    return CATEGORY_UNKNOWN

tracing

Lightweight tracing spans — a thin, optional observability seam (#73).

The Hardening bar (DEFINITION-OF-DONE "Observability: tracing spans …") asks for structured per-Stage span events a downstream tracer could consume — without pulling a heavy OpenTelemetry dependency into latence-core (ADR-0016: the core stays near-zero dependency). So this is deliberately not OTel: it is a stdlib-only seam that emits a structured event per Stage begin / end (and per checkpoint batch), each stamped with a run-scoped correlation id so every event of one run is greppable together and an adopter can fan them into a real tracer (OTel, a log pipeline) at the edge if they want one.

Two design rules the tests pin:

  • Counts only, never content. A span's fields carry the Stage name, capability, provider, record counts, duration, and — on a failure — the typed error category (:mod:latence_core.errors) and message. They never carry a record's text, a PII surface, a secret, or a document body. The same no-PII discipline the Quality Report keeps: a span is safe to ship to a shared tracer.
  • Determinism / opt-in. The default sink is a :class:LoggingSpanSink that formats a span as a single structured logging record on the latence.trace logger at DEBUG — silent unless an operator turns that logger on, so a default run's stdout is unchanged (Baseline determinism). A caller who wants the events (a test, a downstream tracer) passes a :class:CollectingSpanSink or their own :class:SpanSink. No wall-clock leaks into the event payload (duration_ms is measured by the caller via time.perf_counter and is the only timing field), so a seeded run's span shape is byte-stable even though the duration value itself varies.

The correlation id is "<run_id>" — the run is the trace; each Stage span is a child keyed by stage. That is enough for a downstream tracer to reconstruct the per-run tree without this module owning a span-context stack.

Span dataclass

Span(
    correlation_id: str,
    stage: str,
    event: str,
    fields: dict[str, SpanField] = dict(),
    duration_ms: float | None = None,
    error_category: str | None = None,
    error_message: str | None = None,
)

One structured trace event: a Stage begin / end / batch of a run.

correlation_id is the run-scoped id every span of a single run shares (the run is the trace). stage names the Stage; event is "stage_begin", "stage_end", or "stage_batch". fields are the safe, counts-only attributes — never record content. duration_ms is present on an end event (the Stage's wall time, the only timing the caller measured); error_category / error_message are present only on a failed end event (the typed :mod:latence_core.errors category + message).

as_dict

as_dict() -> dict[str, SpanField]

A flat, JSON-able dict of the span — what a :class:SpanSink records/ships.

Deterministic key order; None optionals are omitted so a begin event and a clean end event carry no empty error keys.

Source code in packages/latence-core/src/latence_core/tracing.py
def as_dict(self) -> dict[str, SpanField]:
    """A flat, JSON-able dict of the span — what a :class:`SpanSink` records/ships.

    Deterministic key order; ``None`` optionals are omitted so a ``begin`` event and a
    clean ``end`` event carry no empty error keys.
    """
    out: dict[str, SpanField] = {
        "correlation_id": self.correlation_id,
        "stage": self.stage,
        "event": self.event,
    }
    if self.duration_ms is not None:
        out["duration_ms"] = self.duration_ms
    if self.error_category is not None:
        out["error_category"] = self.error_category
    if self.error_message is not None:
        out["error_message"] = self.error_message
    for key in sorted(self.fields):
        out[key] = self.fields[key]
    return out

SpanSink

Bases: Protocol

Where spans go — the thin seam a downstream tracer plugs into (no OTel dep in core).

LoggingSpanSink

LoggingSpanSink(logger: Logger | None = None)

The default sink: one structured logging record per span on latence.trace.

Emitted at DEBUG so a default run is silent (the operator opts in by turning the latence.trace logger on), keeping stdout unchanged for a normal run. The record's msg is a stable "trace <event> <stage>" and every span field is attached as extra so a structured-logging handler (JSON formatter, log pipeline) picks them up as first-class fields rather than an opaque string.

Source code in packages/latence-core/src/latence_core/tracing.py
def __init__(self, logger: logging.Logger | None = None) -> None:
    self._logger = logger or logging.getLogger(_TRACE_LOGGER_NAME)

CollectingSpanSink

CollectingSpanSink()

An in-memory sink that keeps every span — the test + local-inspection sink.

Deterministic: spans are appended in emission order, so a seeded run's span sequence is reproducible and a regression test can assert the exact begin/end pairing (and that no field carries content).

Source code in packages/latence-core/src/latence_core/tracing.py
def __init__(self) -> None:
    self.spans: list[Span] = []

RunTracer

RunTracer(
    correlation_id: str, sink: SpanSink | None = None
)

A run-scoped span emitter: stamps every span with the run's correlation id (#73).

Constructed once per run with the run id (the correlation id) and a :class:SpanSink (default :class:LoggingSpanSink). The Runner calls :meth:stage_begin / :meth:stage_end / :meth:stage_batch around each Stage; each builds a :class:Span stamped with the correlation id and hands it to the sink. Passing sink=None and the default logger keeps a normal run silent — the seam is inert until an operator wires a real sink or turns the trace logger on.

Source code in packages/latence-core/src/latence_core/tracing.py
def __init__(self, correlation_id: str, sink: SpanSink | None = None) -> None:
    self.correlation_id = correlation_id
    self._sink: SpanSink = sink if sink is not None else LoggingSpanSink()