Give one logical call an optional hard wall-clock boundary (issue #22).
Leaving it unset keeps 1.3.5 behaviour verbatim: the timeout context is
never entered when deadline_s is None.
- new deadline.py: ensure_call_deadline() range check (None or a finite
positive number; bool/0/nan/inf and out-of-range ints are rejected as
ValueError so OverflowError never leaks) plus with_call_deadline(),
which distinguishes an expiry from a TimeoutError raised by the body
or its cleanup via a local-variable identity comparison rather than
cm.expired() alone
- new CallDeadlineExceeded: deliberately outside the four categories and
not a GatewayUnavailableError, and carries no retry_after_s
- new {SCOPE}__CALL_DEADLINE_S key, guarded on the env, direct
construction and dataclasses.replace paths
- three clients take a call_deadline_s constructor argument and a
keyword-only per-call override on chat/embed/recognize_text/
parse_layout; None inherits the assembled value
- validation runs before the awaitable is created, so an illegal value
cannot strand an un-awaited coroutine
- one embed call shares a single deadline across all of its batches
- import-linter gains a polygateway.deadline layer
- cover where the deadline lands: backoff sleep, admission polling,
the structured re-ask ladder and embedding's batch loop, plus the
empty-texts early return that stays outside it
- cover what an expiry costs: exactly one terminal_failure row carrying
error_type=CallDeadlineExceeded, a cancelled attempt row sharing its
logical_call_id, cleanup that outlives the deadline (lower bound only)
and an already-billed success being discarded
- pin the injected clock as orthogonal: a 10^6 second jump never expires
a call, yet total_latency_ms still reads that clock
Grow the telemetry contract from 26 to 36 fields and give every logical
call a failure terminal row, so SQL can finally answer "how many calls
failed" and "why did the whole pool die".
Schema and port move together with the emitter writes in one commit:
splitting them would ship columns that nothing populates.
- schema: append 10 nullable columns (scope, operation, logical_call_id,
event_kind, http_status_code, error_type, cause_type, error_body,
attempts, total_latency_ms) to all five definition sites in one order
- ports: 10 keyword-only parameters without defaults; the protocol
signature is now the single source the assembly gate derives from
- emitter: take domain exception objects instead of pre-flattened text
and pin down the diagnostics in one helper; a relabelled 503 stays
503 and success rows leave all five columns NULL
- emitter: reject recorders whose record_llm_call cannot accept the
current field shape at assembly time, since _record would otherwise
swallow the TypeError and drop every row while calls keep succeeding
- clients: write at most one terminal row per logical call through a
single shared exit, deduplicated by the call context; TelemetryMW
stops writing terminals so the two sites cannot double count
- clients: cancellation stays best effort and propagates, non-domain
exceptions get no terminal row and keep their classification
- transports: give _status_to_error an explicit operation and fix the
historically mislabelled embedding HTTP failures
- structured: promote the bounded error formatter so the reask feedback
and the terminal explanation share one set of limits
Terminal rows carry no cost and no tokens, so cost aggregation is
unchanged; failure counts must now filter on event_kind.
EmbeddingClient does not go through the chat onion: it builds its own
ChatRequest inside _emit purely to reuse the shared TelemetryEmitter, so
wiring chat() alone left every embed row without a tenant. Validate the
dimensions at the embed() entry (before batching, since anything failing
further down is degraded to a warning) and thread them through
_embed_batch -> _attempt -> _emit so every batch row carries the same
pair.
The embedding loop shares the wall-clock entered_at and the same stall
verdict, so it failed the same way through a different path: one timed-out
attempt, then any round with no runnable source, and _on_no_runnable
declared the scope dead. Issue #8 only recorded the chat path; the
regression test pins this one.
Mutation testing showed the negative structured-retries and expected-dim checks
in the env parsing path could be deleted with every test still passing. Their
value is the env key name in the message, so they need tests that assert it.
Changelog now states the real scope of this release and warns that normalising
scope moves the Redis keys, the one change here that silently relocates runtime
state. Records the breaker threshold derivation as deliberately env-only so it
does not resurface as another round.