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
34 changes: 15 additions & 19 deletions nitrostack/core/additional_decorators.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion nitrostack/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions tests/test_decorators_extra.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@

"""Phase 6 — cache, rate_limit, and health_check decorators."""

from __future__ import annotations

import asyncio
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/test_health_async_checks.py
Original file line number Diff line number Diff line change
@@ -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


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())


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"]


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"])


def test_no_never_awaited_warning():
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
_read_status()
Loading