Skip to content

Commit 9484afa

Browse files
authored
feat: run user-supplied health probes on every health check route (#294) (#297)
1 parent d3bb240 commit 9484afa

13 files changed

Lines changed: 306 additions & 19 deletions

‎CONTEXT.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ signals: not configured is silent and lands in `skipped_instruments`, so it show
4646
raises `InstrumentDependencyMissingWarning` plus a `logger.warning` and lands in neither list. The
4747
quiet skip is the one you have to go looking for; the loud one finds you.
4848

49+
**Health probe**:
50+
One check of something the service depends on, run each time the health check route is requested. A
51+
single failing probe makes the route report the service unhealthy. The health check is the route;
52+
health probes are what it runs.
53+
_Avoid_: health check, for this sense; ping, which is FastStream's broker method and only one
54+
possible probe.
55+
4956
**Free**:
5057
Without a web framework: `FreeBootstrapper`, `FreeConfig`, the `free-all` extra. It has no
5158
application, so nothing to attach teardown to and no double-bootstrap guard.

‎docs/introduction/configuration.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,35 @@ Additional params:
391391

392392
- `health_checks_path`
393393
- `health_checks_include_in_schema`
394+
- `health_checks_probes`: named async callables, each returning `bool`, that the route runs on every request
395+
396+
The health check route on Litestar, FastAPI, FastMCP and FastStream runs every probe concurrently. On FastStream the probes run alongside the broker pings. The route returns 200 with `health_status: true` when everything passes. If any probe returns `False` or raises, the route returns 500 with the same JSON body and `health_status: false`. Each failed probe logs one warning with its name; the response body does not say which probe failed.
397+
398+
```python
399+
import functools
400+
401+
import sqlalchemy
402+
403+
from lite_bootstrap import LitestarConfig
404+
405+
406+
async def database_probe() -> bool:
407+
async with engine.connect() as connection:
408+
await connection.execute(sqlalchemy.text("SELECT 1"))
409+
return True
410+
411+
412+
config = LitestarConfig(
413+
health_checks_probes={
414+
"database": database_probe,
415+
"outbox-broker": functools.partial(outbox_broker.ping, timeout=5),
416+
},
417+
)
418+
```
419+
420+
lite-bootstrap puts no timeout on probes, so a probe that hangs makes the route hang too. Give each probe its own timeout, as `functools.partial(broker.ping, timeout=5)` does.
421+
422+
Be careful with probes on dependencies outside the service, such as a database, when the route also serves a Kubernetes liveness probe. During the database outage every pod fails its liveness check and gets restarted, and the restarts add load once the database comes back. Readiness probes are the usual place for dependency checks.
394423

395424
## Skipped instruments
396425

‎lite_bootstrap/bootstrappers/fastapi_bootstrapper.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,11 @@ def build_fastapi_health_check_router(self) -> "fastapi.APIRouter":
178178
)
179179

180180
@fastapi_router.get(self.bootstrap_config.health_checks_path)
181-
async def health_check_handler() -> HealthCheckTypedDict:
182-
return self.render_health_check_data()
181+
async def health_check_handler(response: fastapi.Response) -> HealthCheckTypedDict:
182+
health_status = await self.define_health_status()
183+
if not health_status:
184+
response.status_code = fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR
185+
return self.render_health_check_data(health_status=health_status)
183186

184187
return fastapi_router
185188

‎lite_bootstrap/bootstrappers/fastmcp_bootstrapper.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from fastmcp.server.http import StarletteWithLifespan
2323
from fastmcp.server.middleware import Middleware, MiddlewareContext
2424
from fastmcp.server.providers import Provider
25+
from starlette import status
2526
from starlette.requests import Request
2627
from starlette.responses import JSONResponse, Response
2728

@@ -210,7 +211,11 @@ def bootstrap(self) -> None:
210211
include_in_schema=config.health_checks_include_in_schema,
211212
)
212213
async def health_check_handler(_: "Request") -> "JSONResponse":
213-
return JSONResponse(dict(self.render_health_check_data()))
214+
health_status = await self.define_health_status()
215+
return JSONResponse(
216+
dict(self.render_health_check_data(health_status=health_status)),
217+
status_code=status.HTTP_200_OK if health_status else status.HTTP_500_INTERNAL_SERVER_ERROR,
218+
)
214219

215220

216221
@dataclasses.dataclass(kw_only=True)

‎lite_bootstrap/bootstrappers/faststream_bootstrapper.py‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,22 +88,19 @@ def bootstrap(self) -> None:
8888

8989
@handle_get
9090
async def check_health(_: object) -> "AsgiResponse":
91-
return (
92-
AsgiResponse(
93-
json.dumps(self.render_health_check_data()).encode(),
94-
200,
95-
headers={"content-type": "application/json"},
96-
)
97-
if await self._define_health_status()
98-
else AsgiResponse(b"Service is unhealthy", 500, headers={"content-type": "text/plain"})
91+
health_status = await self.define_health_status()
92+
return AsgiResponse(
93+
json.dumps(self.render_health_check_data(health_status=health_status)).encode(),
94+
200 if health_status else 500,
95+
headers={"content-type": "application/json"},
9996
)
10097

10198
if config.opentelemetry_generate_health_check_spans and import_checker.is_opentelemetry_installed:
10299
check_health = tracer.start_as_current_span(f"GET {config.health_checks_path}")(check_health)
103100

104101
config.application.mount(config.health_checks_path, check_health)
105102

106-
async def _define_health_status(self) -> bool:
103+
async def _define_framework_health_status(self) -> bool:
107104
config = self.bootstrap_config
108105
if not config.application or not config.application.brokers:
109106
return False

‎lite_bootstrap/bootstrappers/litestar_bootstrapper.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
if import_checker.is_litestar_installed:
3030
import litestar
31+
from litestar import status_codes
3132
from litestar.config.app import AppConfig
3233
from litestar.config.cors import CORSConfig
3334
from litestar.logging.config import StructLoggingConfig
@@ -149,8 +150,12 @@ def bootstrap(self) -> None:
149150
class LitestarHealthChecksInstrument(HealthChecksInstrument[LitestarConfig]):
150151
def build_litestar_health_check_router(self) -> "litestar.Router":
151152
@litestar.get(media_type=litestar.MediaType.JSON)
152-
async def health_check_handler() -> HealthCheckTypedDict:
153-
return self.render_health_check_data()
153+
async def health_check_handler() -> litestar.Response[HealthCheckTypedDict]:
154+
health_status = await self.define_health_status()
155+
return litestar.Response(
156+
self.render_health_check_data(health_status=health_status),
157+
status_code=status_codes.HTTP_200_OK if health_status else status_codes.HTTP_500_INTERNAL_SERVER_ERROR,
158+
)
154159

155160
return litestar.Router(
156161
path=self.bootstrap_config.health_checks_path,

‎lite_bootstrap/instruments/healthchecks_instrument.py‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
import asyncio
12
import dataclasses
3+
import logging
24
import typing
5+
from collections.abc import Awaitable, Callable, Mapping
36

47
import typing_extensions
58

69
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
710

811

12+
logger = logging.getLogger(__name__)
13+
14+
HealthProbe = Callable[[], Awaitable[bool]]
15+
16+
917
class HealthCheckTypedDict(typing_extensions.TypedDict, total=False):
1018
service_version: str | None
1119
service_name: str | None
@@ -17,6 +25,7 @@ class HealthChecksConfig(BaseConfig):
1725
health_checks_enabled: bool = True
1826
health_checks_path: str = "/health/"
1927
health_checks_include_in_schema: bool = False
28+
health_checks_probes: Mapping[str, HealthProbe] = dataclasses.field(default_factory=dict)
2029

2130
@property
2231
def health_check_data(self) -> HealthCheckTypedDict:
@@ -38,5 +47,28 @@ class HealthChecksInstrument(BaseInstrument[HealthChecksConfigT]):
3847
def is_configured(cls, bootstrap_config: "HealthChecksConfig") -> bool:
3948
return bootstrap_config.health_checks_enabled
4049

41-
def render_health_check_data(self) -> HealthCheckTypedDict:
42-
return self.bootstrap_config.health_check_data
50+
def render_health_check_data(self, *, health_status: bool = True) -> HealthCheckTypedDict:
51+
health_check_data = self.bootstrap_config.health_check_data
52+
health_check_data["health_status"] = health_status
53+
return health_check_data
54+
55+
async def define_health_status(self) -> bool:
56+
results = await asyncio.gather(
57+
self._define_framework_health_status(),
58+
*(self._run_probe(name, probe) for name, probe in self.bootstrap_config.health_checks_probes.items()),
59+
)
60+
return all(results)
61+
62+
async def _define_framework_health_status(self) -> bool:
63+
return True
64+
65+
@staticmethod
66+
async def _run_probe(name: str, probe: HealthProbe) -> bool:
67+
try:
68+
passed = await probe()
69+
except Exception:
70+
logger.warning("Health probe %s raised", name, exc_info=True)
71+
return False
72+
if not passed:
73+
logger.warning("Health probe %s failed", name)
74+
return bool(passed)

‎tests/conftest.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55
from collections.abc import Generator
66
from importlib import reload
7+
from unittest.mock import MagicMock, patch
78

89
import opentelemetry.metrics._internal
910
import opentelemetry.trace
@@ -16,6 +17,7 @@
1617

1718
from lite_bootstrap import import_checker
1819
from lite_bootstrap.exceptions import InstrumentSkippedWarning
20+
from lite_bootstrap.instruments import healthchecks_instrument
1921

2022

2123
def pytest_configure() -> None:
@@ -36,6 +38,40 @@ def _uninstrument(self, **kwargs: typing.Mapping[str, typing.Any]) -> None:
3638
pass
3739

3840

41+
class ProbeError(RuntimeError):
42+
pass
43+
44+
45+
async def passing_probe() -> bool:
46+
return True
47+
48+
49+
async def failing_probe() -> bool:
50+
return False
51+
52+
53+
async def raising_probe() -> bool:
54+
message = "dependency unreachable"
55+
raise ProbeError(message)
56+
57+
58+
HEALTH_PROBE_CASES: typing.Final = [
59+
pytest.param({"db": passing_probe, "cache": passing_probe}, 200, True, [], id="all-pass"),
60+
pytest.param({"db": passing_probe, "cache": failing_probe}, 500, False, ["cache"], id="one-returns-false"),
61+
pytest.param({"db": passing_probe, "cache": raising_probe}, 500, False, ["cache"], id="one-raises"),
62+
]
63+
64+
65+
@contextlib.contextmanager
66+
def capture_probe_warnings() -> Generator[MagicMock]:
67+
with patch.object(healthchecks_instrument, "logger") as mock_logger:
68+
yield mock_logger.warning
69+
70+
71+
def failed_probe_names(warning_mock: MagicMock) -> list[str]:
72+
return [call.args[1] for call in warning_mock.call_args_list]
73+
74+
3975
class SentryTestTransport(sentry_sdk.Transport):
4076
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: # noqa: ANN401
4177
super().__init__(*args, **kwargs)

‎tests/instruments/test_healthchecks_instrument.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import asyncio
2+
import time
3+
14
from lite_bootstrap.instruments.healthchecks_instrument import HealthChecksConfig, HealthChecksInstrument
25

36

@@ -39,7 +42,33 @@ def test_healthchecks_config_defaults() -> None:
3942
assert config.health_checks_enabled is True
4043
assert config.health_checks_path == "/health/"
4144
assert config.health_checks_include_in_schema is False
45+
assert config.health_checks_probes == {}
4246

4347

4448
def test_healthchecks_dependencies_installed() -> None:
4549
assert HealthChecksInstrument.dependencies_installed() is True
50+
51+
52+
async def test_healthchecks_probes_run_concurrently() -> None:
53+
interval = 0.2
54+
55+
async def slow_probe() -> bool:
56+
await asyncio.sleep(interval)
57+
return True
58+
59+
instrument = HealthChecksInstrument(
60+
bootstrap_config=HealthChecksConfig(health_checks_probes={"first": slow_probe, "second": slow_probe}),
61+
)
62+
started = time.perf_counter()
63+
assert await instrument.define_health_status()
64+
assert time.perf_counter() - started < interval * 1.5
65+
66+
67+
async def test_healthchecks_status_is_healthy_without_probes() -> None:
68+
instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig())
69+
assert await instrument.define_health_status()
70+
71+
72+
def test_healthchecks_render_data_unhealthy() -> None:
73+
instrument = HealthChecksInstrument(bootstrap_config=HealthChecksConfig())
74+
assert instrument.render_health_check_data(health_status=False)["health_status"] is False

‎tests/test_fastapi_bootstrap.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,17 @@
2323
from lite_bootstrap import FastAPIBootstrapper, FastAPIConfig, import_checker
2424
from lite_bootstrap.bootstrappers import fastapi_bootstrapper
2525
from lite_bootstrap.exceptions import ConfigurationError, InstrumentDependencyMissingWarning
26+
from lite_bootstrap.instruments.healthchecks_instrument import HealthProbe
2627
from lite_bootstrap.types import UNSET
27-
from tests.conftest import CustomInstrumentor, SentryTestTransport, emulate_package_missing, warning_source_files
28+
from tests.conftest import (
29+
HEALTH_PROBE_CASES,
30+
CustomInstrumentor,
31+
SentryTestTransport,
32+
capture_probe_warnings,
33+
emulate_package_missing,
34+
failed_probe_names,
35+
warning_source_files,
36+
)
2837

2938

3039
logger = structlog.getLogger(__name__)
@@ -501,3 +510,26 @@ def test_fastapi_otel_honors_excluded_urls_from_environment(
501510
span_names = _server_span_names_for(fastapi_config, ["/from-env", "/custom-healthy"])
502511

503512
assert span_names == ["GET /custom-healthy"]
513+
514+
515+
@pytest.mark.parametrize(("probes", "expected_status", "expected_health_status", "expected_failed"), HEALTH_PROBE_CASES)
516+
def test_fastapi_health_check_runs_probes(
517+
fastapi_config: FastAPIConfig,
518+
probes: dict[str, HealthProbe],
519+
expected_status: int,
520+
expected_health_status: bool,
521+
expected_failed: list[str],
522+
) -> None:
523+
config = dataclasses.replace(fastapi_config, health_checks_probes=probes)
524+
bootstrapper = FastAPIBootstrapper(bootstrap_config=config)
525+
application = bootstrapper.bootstrap()
526+
with capture_probe_warnings() as probe_warnings, TestClient(application) as test_client:
527+
response = test_client.get(config.health_checks_path)
528+
529+
assert response.status_code == expected_status
530+
assert response.json() == {
531+
"health_status": expected_health_status,
532+
"service_name": "microservice",
533+
"service_version": "2.0.0",
534+
}
535+
assert failed_probe_names(probe_warnings) == expected_failed

0 commit comments

Comments
 (0)