fix(health): non-gating readiness probe; shared-task-group lifespan; settings migration
Fixes MCP reconnect timeouts on tenant servers (Deck #302). Three changes: - /health/ready now gates only on local config. Nextcloud/Qdrant health is refreshed by a background loop, cached, and reported but NON-gating, so a single-replica tenant Pod is no longer pulled from its Service on a transient dependency blip (which dropped every MCP streamable-HTTP session and caused reconnect timeouts). The probe path performs no external I/O. - Refactor starlette_lifespan: collapse the four near-identical per-mode task-group + session + yield + teardown skeletons into one shared task group that also runs the readiness refresh loop; each mode contributes a (start, teardown) pair. eviction_task_group is now always present. - Migrate app.py off os.getenv: all config is read through dynaconf Settings (adds health_ready_refresh_interval, oidc_token_type, oidc_scopes, port). Inline/dynamic defaults preserved at each call site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
86ed15f466
commit
6ef7786cec
@@ -123,14 +123,9 @@ class TestSetupOAuthConfigForMultiUserBasic:
|
||||
|
||||
valid_fernet_key = Fernet.generate_key().decode()
|
||||
|
||||
# Mock TOKEN_ENCRYPTION_KEY environment variable
|
||||
mocker.patch(
|
||||
"os.getenv",
|
||||
side_effect=lambda k, default=None: {
|
||||
"TOKEN_ENCRYPTION_KEY": valid_fernet_key,
|
||||
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
|
||||
}.get(k, default),
|
||||
)
|
||||
# Provide the encryption key via settings: the function reads from the
|
||||
# injected Settings, not os.getenv, after the env->settings migration.
|
||||
hybrid_auth_settings.token_encryption_key = valid_fernet_key
|
||||
|
||||
# Mock httpx.AsyncClient
|
||||
mock_response = MagicMock()
|
||||
@@ -273,17 +268,12 @@ class TestSetupOAuthConfigForMultiUserBasic:
|
||||
self, hybrid_auth_settings, oidc_discovery_response, mocker
|
||||
):
|
||||
"""Test using custom OIDC discovery URL."""
|
||||
# Mock OIDC_DISCOVERY_URL environment variable
|
||||
# Provide the custom discovery URL via settings: the function reads from
|
||||
# the injected Settings, not os.getenv, after the env->settings migration.
|
||||
custom_discovery_url = (
|
||||
"https://custom.idp.example.com/.well-known/openid-configuration"
|
||||
)
|
||||
mocker.patch(
|
||||
"os.getenv",
|
||||
side_effect=lambda k, default=None: {
|
||||
"OIDC_DISCOVERY_URL": custom_discovery_url,
|
||||
"NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000",
|
||||
}.get(k, default),
|
||||
)
|
||||
hybrid_auth_settings.oidc_discovery_url = custom_discovery_url
|
||||
|
||||
# Mock httpx.AsyncClient
|
||||
mock_response = MagicMock()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Unit tests for the readiness dependency-health cache (Deck #302).
|
||||
|
||||
The readiness probe reads this snapshot without performing any I/O; these tests
|
||||
pin the small amount of logic it relies on (update/snapshot/staleness).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.observability.readiness import (
|
||||
DependencyStatus,
|
||||
ReadinessCache,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_dependency_status_defaults():
|
||||
status = DependencyStatus(name="nextcloud")
|
||||
assert status.healthy is None # not yet checked
|
||||
assert status.detail == "pending"
|
||||
assert status.checked_at == 0.0
|
||||
|
||||
|
||||
def test_update_and_snapshot_round_trip():
|
||||
cache = ReadinessCache()
|
||||
cache.update("nextcloud_reachable", True, "ok", now=100.0)
|
||||
cache.update("qdrant", False, "error: status 503", now=100.0)
|
||||
|
||||
snap = cache.snapshot()
|
||||
assert snap["nextcloud_reachable"].healthy is True
|
||||
assert snap["nextcloud_reachable"].detail == "ok"
|
||||
assert snap["qdrant"].healthy is False
|
||||
assert snap["qdrant"].detail == "error: status 503"
|
||||
|
||||
|
||||
def test_snapshot_is_a_copy():
|
||||
cache = ReadinessCache()
|
||||
cache.update("nextcloud_reachable", True, "ok", now=1.0)
|
||||
snap = cache.snapshot()
|
||||
# Mutating the returned mapping must not affect the cache.
|
||||
snap.clear()
|
||||
assert "nextcloud_reachable" in cache.snapshot()
|
||||
|
||||
|
||||
def test_is_stale_when_empty():
|
||||
assert ReadinessCache(ttl_seconds=30.0).is_stale(now=0.0) is True
|
||||
|
||||
|
||||
def test_is_stale_respects_ttl():
|
||||
cache = ReadinessCache(ttl_seconds=30.0)
|
||||
cache.update("nextcloud_reachable", True, "ok", now=100.0)
|
||||
|
||||
# Within the TTL window the snapshot is fresh.
|
||||
assert cache.is_stale(now=120.0) is False
|
||||
# At/after the TTL boundary it is stale.
|
||||
assert cache.is_stale(now=130.0) is True
|
||||
|
||||
|
||||
def test_is_stale_if_any_entry_is_old():
|
||||
cache = ReadinessCache(ttl_seconds=30.0)
|
||||
cache.update("nextcloud_reachable", True, "ok", now=100.0)
|
||||
cache.update("qdrant", True, "ok", now=200.0)
|
||||
# nextcloud_reachable is well past the TTL even though qdrant is fresh.
|
||||
assert cache.is_stale(now=205.0) is True
|
||||
Reference in New Issue
Block a user