Files
mcp-nextcloud/nextcloud_mcp_server/observability/readiness.py
T
Chris CoutinhoandClaude Opus 4.8 cc2ce6e853 fix(config): correct OIDC token-type/scopes env keys; address review round 1
- _DEFAULTS keys for NEXTCLOUD_OIDC_TOKEN_TYPE / NEXTCLOUD_OIDC_SCOPES were
  registered as oidc_* (uppercasing to OIDC_*), so dynaconf
  (ignore_unknown_envvars) never read the NEXTCLOUD_-prefixed env vars and the
  fields stayed at their defaults. Prefix the keys to match _field_map; add a
  regression test.
- Add gte=1 validator for HEALTH_READY_REFRESH_INTERVAL and a 1..65535 range
  validator for PORT.
- Tie ReadinessCache.ttl_seconds to 2x the configured refresh interval so
  is_stale() stays meaningful when the interval is tuned.
- Raise the refresh-loop exception log from DEBUG to WARNING.
- Make health_ready a sync handler (no awaits); dedupe the localhost fallback
  into _DEFAULT_MCP_SERVER_URL; use pytest.approx for the float default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:45:02 +02:00

77 lines
2.7 KiB
Python

"""Non-blocking readiness dependency-health cache.
Kubernetes readiness probes must be cheap and must not gate a (typically
single-replica) tenant Pod out of its Service on transient external-dependency
latency. Doing so converts a *degraded* shared dependency (Nextcloud, Qdrant)
into a *total* outage: the only Pod is removed from the Service, the gateway
has no upstream, and connected MCP clients see their streamable-HTTP sessions
drop and fail to reconnect (Deck #302).
A background loop refreshes this snapshot off the probe path; the readiness
handler only ever reads ``snapshot()`` (no I/O), so probe latency is decoupled
from upstream latency. Dependency results are reported for observability but are
intentionally *non-gating*.
"""
from __future__ import annotations
import time
from pydantic import BaseModel, Field
class DependencyStatus(BaseModel):
"""Last observed health of a single external dependency.
``healthy`` is ``None`` until the first check completes; ``detail`` carries
the human-readable string the readiness handler reports verbatim
(``"ok"`` / ``"embedded"`` / ``"pending"`` / ``"error: ..."``).
"""
name: str
healthy: bool | None = None
detail: str = "pending"
checked_at: float = 0.0
class ReadinessCache(BaseModel):
"""Time-bounded snapshot of external dependency health.
Written only by the background refresh loop and read only by the readiness
handler. The single-writer invariant (one refresh loop) is what makes this
safe without a lock; a reader simply tolerates seeing the previous value for
one entry until the next refresh.
"""
ttl_seconds: float = 30.0
statuses: dict[str, DependencyStatus] = Field(default_factory=dict)
def snapshot(self) -> dict[str, DependencyStatus]:
"""Return a shallow copy of the current per-dependency statuses."""
return dict(self.statuses)
def update(
self, name: str, healthy: bool, detail: str, *, now: float | None = None
) -> None:
"""Record the outcome of a dependency check."""
self.statuses[name] = DependencyStatus(
name=name,
healthy=healthy,
detail=detail,
checked_at=time.monotonic() if now is None else now,
)
def is_stale(self, *, now: float | None = None) -> bool:
"""True when there is no data yet or any entry is older than the TTL.
Exposed for observability/diagnostics; the refresh loop runs on a fixed
cadence rather than polling this.
"""
if not self.statuses:
return True
current = time.monotonic() if now is None else now
return any(
current - status.checked_at >= self.ttl_seconds
for status in self.statuses.values()
)