Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,88 @@ def is_available(self) -> bool:
except Exception:
return False

def health_check(self) -> tuple[bool, str]:
"""Mode-dependent probe.

local / local_embedded:
(True, "") if the local Hindsight stack imports cleanly.
(False, "sdk_missing: <exc>") otherwise.

local_external / cloud:
GET <api_url>/version with the configured key.
(True, "") — 200 + parseable JSON.
(False, "no_credentials") — neither apiKey nor api_url
is set (cloud only — for
local_external the api_url
is required and absence is
classified as no_url).
(False, "no_url") — local_external without an
api_url.
(False, "auth: ...") — 401 / 403.
(False, "not_found: ...") — 404.
(False, "http: ...") — other non-2xx.
(False, "unreachable: ...") — connection / timeout / parse.

Top-level config load failure → (False, "config_error: <exc>").
MUST NOT raise.
"""
try:
cfg = _load_config()
except Exception as exc: # noqa: BLE001
return (False, f"config_error: {exc}")
mode = cfg.get("mode", "cloud")

# ── local modes: import probe only ──────────────────
if mode in {"local", "local_embedded"}:
ok, exc_msg = _check_local_runtime()
if ok:
return (True, "")
return (False, f"sdk_missing: {(exc_msg or 'import failed')[:200]}")

# ── url-based modes (cloud / local_external) ────────
api_url = (cfg.get("api_url")
or os.environ.get("HINDSIGHT_API_URL", "")).strip()
api_key = (cfg.get("apiKey")
or cfg.get("api_key")
or os.environ.get("HINDSIGHT_API_KEY", "")).strip()

if mode == "local_external":
if not api_url:
return (False, "no_url")
else:
# cloud
if not (api_url or api_key):
return (False, "no_credentials")
if not api_url:
# api_key set but no URL — most cloud deployments
# need both. Treat as a config error pointing at
# the missing URL.
return (False, "no_url")

import urllib.error
import urllib.request
url = api_url.rstrip("/") + "/version"
req = urllib.request.Request(url)
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
payload = resp.read().decode("utf-8", errors="replace")
data = json.loads(payload)
if not isinstance(data, dict):
return (False, f"unreachable: /version returned non-dict")
return (True, "")
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return (False, f"auth: HTTP {exc.code} from {url}")
if exc.code == 404:
return (False, f"not_found: HTTP 404 from {url}")
return (False, f"http: HTTP {exc.code} from {url}")
except (urllib.error.URLError, OSError, TimeoutError) as exc:
return (False, f"unreachable: {str(exc)[:200]}")
except Exception as exc: # noqa: BLE001
return (False, f"unreachable: {str(exc)[:200]}")

def save_config(self, values, hermes_home):
"""Write config to $HERMES_HOME/hindsight/config.json."""
import json
Expand Down
230 changes: 230 additions & 0 deletions tests/plugins/memory/test_hindsight_health_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
"""Tests for ``HindsightMemoryProvider.health_check`` (#42 step 2g).

The override dispatches by config `mode` — local modes do an
import probe; cloud / local_external modes hit `<api_url>/version`.
Reasons follow the RFC #42 prefix taxonomy.
"""
from __future__ import annotations

import io
import json
import sys
import urllib.error
from types import SimpleNamespace

import pytest

import plugins.memory.hindsight as hs
from plugins.memory.hindsight import HindsightMemoryProvider


# ── Helpers ────────────────────────────────────────────────────


def _stub_config(monkeypatch, **cfg):
monkeypatch.setattr(hs, "_load_config", lambda: cfg)


def _stub_local_runtime(monkeypatch, *, available: bool,
error: str | None = None):
monkeypatch.setattr(
hs, "_check_local_runtime",
lambda: (available, error))


def _stub_urlopen(monkeypatch, *, response_payload=None,
raises=None):
"""Replace urllib.request.urlopen with a fake. response_payload
is bytes returned by .read(); raises is an exception to throw."""
import urllib.request as _ur

class _Resp:
def __init__(self, payload):
self._payload = payload

def __enter__(self):
return self

def __exit__(self, *exc):
return False

def read(self):
return self._payload

def _urlopen(req, timeout=None):
if raises is not None:
raise raises
return _Resp(
response_payload if response_payload is not None
else json.dumps({"version": "0.5.6"}).encode("utf-8"))

monkeypatch.setattr(_ur, "urlopen", _urlopen)


# ── Local modes ────────────────────────────────────────────────


def test_local_returns_true_when_runtime_imports(monkeypatch):
_stub_config(monkeypatch, mode="local")
_stub_local_runtime(monkeypatch, available=True)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is True
assert reason == ""


def test_local_embedded_returns_true_when_runtime_imports(monkeypatch):
_stub_config(monkeypatch, mode="local_embedded")
_stub_local_runtime(monkeypatch, available=True)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is True
assert reason == ""


def test_local_returns_sdk_missing_when_import_fails(monkeypatch):
_stub_config(monkeypatch, mode="local")
_stub_local_runtime(monkeypatch, available=False,
error="numpy ABI mismatch on this CPU")
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("sdk_missing:")
assert "numpy ABI mismatch" in reason


# ── local_external (URL but no key required) ───────────────────


def test_local_external_no_url_returns_no_url(monkeypatch):
_stub_config(monkeypatch, mode="local_external")
monkeypatch.delenv("HINDSIGHT_API_URL", raising=False)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason == "no_url"


def test_local_external_with_url_probes_version(monkeypatch):
_stub_config(monkeypatch, mode="local_external",
api_url="http://hindsight-local:9999")
_stub_urlopen(monkeypatch)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is True


# ── Cloud mode ─────────────────────────────────────────────────


def test_cloud_no_credentials_when_both_empty(monkeypatch):
_stub_config(monkeypatch, mode="cloud")
monkeypatch.delenv("HINDSIGHT_API_KEY", raising=False)
monkeypatch.delenv("HINDSIGHT_API_URL", raising=False)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason == "no_credentials"


def test_cloud_no_url_when_only_key(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="real-key")
monkeypatch.delenv("HINDSIGHT_API_URL", raising=False)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason == "no_url"


def test_cloud_success(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="real",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is True


def test_cloud_auth_401(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="wrong",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch,
raises=urllib.error.HTTPError(
"http://x/version", 401, "Unauthorized",
{}, None))
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("auth:")
assert "401" in reason


def test_cloud_404_is_not_found(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="k",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch,
raises=urllib.error.HTTPError(
"http://x/version", 404, "NF", {}, None))
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("not_found:")
assert "404" in reason


def test_cloud_other_http_classified_as_http(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="k",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch,
raises=urllib.error.HTTPError(
"http://x/version", 503, "Down", {}, None))
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("http:")
assert "503" in reason


def test_cloud_connection_refused_classified_as_unreachable(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="k",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch,
raises=urllib.error.URLError("connection refused"))
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("unreachable:")
assert "connection refused" in reason


def test_cloud_malformed_response_classified_as_unreachable(
monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="k",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch, response_payload=b"not json")
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("unreachable:")


def test_cloud_non_dict_json_classified_as_unreachable(monkeypatch):
_stub_config(monkeypatch, mode="cloud", apiKey="k",
api_url="https://api.hindsight.test")
_stub_urlopen(monkeypatch, response_payload=b'["array", "not", "dict"]')
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("unreachable:")
assert "non-dict" in reason


# ── Top-level config failure ──────────────────────────────────


def test_config_error_when_load_config_raises(monkeypatch):
monkeypatch.setattr(
hs, "_load_config",
lambda: (_ for _ in ()).throw(RuntimeError("config corrupted")))
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is False
assert reason.startswith("config_error:")
assert "config corrupted" in reason


# ── Env-var fallback ──


def test_env_vars_used_when_config_missing_keys(monkeypatch):
_stub_config(monkeypatch, mode="cloud") # No api_url/apiKey in cfg
monkeypatch.setenv("HINDSIGHT_API_KEY", "env-key")
monkeypatch.setenv("HINDSIGHT_API_URL", "https://env.test")
_stub_urlopen(monkeypatch)
healthy, reason = HindsightMemoryProvider().health_check()
assert healthy is True