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 aTypeError) — an inter-Stage contract / DAG-wiring violation: a Stage wired to the wrong carrier, a missingdepends_onedge, a Provider that does not satisfy its Capability. The Pipeline is malformed; retrying will not help. - :class:
ConfigError(also aValueError) — 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 aRuntimeError) — a Provider (typically an endpoint) failed at runtime.retryablemarks a transient fault (a dropped connection, a 5xx) the Runner's retry may recover from, vs a terminal one. - :class:
StorageError(also aRuntimeError) — 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, CONTEXTQuarantine). 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 ¶
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
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 ¶
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
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 ¶
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
error_category ¶
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
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
fieldscarry 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:
LoggingSpanSinkthat formats a span as a single structuredloggingrecord on thelatence.tracelogger 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:CollectingSpanSinkor their own :class:SpanSink. No wall-clock leaks into the event payload (duration_msis measured by the caller viatime.perf_counterand 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 ¶
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
SpanSink ¶
Bases: Protocol
Where spans go — the thin seam a downstream tracer plugs into (no OTel dep in core).
LoggingSpanSink ¶
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
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
RunTracer ¶
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.