From 234b32a8c9dae46807888465b086596453bf9b4b Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 19:48:49 +0530 Subject: [PATCH 1/2] fix: support async health checks --- nitrostack/core/additional_decorators.py | 34 +++++------ nitrostack/core/app.py | 2 +- tests/test_decorators_extra.py | 18 ++++-- tests/test_health_async_checks.py | 72 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 tests/test_health_async_checks.py diff --git a/nitrostack/core/additional_decorators.py b/nitrostack/core/additional_decorators.py index aa30a47..81620eb 100644 --- a/nitrostack/core/additional_decorators.py +++ b/nitrostack/core/additional_decorators.py @@ -1,4 +1,5 @@ import time +import inspect from functools import wraps from typing import Callable, Any, Dict, List, Tuple from nitrostack.core.context import ExecutionContext @@ -100,26 +101,21 @@ def get_checks(cls) -> Dict[str, Callable[[], bool]]: return cls._bound_checks @classmethod - def run_all(cls) -> Dict[str, str]: - results = {} - for name, check_fn in cls._bound_checks.items(): + async def run_all(cls) -> Dict[str, str]: + """Run every registered health check and return {name: status}. + + Coroutine checks are awaited, sync checks are called directly, and a + check that raises reports its own error without affecting the others. + """ + results: Dict[str, str] = {} + for name, check in list(cls._bound_checks.items()): try: - # Handle both sync and async checks - import inspect - if inspect.iscoroutinefunction(check_fn): - # In a real environment, we'd await it, but let's handle it or run via loop - import asyncio - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - status = loop.run_until_complete(check_fn()) - else: - status = check_fn() - results[name] = "healthy" if status else "unhealthy" - except Exception as e: - results[name] = f"error: {str(e)}" + outcome = check() + if inspect.isawaitable(outcome): + outcome = await outcome + results[name] = "healthy" if outcome else "unhealthy" + except Exception as exc: # noqa: BLE001 - one bad check must not hide the rest + results[name] = f"error: {exc}" return results def health_check(name: str): diff --git a/nitrostack/core/app.py b/nitrostack/core/app.py index 9d9b069..233838e 100644 --- a/nitrostack/core/app.py +++ b/nitrostack/core/app.py @@ -812,7 +812,7 @@ def _register_health_resource(self) -> None: ) async def health_status_resource(context: ExecutionContext) -> str: - results = HealthCheckRegistry.run_all() + results = await HealthCheckRegistry.run_all() return json.dumps(results) self._resources[config.uri] = _ResourceEntry(config=config, instance=None, method=health_status_resource) diff --git a/tests/test_decorators_extra.py b/tests/test_decorators_extra.py index fdd64fa..0580c69 100644 --- a/tests/test_decorators_extra.py +++ b/tests/test_decorators_extra.py @@ -1,4 +1,6 @@ + """Phase 6 — cache, rate_limit, and health_check decorators.""" + from __future__ import annotations import asyncio @@ -46,7 +48,11 @@ def test_cache_returns_same_result_within_ttl(): class Svc: @cache(ttl=60) - async def add(self, value: int, context: ExecutionContext | None = None) -> int: + async def add( + self, + value: int, + context: ExecutionContext | None = None, + ) -> int: calls["n"] += 1 return value + 1 @@ -83,8 +89,9 @@ def db_ok(self) -> bool: return True HealthCheckRegistry.bind_instance("db", Probe.db_ok, Probe()) - results = HealthCheckRegistry.run_all() + results = asyncio.run(HealthCheckRegistry.run_all()) assert results["db"] == "healthy" + HealthCheckRegistry._checks.clear() HealthCheckRegistry._bound_checks.clear() @@ -97,8 +104,9 @@ def boom(): raise RuntimeError("down") HealthCheckRegistry._bound_checks["svc"] = boom - results = HealthCheckRegistry.run_all() + results = asyncio.run(HealthCheckRegistry.run_all()) assert results["svc"].startswith("error:") + HealthCheckRegistry._checks.clear() HealthCheckRegistry._bound_checks.clear() @@ -115,14 +123,14 @@ def down(): HealthCheckRegistry._bound_checks["async"] = async_ok HealthCheckRegistry._bound_checks["down"] = down - results = HealthCheckRegistry.run_all() + results = asyncio.run(HealthCheckRegistry.run_all()) assert results["async"] == "healthy" assert results["down"] == "unhealthy" + HealthCheckRegistry._checks.clear() HealthCheckRegistry._bound_checks.clear() - def test_copy_mcp_attributes_copies_mcp_prefixed_fields(): def src(): return None diff --git a/tests/test_health_async_checks.py b/tests/test_health_async_checks.py new file mode 100644 index 0000000..8363f71 --- /dev/null +++ b/tests/test_health_async_checks.py @@ -0,0 +1,72 @@ +import asyncio +import json +import warnings + +import pytest + +from nitrostack import injectable, module +from nitrostack.core.additional_decorators import HealthCheckRegistry, health_check +from nitrostack.testing import NitroTestingModule + + +@pytest.fixture(autouse=True) +def clean_registry(): + saved = (dict(HealthCheckRegistry._checks), dict(HealthCheckRegistry._bound_checks)) + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + yield + HealthCheckRegistry._checks.clear() + HealthCheckRegistry._bound_checks.clear() + HealthCheckRegistry._checks.update(saved[0]) + HealthCheckRegistry._bound_checks.update(saved[1]) + + +def _app_module(): + @injectable() + class Svc: + @health_check("async_ok") + async def async_ok(self): + await asyncio.sleep(0) + return True + + @health_check("sync_ok") + def sync_ok(self): + return True + + @health_check("async_boom") + async def async_boom(self): + raise RuntimeError("db down") + + @module(name="m", providers=[Svc]) + class M: + pass + + return M + + +async def _read_status(): + harness = await NitroTestingModule.create(_app_module()) + raw = await harness.read_resource("health://status") + return json.loads(raw) if isinstance(raw, str) else raw + + +@pytest.mark.asyncio +async def test_async_and_sync_checks_both_report_healthy(): + status = await _read_status() + assert "event loop" not in str(status["async_ok"]) + assert status["async_ok"] == status["sync_ok"] + + +@pytest.mark.asyncio +async def test_raising_check_reports_error_without_affecting_others(): + status = await _read_status() + assert "db down" in status["async_boom"] + assert "error" not in str(status["async_ok"]) + assert "error" not in str(status["sync_ok"]) + + +@pytest.mark.asyncio +async def test_no_never_awaited_warning(): + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + await _read_status() \ No newline at end of file From c344dce39c91852ac7fcab0f98b782474350fe64 Mon Sep 17 00:00:00 2001 From: Shreya Vemana Date: Sun, 25 Oct 2026 22:53:02 +0530 Subject: [PATCH 2/2] test(health): run async health tests with asyncio.run so CI needs no extra plugin --- tests/test_health_async_checks.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_health_async_checks.py b/tests/test_health_async_checks.py index 8363f71..90374d8 100644 --- a/tests/test_health_async_checks.py +++ b/tests/test_health_async_checks.py @@ -44,29 +44,29 @@ class M: return M -async def _read_status(): - harness = await NitroTestingModule.create(_app_module()) - raw = await harness.read_resource("health://status") - return json.loads(raw) if isinstance(raw, str) else raw +def _read_status(): + async def run(): + harness = await NitroTestingModule.create(_app_module()) + raw = await harness.read_resource("health://status") + return json.loads(raw) if isinstance(raw, str) else raw + return asyncio.run(run()) -@pytest.mark.asyncio -async def test_async_and_sync_checks_both_report_healthy(): - status = await _read_status() + +def test_async_and_sync_checks_both_report_healthy(): + status = _read_status() assert "event loop" not in str(status["async_ok"]) assert status["async_ok"] == status["sync_ok"] -@pytest.mark.asyncio -async def test_raising_check_reports_error_without_affecting_others(): - status = await _read_status() +def test_raising_check_reports_error_without_affecting_others(): + status = _read_status() assert "db down" in status["async_boom"] assert "error" not in str(status["async_ok"]) assert "error" not in str(status["sync_ok"]) -@pytest.mark.asyncio -async def test_no_never_awaited_warning(): +def test_no_never_awaited_warning(): with warnings.catch_warnings(): warnings.simplefilter("error", RuntimeWarning) - await _read_status() \ No newline at end of file + _read_status() \ No newline at end of file