Files
2026-07-13 12:24:33 +08:00

71 lines
3.6 KiB
Markdown

# Observability-aware errors (`errors.py`)
`lmcache/v1/mp_observability/errors.py` defines `LMCacheTimeoutError`, a
timeout exception that reports itself to the MP observability EventBus when it
is constructed. It is the single place timeouts in `lmcache/` should be raised
from; the `ban-raw-timeout-error` pre-commit hook enforces this by rejecting
any bare `raise TimeoutError(...)` / `raise asyncio.TimeoutError(...)` under
`lmcache/`.
## Why
Timeouts are the most common "something is stuck" failure in MP mode (MQ
round-trips, GPU transfer waits, adapter drains, NIXL handshakes). Before this,
each site raised the built-in `TimeoutError`, which left no trace in the
observability stack — operators only learned of a timeout if the surrounding
code happened to log it. Routing every timeout through one class makes them
uniformly observable (counter + log + trace) without touching each call site
beyond the class swap.
## Contract
`LMCacheTimeoutError(message: str, *, session_id: str = "")`
- **Subclass of the built-in `TimeoutError`.** Every existing
`except TimeoutError` handler continues to catch it unchanged, so swapping a
raw `raise TimeoutError(...)` for `raise LMCacheTimeoutError(...)` is
behaviour-preserving. (On Python 3.11+ `asyncio.TimeoutError` is the same
type, so `except asyncio.TimeoutError` catches it too; on 3.10 it does not —
do not rely on the async alias for catching these raises.)
- **Emits on construction, not on raise.** `__init__` publishes one
`EventType.TIMEOUT_RAISED` event (see [EVENTS.md](EVENTS.md)) to the global
EventBus via `get_event_bus()`.
- **Zero-cost when observability is off.** The emit path is guarded by
`is_observability_enabled()`, which is only `True` inside the MP server
process. In single-process / CLI mode the constructor does nothing beyond
`super().__init__(message)` (one boolean check) — no event, no stack-trace
capture, no OTel dependency exercised.
- **Never raises from `__init__`.** Any failure to publish is swallowed and
logged at debug level: observability must never break error handling.
- **`session_id`** is forwarded onto the event so the timeout span can nest
under the originating request's root span. Pass it where the raise site has a
request/`IPCCacheServerKey.request_id` in scope (e.g. `shm.prepare_store`);
leave it empty otherwise.
## What gets recorded
The emitted `TIMEOUT_RAISED` event carries `message`, `exception_type`, and a
captured `stacktrace` (the construction stack minus the `__init__` frame,
following the OTel `exception.*` semantic conventions). Three subscribers
consume it, registered by `init_observability` under the usual
metrics/logging/tracing toggles:
| Subscriber | Output | Default |
|---|---|---|
| `TimeoutMetricsSubscriber` | `lmcache_mp.timeouts` counter, tagged `exception_type` | on (`metrics_enabled`) |
| `TimeoutLoggingSubscriber` | `WARNING` log with message + stack trace | on (`logging_enabled`) |
| `TimeoutTracingSubscriber` | zero-duration `timeout` span with an `exception` event + ERROR status | on when tracing enabled |
The tracing subscriber records the exception the same way OTel's
`Span.record_exception` would (an `exception` span event with `exception.type`
/ `exception.message` / `exception.stacktrace` plus ERROR status), driven from
the EventBus drain thread where the original exception object is no longer
available.
## Extending
To make another timeout observable, raise `LMCacheTimeoutError` instead of the
built-in. To add a new observable error family, define a sibling subclass of
the relevant built-in here, add a matching `EventType`, and add subscriber(s)
that consume it — mirroring the timeout wiring.