From 6e3238a5a7834d63b67a082055b9e54f4edf0ea7 Mon Sep 17 00:00:00 2001 From: fusion44 Date: Sun, 12 Jul 2026 17:11:04 +0200 Subject: [PATCH] feat(api): implement GET /system/health readiness endpoint (#145) Make /system/health unauthenticated, compute real readiness from the shared startup state via build_health_info, and return 503 (body still a SystemHealthInfo) when a subsystem is not ready. Drop the now-dead per-backend get_system_health delegation. Co-Authored-By: Claude Fable 5 --- app/system/impl/native_python.py | 5 ---- app/system/impl/raspiblitz.py | 4 ---- app/system/impl/system_base.py | 5 ---- app/system/router.py | 13 +++++++---- app/system/service.py | 10 ++++---- tests/routers/test_system.py | 39 ++++++++++++++++++++++++++++++++ 6 files changed, 52 insertions(+), 24 deletions(-) diff --git a/app/system/impl/native_python.py b/app/system/impl/native_python.py index 031a681..9672b60 100644 --- a/app/system/impl/native_python.py +++ b/app/system/impl/native_python.py @@ -17,7 +17,6 @@ from app.system.models import ( ConnectionInfo, LoginInput, RawDebugLogData, - SystemHealthInfo, SystemInfo, ) @@ -55,10 +54,6 @@ class NativePythonSystem(SystemBase): chain=lninfo.chains[0].network, ) - @logger.catch(exclude=(HTTPException,)) - async def get_system_health(self, verbose: bool) -> SystemHealthInfo: - return SystemHealthInfo(healthy=True) - @logger.catch(exclude=(HTTPException,)) async def shutdown(self, reboot: bool) -> bool: logger.info("Shutdown / reboot not supported in native_python mode.") diff --git a/app/system/impl/raspiblitz.py b/app/system/impl/raspiblitz.py index 5c63c80..d15f68b 100644 --- a/app/system/impl/raspiblitz.py +++ b/app/system/impl/raspiblitz.py @@ -27,7 +27,6 @@ from app.system.models import ( ConnectionInfo, LoginInput, RawDebugLogData, - SystemHealthInfo, SystemInfo, ) @@ -105,9 +104,6 @@ class RaspiBlitzSystem(SystemBase): chain=data_chain, ) - async def get_system_health(self, verbose: bool) -> SystemHealthInfo: - return SystemHealthInfo(healthy=True) - async def shutdown(self, reboot: bool) -> bool: params = "" if reboot: diff --git a/app/system/impl/system_base.py b/app/system/impl/system_base.py index a7b53a1..f3f7e3a 100644 --- a/app/system/impl/system_base.py +++ b/app/system/impl/system_base.py @@ -7,7 +7,6 @@ from app.system.models import ( ConnectionInfo, LoginInput, RawDebugLogData, - SystemHealthInfo, SystemInfo, ) @@ -17,10 +16,6 @@ class SystemBase: async def get_system_info(self) -> SystemInfo: raise NotImplementedError() - @abstractmethod - async def get_system_health(self, verbose: bool) -> SystemHealthInfo: - raise NotImplementedError() - @abstractmethod async def shutdown(self, reboot: bool) -> bool: raise NotImplementedError() diff --git a/app/system/router.py b/app/system/router.py index b93ba8e..e7ee515 100644 --- a/app/system/router.py +++ b/app/system/router.py @@ -137,16 +137,21 @@ async def get_debug_logs_raw_route() -> RawDebugLogData: @router.get( "/health", name=f"{_PREFIX}.health", - summary="Returns info about the systems health", - dependencies=[Depends(JWTBearer())], + summary="Returns info about the system's health", + response_model=SystemHealthInfo, + responses={503: {"description": "One or more subsystems are not ready"}}, ) async def get_system_health( + response: Response, verbose: bool = Query( False, - description="Returns info about each subsytem running on this node if true. Currently not implemented.", + description="If true, include a per-subsystem (api, bitcoind, lightning) health breakdown.", ), ) -> SystemHealthInfo: - return await system_health(verbose) + result = await system_health(verbose) + if not result.healthy: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return result @router.post( diff --git a/app/system/service.py b/app/system/service.py index 1c52fe8..45ea3e5 100644 --- a/app/system/service.py +++ b/app/system/service.py @@ -6,8 +6,10 @@ from loguru import logger from app.api.config import config from app.api.error_report.report import Frame +from app.api.startup_status import api_startup_status from app.api.utils import Event, broadcast_msg from app.external.result_type.src.result.result import Err, Ok +from app.system.health import build_health_info from app.system.models import ( APIPlatform, ConnectionInfo, @@ -57,12 +59,8 @@ async def get_system_info() -> SystemInfo: async def system_health(verbose: bool) -> SystemHealthInfo: - try: - return await system.get_system_health(verbose) - except HTTPException: - raise - except NotImplementedError as r: - raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=r.args[0]) + # Readiness is platform independent: read the shared startup state directly. + return build_health_info(api_startup_status, verbose) async def get_hardware_info() -> map: diff --git a/tests/routers/test_system.py b/tests/routers/test_system.py index 8511776..8b75c57 100644 --- a/tests/routers/test_system.py +++ b/tests/routers/test_system.py @@ -2,6 +2,8 @@ from starlette.testclient import TestClient from app.main import app from tests.routers.utils import call_route +import app.main as main +from app.api.models import StartupState client = TestClient(app) @@ -11,3 +13,40 @@ def test_route_authentications_latest(): for prefix in prefixes: call_route(client, f"{prefix}/refresh-token", method="p") + + +def _set_status(bitcoin, lightning): + main.api_startup_status.bitcoin = bitcoin + main.api_startup_status.lightning = lightning + + +def test_health_is_unauthenticated_and_200_when_ready(): + _set_status(StartupState.DONE, StartupState.DONE) + # no `with`: don't start lifespan/background tasks + c = TestClient(main.app) + resp = c.get("/system/health") # no Authorization header + assert resp.status_code == 200 + body = resp.json() + assert body["healthy"] is True + assert body["subsystems"] == [] + + +def test_health_503_when_not_ready_body_is_health_info(): + _set_status(StartupState.BOOTSTRAPPING, StartupState.DONE) + c = TestClient(main.app) + resp = c.get("/system/health") + assert resp.status_code == 503 + body = resp.json() + # body is a SystemHealthInfo, NOT an ErrorMessage + assert body["healthy"] is False + assert "error_code" not in body + assert body["message"] == "bitcoind not ready: bootstrapping" + + +def test_health_verbose_lists_subsystems(): + _set_status(StartupState.DONE, StartupState.DONE) + c = TestClient(main.app) + resp = c.get("/system/health?verbose=true") + assert resp.status_code == 200 + names = [s["name"] for s in resp.json()["subsystems"]] + assert names == ["api", "bitcoind", "lightning"]