Skip to content

Commit

Permalink
feat: add HealthCheckService (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
hartungstenio authored Sep 21, 2024
1 parent fac310d commit cf4825d
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 4 deletions.
19 changes: 17 additions & 2 deletions django_healthy/_compat.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import sys

if sys.version_info >= (3, 9):
from collections.abc import Mapping, MutableMapping
else:
from typing import Mapping, MutableMapping

if sys.version_info >= (3, 10):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias

if sys.version_info >= (3, 11):
from typing import Self
from typing import NotRequired, Self, TypedDict
else:
from typing_extensions import Self
from typing_extensions import NotRequired, Self, TypedDict


__all__ = [
"Mapping",
"MutableMapping",
"NotRequired",
"Self",
"TypeAlias",
"TypedDict",
]
2 changes: 1 addition & 1 deletion django_healthy/health_checks/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async def check_health(self) -> HealthCheckResult:
else:
return HealthCheckResult.healthy()

def _perform_health_check(self):
def _perform_health_check(self) -> None:
connection: DjangoDatabaseWrapper = cast(DjangoDatabaseWrapper, connections[self.alias])

with connection.cursor() as cursor:
Expand Down
68 changes: 68 additions & 0 deletions django_healthy/health_checks/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from __future__ import annotations

from typing import Any, Iterator, cast

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string

from django_healthy._compat import Mapping, MutableMapping, NotRequired, TypeAlias, TypedDict

from .base import HealthCheck


class InvalidHealthCheckError(ImproperlyConfigured):
pass


class BackendConfig(TypedDict):
BACKEND: str
OPTIONS: NotRequired[Mapping[str, Any]]


HealthCheckConfig: TypeAlias = Mapping[str, BackendConfig]


class HealthCheckHandler(Mapping[str, HealthCheck]):
__slots__: tuple[str, ...] = ("_backends", "_health_checks")

def __init__(self, backends: HealthCheckConfig | None = None):
self._backends = (
backends if backends is not None else cast(HealthCheckConfig, getattr(settings, "HEALTH_CHECKS", {}))
)
self._health_checks: MutableMapping[str, HealthCheck] = {}

def __getitem__(self, alias: str) -> HealthCheck:
try:
return self._health_checks[alias]
except KeyError:
try:
params = self._backends[alias]
except KeyError as exc:
msg = f"Could not find config for '{alias}' in settings.HEALTH_CHECKS."
raise InvalidHealthCheckError(msg) from exc
else:
health_check = self.create_health_check(params)
self._health_checks[alias] = health_check
return health_check

def __iter__(self) -> Iterator[str]:
return iter(self._backends)

def __len__(self) -> int:
return len(self._backends)

def create_health_check(self, params: BackendConfig) -> HealthCheck:
backend = params["BACKEND"]
options = params.get("OPTIONS", {})

try:
factory = import_string(backend)
except ImportError as e:
msg = f"Could not find backend {backend!r}: {e}"
raise InvalidHealthCheckError(msg) from e
else:
return factory(**options)


health_checks = HealthCheckHandler()
65 changes: 65 additions & 0 deletions django_healthy/health_checks/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from datetime import timedelta
from timeit import default_timer as timer
from typing import Any

from django_healthy._compat import Mapping # noqa: TCH001

from .base import HealthCheck, HealthStatus
from .handler import HealthCheckHandler, health_checks


@dataclass
class HealthReportEntry:
status: HealthStatus
duration: timedelta
description: str | None = None
exception: Exception | None = None
data: dict[str, Any] = field(default_factory=dict)


@dataclass
class HealthReport:
entries: Mapping[str, HealthReportEntry]
status: HealthStatus
total_duration: timedelta


class HealthCheckService:
__slots__: tuple[str, ...] = ("_handler",)

def __init__(self, handler: HealthCheckHandler | None = None):
self._handler = handler or health_checks

async def check_health(self) -> HealthReport:
start_time: float = timer()
task_map: dict[str, asyncio.Task[HealthReportEntry]] = {
name: asyncio.create_task(self.run_health_check(health_check))
for name, health_check in self._handler.items()
}
await asyncio.gather(*task_map.values())
end_time: float = timer()

entries: dict[str, HealthReportEntry] = {name: task.result() for name, task in task_map.items()}
worst_case = min(entry.status.value for entry in entries.values())
return HealthReport(
entries=entries,
status=HealthStatus(worst_case),
total_duration=timedelta(seconds=end_time - start_time),
)

async def run_health_check(self, health_check: HealthCheck) -> HealthReportEntry:
start_time: float = timer()
result = await health_check.check_health()
end_time: float = timer()

return HealthReportEntry(
status=result.status,
duration=timedelta(seconds=end_time - start_time),
description=result.description,
exception=result.exception,
data=result.data,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ classifiers = [
dependencies = [
"asgiref>=3.3",
"django>=4.2",
"typing-extensions; python_version<'3.11'"
"typing-extensions; python_version<'3.10'"
]

[project.urls]
Expand Down
9 changes: 9 additions & 0 deletions testproj/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,12 @@
STATIC_URL = "static/"

DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

HEALTH_CHECKS = {
"cache": {
"BACKEND": "django_healthy.health_checks.cache.CacheHealthCheck",
},
"db": {
"BACKEND": "django_healthy.health_checks.db.DatabasePingHealthCheck",
},
}
50 changes: 50 additions & 0 deletions tests/health_checks/test_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import pytest
from django.conf import settings

from django_healthy.health_checks.cache import CacheHealthCheck
from django_healthy.health_checks.handler import HealthCheckHandler, InvalidHealthCheckError


class TestHealthCheckHandler:
def test_with_custom_settings(self):
handler = HealthCheckHandler(
backends={
"test": {
"BACKEND": "django_healthy.health_checks.cache.CacheHealthCheck",
}
}
)
items = set(handler.keys())

assert items == {"test"}

def test_with_default_settings(self):
handler = HealthCheckHandler()
items = set(handler.keys())

assert items == set(settings.HEALTH_CHECKS)

def test_get_existing_item(self):
handler = HealthCheckHandler(
backends={
"test": {
"BACKEND": "django_healthy.health_checks.cache.CacheHealthCheck",
}
}
)

got = handler["test"]

assert isinstance(got, CacheHealthCheck)

def test_get_missing_item(self):
handler = HealthCheckHandler(
backends={
"test": {
"BACKEND": "django_healthy.health_checks.cache.CacheHealthCheck",
}
}
)

with pytest.raises(InvalidHealthCheckError):
handler["missing"]
79 changes: 79 additions & 0 deletions tests/health_checks/test_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import pytest
from django.conf import settings

from django_healthy.health_checks import HealthStatus
from django_healthy.health_checks.handler import HealthCheckHandler
from django_healthy.health_checks.service import HealthCheckService

pytestmark = pytest.mark.django_db


@pytest.mark.asyncio
class TestHealthCheckService:
async def test_with_default_handler(self):
service = HealthCheckService()

got = await service.check_health()

assert got.status == HealthStatus.HEALTHY
assert set(got.entries.keys()) == set(settings.HEALTH_CHECKS.keys())

async def test_with_custom_handler(self):
service = HealthCheckService(
HealthCheckHandler(
backends={
"test": {
"BACKEND": "django_healthy.health_checks.cache.CacheHealthCheck",
}
}
)
)

got = await service.check_health()

assert got.status == HealthStatus.HEALTHY
assert set(got.entries.keys()) == {"test"}

async def test_with_unhealthy_service(self):
service = HealthCheckService(
HealthCheckHandler(
backends={
"test": {
"BACKEND": "django_healthy.health_checks.db.DatabasePingHealthCheck",
"OPTIONS": {
"alias": "dummy",
},
}
}
)
)

got = await service.check_health()

assert got.status == HealthStatus.UNHEALTHY
assert set(got.entries.keys()) == {"test"}

async def test_with_multiple_service_status_gets_worst_case(self):
service = HealthCheckService(
HealthCheckHandler(
backends={
"healthy": {
"BACKEND": "django_healthy.health_checks.db.DatabasePingHealthCheck",
"OPTIONS": {
"alias": "default",
},
},
"unhealthy": {
"BACKEND": "django_healthy.health_checks.db.DatabasePingHealthCheck",
"OPTIONS": {
"alias": "dummy",
},
},
}
)
)

got = await service.check_health()

assert got.status == HealthStatus.UNHEALTHY
assert set(got.entries.keys()) == {"healthy", "unhealthy"}

0 comments on commit cf4825d

Please sign in to comment.