diff --git a/plugins/memory/retaindb/__init__.py b/plugins/memory/retaindb/__init__.py index 62121410d41c..f3ca489aefe3 100644 --- a/plugins/memory/retaindb/__init__.py +++ b/plugins/memory/retaindb/__init__.py @@ -477,6 +477,59 @@ def name(self) -> str: def is_available(self) -> bool: return bool(os.environ.get("RETAINDB_API_KEY")) + def health_check(self) -> tuple[bool, str]: + """Probe RetainDB by GET-ing ``/v1/memory/profile/`` + with the configured key. Side-effect-free; just validates + auth + base_url reachability. + + Returns (RFC #42 conventions): + (True, "") — profile GET succeeds. + (False, "no_api_key") — RETAINDB_API_KEY unset. + (False, "sdk_missing") — `requests` not installed. + (False, "auth: ...") — 401/403/forbidden/ + unauthorized/invalid-api-key/ + authentication. + (False, "not_found: ...") — 404 (wrong base_url or + project routing missing). + (False, "unreachable: ...") — anything else. + + MUST NOT raise. + """ + api_key = os.environ.get("RETAINDB_API_KEY", "") + if not api_key: + return (False, "no_api_key") + base_url = re.sub( + r"/+$", "", + os.environ.get("RETAINDB_BASE_URL", _DEFAULT_BASE_URL)) + project = os.environ.get("RETAINDB_PROJECT", "default") + try: + import requests # noqa: F401 + except ImportError: + return (False, "sdk_missing") + try: + client = _Client(api_key=api_key, base_url=base_url, + project=project) + client.get_profile("hermes-doctor-probe") + except Exception as exc: # noqa: BLE001 + msg = str(exc) + msg_lc = msg.lower() + # _Client.request raises with the status code embedded + # in the message — pattern match for the classes we care + # about. + if ( + "401" in msg + or "403" in msg + or "unauthorized" in msg_lc + or "forbidden" in msg_lc + or "invalid api key" in msg_lc + or "authentication" in msg_lc + ): + return (False, f"auth: {msg[:200]}") + if "404" in msg: + return (False, f"not_found: {msg[:200]}") + return (False, f"unreachable: {msg[:200]}") + return (True, "") + def get_config_schema(self) -> List[Dict[str, Any]]: return [ {"key": "api_key", "description": "RetainDB API key", "secret": True, "required": True, "env_var": "RETAINDB_API_KEY", "url": "https://retaindb.com"}, diff --git a/tests/plugins/memory/test_retaindb_health_check.py b/tests/plugins/memory/test_retaindb_health_check.py new file mode 100644 index 000000000000..e6caf6974f93 --- /dev/null +++ b/tests/plugins/memory/test_retaindb_health_check.py @@ -0,0 +1,179 @@ +"""Tests for ``RetainDBMemoryProvider.health_check`` (#42 step 2f). + +The override does a GET /v1/memory/profile/hermes-doctor-probe +round-trip with the configured key, classifying the outcome with +the RFC #42 reason-prefix taxonomy (auth: / not_found: / +unreachable: / no_api_key / sdk_missing). +""" +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest + +import plugins.memory.retaindb as rdb +from plugins.memory.retaindb import RetainDBMemoryProvider + + +# ── Helpers ──────────────────────────────────────────────────── + + +def _set_env(monkeypatch, *, api_key="real-key", base_url=None, + project=None): + if api_key: + monkeypatch.setenv("RETAINDB_API_KEY", api_key) + else: + monkeypatch.delenv("RETAINDB_API_KEY", raising=False) + if base_url is not None: + monkeypatch.setenv("RETAINDB_BASE_URL", base_url) + else: + monkeypatch.delenv("RETAINDB_BASE_URL", raising=False) + if project is not None: + monkeypatch.setenv("RETAINDB_PROJECT", project) + else: + monkeypatch.delenv("RETAINDB_PROJECT", raising=False) + + +def _stub_client(monkeypatch, *, get_profile_raises=None, + get_profile_returns=None, + init_raises=None): + """Patch `_Client` so its constructor + get_profile follow the + test's chosen behavior.""" + + class _FakeClient: + def __init__(self, api_key, base_url, project): + if init_raises is not None: + raise init_raises + self.api_key = api_key + self.base_url = base_url + self.project = project + + def get_profile(self, user_id): + if get_profile_raises is not None: + raise get_profile_raises + return get_profile_returns or {} + + monkeypatch.setattr(rdb, "_Client", _FakeClient) + + +# ── Tests ────────────────────────────────────────────────────── + + +def test_returns_true_on_successful_probe(monkeypatch): + _set_env(monkeypatch) + _stub_client(monkeypatch, get_profile_returns={"memories": []}) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is True + assert reason == "" + + +def test_no_api_key_when_env_unset(monkeypatch): + _set_env(monkeypatch, api_key="") + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason == "no_api_key" + + +def test_sdk_missing_when_requests_unavailable(monkeypatch): + _set_env(monkeypatch) + monkeypatch.setitem(sys.modules, "requests", None) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason == "sdk_missing" + + +@pytest.mark.parametrize("err_msg", [ + "RetainDB GET /v1/memory/profile/probe failed (401): unauthorized", + "RetainDB GET /v1/memory/profile/probe failed (403): forbidden", + "Invalid API key supplied", + "authentication failed", +]) +def test_auth_classified_correctly(monkeypatch, err_msg): + _set_env(monkeypatch) + _stub_client(monkeypatch, + get_profile_raises=RuntimeError(err_msg)) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason.startswith("auth:"), reason + assert err_msg[:40] in reason + + +def test_404_classified_as_not_found(monkeypatch): + _set_env(monkeypatch, base_url="https://api.retaindb.invalid") + err = "RetainDB GET /v1/memory/profile/probe failed (404): not found" + _stub_client(monkeypatch, get_profile_raises=RuntimeError(err)) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason.startswith("not_found:") + assert "404" in reason + + +def test_network_error_classified_as_unreachable(monkeypatch): + _set_env(monkeypatch) + _stub_client(monkeypatch, + get_profile_raises=ConnectionError( + "Connection refused")) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason.startswith("unreachable:") + assert "Connection refused" in reason + + +def test_client_init_failure_classified_as_unreachable(monkeypatch): + """When _Client(api_key=...) itself raises (e.g. bad base_url + parsing), the override surfaces it as unreachable rather than + propagating.""" + _set_env(monkeypatch) + _stub_client(monkeypatch, + init_raises=RuntimeError("bad base_url")) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + assert reason.startswith("unreachable:") + assert "bad base_url" in reason + + +def test_truncates_long_errors(monkeypatch): + _set_env(monkeypatch) + long_msg = "x" * 500 + _stub_client(monkeypatch, + get_profile_raises=RuntimeError(long_msg)) + healthy, reason = RetainDBMemoryProvider().health_check() + assert healthy is False + # prefix ("unreachable: ") + 200 = 213 + assert len(reason) <= 220 + + +def test_uses_custom_base_url_when_set(monkeypatch): + """Operators with self-hosted RetainDB set RETAINDB_BASE_URL; + the probe must pass that through to the client (not the default).""" + captured = {} + + class _CapturingClient: + def __init__(self, api_key, base_url, project): + captured["base_url"] = base_url + + def get_profile(self, user_id): + return {} + + monkeypatch.setattr(rdb, "_Client", _CapturingClient) + _set_env(monkeypatch, base_url="https://retaindb.self-hosted.test") + RetainDBMemoryProvider().health_check() + assert captured["base_url"] == "https://retaindb.self-hosted.test" + + +def test_trailing_slash_base_url_normalized(monkeypatch): + captured = {} + + class _CapturingClient: + def __init__(self, api_key, base_url, project): + captured["base_url"] = base_url + + def get_profile(self, user_id): + return {} + + monkeypatch.setattr(rdb, "_Client", _CapturingClient) + _set_env(monkeypatch, + base_url="https://api.retaindb.com//") + RetainDBMemoryProvider().health_check() + assert captured["base_url"] == "https://api.retaindb.com"