Health checking¶
EDRI has a built-in health-checking subsystem that monitors every Manager process, aggregates their status, and exposes it on an HTTP endpoint. It is cross-cutting: the Router drives and aggregates the checks, while each Manager reports its own status and metrics on every round-trip. The Switch, which lives outside the router/manager/event topology, exposes its own analogous read-only status endpoint separately.
How it works¶
A periodic task is scheduled via the Scheduler Manager (using the scheduler’s
Setevent).The scheduled task emits a router
HealthCheckevent at a fixed interval. Every Manager is subscribed to it, so it doubles as a heartbeat to each Manager's pipe.Managers respond with status information, which the Router's HealthChecker aggregates.
If a Manager stops reporting for multiple intervals, the HealthChecker escalates its recorded status to
WARNING, thenERROR. This escalation does not trigger a restart on its own — it only updates the status surfaced to operators.A restart happens instead when delivering an event to a Manager fails with a broken pipe (a dead process). Because the periodic
HealthCheckis routed to every Manager, it is normally what detects the dead pipe. The Router then drops the subscription and restarts the component from its definition, withfrom_timeset to the moment of failure so missed events are replayed (see Delivery guarantees). A restart only happens if the component definition is available to the HealthChecker.
Handler exceptions are captured, not fatal¶
When a @request / @response handler raises, the Manager does not crash. The resolver loop catches the exception, records it (the event name, the event payload, the exception, a formatted traceback, and the timestamp when it was caught), and keeps processing other events. The captured exceptions are attached to that Manager's next HealthCheck reply and flip its reported status to WARNING, then the buffer is cleared.
This is how handler errors become observable without scraping logs: they surface in the status endpoint below, per component, until the next clean health cycle.
Status endpoints¶
EDRI exposes three HTTP endpoints for health monitoring, each serving a different purpose:
Endpoint |
Purpose |
Behaviour |
|---|---|---|
|
Liveness — confirms the Router event loop is running. Does not depend on component readiness. |
Always returns HTTP 200 when the Router event loop is running. Use for Kubernetes |
|
Readiness — all components are ready to accept traffic. |
Returns HTTP 200 when all components report ready. Returns HTTP 503 if any component is warming up, draining, or has an unreachable dependency. |
|
Diagnostic — detailed per-component health, vitals, exceptions. |
Returns HTTP 200 (or 503 when any component is not ready) with full diagnostic data. |
Both /edri/ready and /edri/health-check-status expose the same per-component records described below. No application code is needed for any of these endpoints.
Diagnostic endpoint¶
The aggregated health of all monitored components is exposed as a built-in API resource:
GET /edri/health-check-status(eventEdriHealth, resourcehealth-check-status)Available as JSON or, via Content Negotiation, rendered HTML (template
health_check_status.j2).
Each record describes one monitored component:
Field |
Meaning |
|---|---|
|
Component name. |
|
When its last health reply was recorded. |
|
|
|
Whether the component reports itself ready to accept traffic (a healthy component can still be |
|
Human-readable explanation for a non-OK status or non-ready state; |
|
Handler exceptions caught since the previous reply. Each entry includes the event name, the exception message, a formatted traceback, and the timestamp when it was captured ( |
|
Round-trip latency of the last health check, measured by the Router (dispatch → component → back). Rises before a struggling component goes silent, so it is a degradation signal the component cannot report about itself. |
|
Operational metrics that ride along on the periodic health round-trip (below). |
Vitals¶
Some vitals are point-in-time snapshots; some are deltas accumulated over the interval since the previous check (divide by EDRI_HEALTH_CHECK_TIMEOUT for a per-second rate). All process-level metrics are best-effort and report null when unreadable on the platform.
Vital |
Kind |
Meaning |
|---|---|---|
|
snapshot |
Workers currently alive (busy slots). |
|
snapshot |
Work queued because all slots are full — the component's backpressure signal. |
|
snapshot |
The |
|
snapshot |
Age of the longest-running worker ( |
|
delta |
Events dispatched to handlers since the previous check (÷ interval = throughput). |
|
delta |
Of those, how many left a |
|
delta |
CPU time (user + system) consumed since the previous check (÷ interval = CPU utilization). |
|
snapshot |
Resident memory now / high-water mark, in bytes. |
|
snapshot |
Live threads — a growing count flags a thread leak. |
|
snapshot |
Open file descriptors — an fd-leak detector. |
|
snapshot |
Component process PID, for correlation with external tools. |
|
snapshot |
Seconds since the process entered its run loop — a small value on a long-lived component points at a crash-loop. |
|
— |
Application-defined metrics added via |
Reporting custom health (health())¶
A Manager assesses its own health via an overridable health() probe that returns a HealthReport(status, ready, reason). The default derives status purely from caught exceptions (any → WARNING) and always reports ready=True. Override it to add domain knowledge — typically checking the dependencies the component needs:
from edri.dataclass.health_checker import HealthReport, Status
def health(self) -> HealthReport:
if not self._database.is_connected():
return HealthReport(Status.DEGRADED, ready=False, reason="database unreachable")
return super().health()
The probe runs inline on the component's main loop on every health round-trip, so keep it cheap and non-blocking. Caught exceptions are reported separately regardless, so an override should escalate status, not suppress exceptions.
Adding custom vitals (collect_custom_vitals())¶
Beyond the built-in vitals, a Manager can attach its own metrics by overriding collect_custom_vitals(). They ride along on the same periodic health round-trip and appear under each record's vitals.custom:
from typing import Any
def collect_custom_vitals(self) -> dict[str, Any]:
return {
"queue_depth": self._inbox.qsize(),
"cache_entries": len(self.cache),
"last_sync_age_seconds": time() - self._last_sync,
}
The keys are reported verbatim (not camel-cased) under vitals.custom in the JSON response and each renders as its own line in the HTML page. Like health(), the probe runs inline on the main loop on every check, so keep it cheap and non-blocking. Return JSON-serializable values so they survive both the pipe round-trip and the endpoint's JSON encoding (datetime, Enum, UUID and bytes are encoded automatically). If the override raises, it is caught and logged and the custom block degrades to empty — it never wipes out the standard vitals.
Configuration¶
Environment Variable |
Default |
Description |
|---|---|---|
|
|
Interval (seconds) between health checks. |
|
|
How many missed intervals are tolerated before escalating status. |
|
|
When |
See Environment Variables for the full list.