├── tests ├── __init__.py ├── testapp │ ├── __init__.py │ ├── urls.py │ ├── celery.py │ ├── manage.py │ └── settings.py ├── test_plugins.py ├── test_migrations.py ├── test_autodiscover.py ├── test_db_heartbeat.py ├── test_mail.py ├── test_db.py ├── test_commands.py ├── test_mixins.py ├── test_rabbitmq.py ├── test_backends.py ├── test_redis.py ├── test_cache.py ├── test_celery_ping.py ├── test_storage.py └── test_views.py ├── health_check ├── db │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0001_initial.py │ │ └── 0002_alter_testmodel_options.py │ ├── models.py │ ├── apps.py │ └── backends.py ├── cache │ ├── __init__.py │ ├── apps.py │ └── backends.py ├── contrib │ ├── __init__.py │ ├── mail │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── redis │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── celery │ │ ├── __init__.py │ │ ├── tasks.py │ │ ├── apps.py │ │ └── backends.py │ ├── migrations │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── psutil │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── rabbitmq │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── celery_ping │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── db_heartbeat │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── s3boto3_storage │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ └── s3boto_storage │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py ├── storage │ ├── __init__.py │ ├── apps.py │ └── backends.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── health_check.py ├── __init__.py ├── urls.py ├── conf.py ├── plugins.py ├── exceptions.py ├── backends.py ├── templates │ └── health_check │ │ └── index.html ├── mixins.py └── views.py ├── .bandit ├── .git-blame-ignore-revs ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── docs ├── images │ ├── icon.svg │ ├── logo-light.svg │ └── logo-dark.svg ├── container.md ├── contrib.md ├── install.md ├── index.md ├── settings.md └── usage.md ├── .readthedocs.yaml ├── .editorconfig ├── LICENSE ├── .pre-commit-config.yaml ├── README.md ├── .gitignore ├── mkdocs.yml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/mail/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/redis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/db/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: tests 3 | -------------------------------------------------------------------------------- /health_check/contrib/celery/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/psutil/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/rabbitmq/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/celery_ping/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/db_heartbeat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto3_storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto_storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /health_check/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app 2 | 3 | __all__ = ["app"] 4 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Ruff format 2 | b24886fa7f02425fda25dd5c2f987b01a4127d03qq 3 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | path("ht/", include("health_check.urls")), 5 | ] 6 | -------------------------------------------------------------------------------- /health_check/contrib/celery/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | 3 | 4 | @shared_task(ignore_result=False) 5 | def add(x, y): 6 | return x + y 7 | -------------------------------------------------------------------------------- /tests/testapp/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | app = Celery("testapp", broker="memory://") 4 | app.config_from_object("django.conf:settings", namespace="CELERY") 5 | -------------------------------------------------------------------------------- /health_check/__init__.py: -------------------------------------------------------------------------------- 1 | """Monitor the health of your Django app and its connected services.""" 2 | 3 | from . import _version # noqa 4 | 5 | __version__ = _version.__version__ 6 | VERSION = _version.__version_tuple__ 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /health_check/db/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestModel(models.Model): 5 | title = models.CharField(max_length=128) 6 | 7 | class Meta: 8 | db_table = "health_check_db_testmodel" 9 | default_permissions = () 10 | -------------------------------------------------------------------------------- /tests/testapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /health_check/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from health_check.views import MainView 4 | 5 | app_name = "health_check" 6 | 7 | urlpatterns = [ 8 | path("", MainView.as_view(), name="health_check_home"), 9 | path("/", MainView.as_view(), name="health_check_subset"), 10 | ] 11 | -------------------------------------------------------------------------------- /health_check/contrib/mail/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.mail" 8 | 9 | def ready(self): 10 | from .backends import MailHealthCheck 11 | 12 | plugin_dir.register(MailHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/contrib/redis/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.redis" 8 | 9 | def ready(self): 10 | from .backends import RedisHealthCheck 11 | 12 | plugin_dir.register(RedisHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | HEALTH_CHECK = getattr(settings, "HEALTH_CHECK", {}) 4 | HEALTH_CHECK.setdefault("DISK_USAGE_MAX", 90) 5 | HEALTH_CHECK.setdefault("MEMORY_MIN", 100) 6 | HEALTH_CHECK.setdefault("WARNINGS_AS_ERRORS", True) 7 | HEALTH_CHECK.setdefault("SUBSETS", {}) 8 | HEALTH_CHECK.setdefault("DISABLE_THREADING", False) 9 | -------------------------------------------------------------------------------- /health_check/contrib/rabbitmq/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.rabbitmq" 8 | 9 | def ready(self): 10 | from .backends import RabbitMQHealthCheck 11 | 12 | plugin_dir.register(RabbitMQHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/storage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.storage" 8 | 9 | def ready(self): 10 | from .backends import DefaultFileStorageHealthCheck 11 | 12 | plugin_dir.register(DefaultFileStorageHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/contrib/migrations/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.migrations" 8 | 9 | def ready(self): 10 | from .backends import MigrationsHealthCheck 11 | 12 | plugin_dir.register(MigrationsHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/contrib/celery_ping/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.celery_ping" 8 | 9 | def ready(self): 10 | from .backends import CeleryPingHealthCheck 11 | 12 | plugin_dir.register(CeleryPingHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/contrib/db_heartbeat/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.db_heartbeat" 8 | 9 | def ready(self): 10 | from .backends import DatabaseHeartBeatCheck 11 | 12 | plugin_dir.register(DatabaseHeartBeatCheck) 13 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto_storage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.s3boto_storage" 8 | 9 | def ready(self): 10 | from .backends import S3BotoStorageHealthCheck 11 | 12 | plugin_dir.register(S3BotoStorageHealthCheck) 13 | -------------------------------------------------------------------------------- /health_check/db/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | default_auto_field = "django.db.models.AutoField" 8 | name = "health_check.db" 9 | 10 | def ready(self): 11 | from .backends import DatabaseBackend 12 | 13 | plugin_dir.register(DatabaseBackend) 14 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto3_storage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from health_check.plugins import plugin_dir 4 | 5 | 6 | class HealthCheckConfig(AppConfig): 7 | name = "health_check.contrib.s3boto3_storage" 8 | 9 | def ready(self): 10 | from .backends import S3Boto3StorageHealthCheck 11 | 12 | plugin_dir.register(S3Boto3StorageHealthCheck) 13 | -------------------------------------------------------------------------------- /docs/images/icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /health_check/cache/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | from health_check.plugins import plugin_dir 5 | 6 | 7 | class HealthCheckConfig(AppConfig): 8 | name = "health_check.cache" 9 | 10 | def ready(self): 11 | from .backends import CacheBackend 12 | 13 | for backend in settings.CACHES: 14 | plugin_dir.register(CacheBackend, backend=backend) 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | version: 2 5 | build: 6 | os: ubuntu-24.04 7 | tools: 8 | python: "3" 9 | jobs: 10 | install: 11 | - curl -LsSf https://astral.sh/uv/install.sh | sh 12 | build: 13 | html: 14 | - $HOME/.local/bin/uv run mkdocs build --site-dir $READTHEDOCS_OUTPUT/html 15 | mkdocs: 16 | configuration: mkdocs.yml 17 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from health_check.backends import BaseHealthCheckBackend 4 | from health_check.plugins import plugin_dir 5 | 6 | 7 | class FakePlugin(BaseHealthCheckBackend): 8 | def check_status(self): 9 | pass 10 | 11 | 12 | class Plugin(BaseHealthCheckBackend): 13 | def check_status(self): 14 | pass 15 | 16 | 17 | class TestPlugin: 18 | @pytest.fixture(autouse=True) 19 | def setup(self): 20 | plugin_dir.reset() 21 | plugin_dir.register(FakePlugin) 22 | yield 23 | plugin_dir.reset() 24 | 25 | def test_register_plugin(self): 26 | assert len(plugin_dir._registry) == 1 27 | -------------------------------------------------------------------------------- /health_check/db/backends.py: -------------------------------------------------------------------------------- 1 | from django.db import DatabaseError, IntegrityError 2 | 3 | from health_check.backends import BaseHealthCheckBackend 4 | from health_check.exceptions import ServiceReturnedUnexpectedResult, ServiceUnavailable 5 | 6 | from .models import TestModel 7 | 8 | 9 | class DatabaseBackend(BaseHealthCheckBackend): 10 | def check_status(self): 11 | try: 12 | obj = TestModel.objects.create(title="test") 13 | obj.title = "newtest" 14 | obj.save() 15 | obj.delete() 16 | except IntegrityError: 17 | raise ServiceReturnedUnexpectedResult("Integrity Error") 18 | except DatabaseError: 19 | raise ServiceUnavailable("Database error") 20 | -------------------------------------------------------------------------------- /health_check/plugins.py: -------------------------------------------------------------------------------- 1 | class AlreadyRegistered(Exception): 2 | pass 3 | 4 | 5 | class NotRegistered(Exception): 6 | pass 7 | 8 | 9 | class HealthCheckPluginDirectory: 10 | """Django health check registry.""" 11 | 12 | def __init__(self): 13 | self._registry = [] # plugin_class class -> plugin options 14 | 15 | def reset(self): 16 | """Reset registry state, e.g. for testing purposes.""" 17 | self._registry = [] 18 | 19 | def register(self, plugin, **options): 20 | """Add the given plugin from the registry.""" 21 | # Instantiate the admin class to save in the registry 22 | self._registry.append((plugin, options)) 23 | 24 | 25 | plugin_dir = HealthCheckPluginDirectory() 26 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto_storage/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from health_check.storage.backends import StorageHealthCheck 4 | 5 | 6 | class S3BotoStorageHealthCheck(StorageHealthCheck): 7 | """ 8 | Tests the status of a `S3BotoStorage` file storage backend. 9 | 10 | S3BotoStorage is included in the `django-storages` package 11 | and recommended by for example Amazon and Heroku for Django 12 | static and media file storage on cloud platforms. 13 | 14 | ``django-storages`` can be found at https://git.io/v1lGx 15 | ``S3BotoStorage`` can be found at https://git.io/v1lGF 16 | """ 17 | 18 | logger = logging.getLogger(__name__) 19 | storage = "storages.backends.s3boto.S3BotoStorage" 20 | 21 | def check_delete(self, file_name): 22 | storage = self.get_storage() 23 | storage.delete(file_name) 24 | -------------------------------------------------------------------------------- /health_check/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ # noqa: N812 2 | 3 | 4 | class HealthCheckException(Exception): 5 | message_type = _("unknown error") 6 | 7 | def __init__(self, message): 8 | self.message = message 9 | 10 | def __str__(self): 11 | return f"{self.message_type}: {self.message}" 12 | 13 | 14 | class ServiceWarning(HealthCheckException): 15 | """ 16 | Warning of service misbehavior. 17 | 18 | If the ``HEALTH_CHECK['WARNINGS_AS_ERRORS']`` is set to ``False``, 19 | these exceptions will not case a 500 status response. 20 | """ 21 | 22 | message_type = _("warning") 23 | 24 | 25 | class ServiceUnavailable(HealthCheckException): 26 | message_type = _("unavailable") 27 | 28 | 29 | class ServiceReturnedUnexpectedResult(HealthCheckException): 30 | message_type = _("unexpected result") 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | # isort config 15 | atomic = true 16 | multi_line_output = 5 17 | line_length = 80 18 | combine_as_imports = true 19 | skip = wsgi.py,docs,env,.eggs 20 | known_first_party = health_check,tests 21 | known_third_party = django,celery,psutil 22 | default_section=THIRDPARTY 23 | not_skip = __init__.py 24 | 25 | 26 | [*.{rst,ini}] 27 | indent_style = space 28 | indent_size = 4 29 | 30 | [*.{yml,html,xml,xsl,json,toml}] 31 | indent_style = space 32 | indent_size = 2 33 | 34 | [*.{css,less}] 35 | indent_style = space 36 | indent_size = 2 37 | 38 | [*.{js,coffee}] 39 | indent_style = space 40 | indent_size = 4 41 | 42 | [Makefile] 43 | indent_style = tab 44 | indent_size = 1 45 | -------------------------------------------------------------------------------- /health_check/db/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.1 on 2016-09-26 18:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | replaces = [ 10 | ("health_check_db", "0001_initial"), 11 | ] 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="TestModel", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("title", models.CharField(max_length=128)), 29 | ], 30 | options={ 31 | "db_table": "health_check_db_testmodel", 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /health_check/contrib/psutil/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | from health_check.plugins import plugin_dir 5 | 6 | 7 | class HealthCheckConfig(AppConfig): 8 | name = "health_check.contrib.psutil" 9 | 10 | def ready(self): 11 | from .backends import DiskUsage, MemoryUsage 12 | 13 | # Ensure checks haven't been explicitly disabled before registering 14 | if ( 15 | hasattr(settings, "HEALTH_CHECK") 16 | and ("DISK_USAGE_MAX" in settings.HEALTH_CHECK) 17 | and (settings.HEALTH_CHECK["DISK_USAGE_MAX"] is None) 18 | ): 19 | pass 20 | else: 21 | plugin_dir.register(DiskUsage) 22 | if ( 23 | hasattr(settings, "HEALTH_CHECK") 24 | and ("DISK_USAGE_MAX" in settings.HEALTH_CHECK) 25 | and (settings.HEALTH_CHECK["MEMORY_MIN"] is None) 26 | ): 27 | pass 28 | else: 29 | plugin_dir.register(MemoryUsage) 30 | -------------------------------------------------------------------------------- /docs/images/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Django 10 | 11 | 12 | HealthCheck 13 | 14 | 15 | Pluggable health checks for Django applications 16 | 17 | 18 | -------------------------------------------------------------------------------- /health_check/contrib/celery/apps.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from celery import current_app 4 | from django.apps import AppConfig 5 | from django.conf import settings 6 | 7 | from health_check.plugins import plugin_dir 8 | 9 | 10 | class HealthCheckConfig(AppConfig): 11 | name = "health_check.contrib.celery" 12 | 13 | def ready(self): 14 | from .backends import CeleryHealthCheck 15 | 16 | if hasattr(settings, "HEALTHCHECK_CELERY_TIMEOUT"): 17 | warnings.warn( 18 | "HEALTHCHECK_CELERY_TIMEOUT is deprecated and may be removed in the " 19 | "future. Please use HEALTHCHECK_CELERY_RESULT_TIMEOUT and " 20 | "HEALTHCHECK_CELERY_QUEUE_TIMEOUT instead.", 21 | DeprecationWarning, 22 | ) 23 | 24 | for queue in current_app.amqp.queues: 25 | celery_class_name = "CeleryHealthCheck" + queue.title() 26 | 27 | celery_class = type(celery_class_name, (CeleryHealthCheck,), {"queue": queue}) 28 | plugin_dir.register(celery_class) 29 | -------------------------------------------------------------------------------- /docs/images/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Django 10 | 11 | 12 | HealthCheck 13 | 14 | 15 | Pluggable health checks for Django applications 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from django.db.migrations import Migration 5 | 6 | from health_check.contrib.migrations.backends import MigrationsHealthCheck 7 | 8 | 9 | class MockMigration(Migration): 10 | pass 11 | 12 | 13 | @pytest.mark.django_db 14 | class TestMigrationsHealthCheck: 15 | def test_check_status_work(self): 16 | with patch( 17 | "health_check.contrib.migrations.backends.MigrationsHealthCheck.get_migration_plan", 18 | return_value=[], 19 | ): 20 | backend = MigrationsHealthCheck() 21 | backend.run_check() 22 | assert not backend.errors 23 | 24 | def test_check_status_raises_error_if_there_are_migrations(self): 25 | with patch( 26 | "health_check.contrib.migrations.backends.MigrationsHealthCheck.get_migration_plan", 27 | return_value=[(MockMigration, False)], 28 | ): 29 | backend = MigrationsHealthCheck() 30 | backend.run_check() 31 | assert backend.errors 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011-2025 Johannes Maron, Kristian Øllegaard and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto3_storage/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from health_check.exceptions import ServiceUnavailable 4 | from health_check.storage.backends import StorageHealthCheck 5 | 6 | 7 | class S3Boto3StorageHealthCheck(StorageHealthCheck): 8 | """ 9 | Tests the status of a `S3BotoStorage` file storage backend. 10 | 11 | S3BotoStorage is included in the `django-storages` package 12 | and recommended by for example Amazon and Heroku for Django 13 | static and media file storage on cloud platforms. 14 | 15 | ``django-storages`` can be found at https://git.io/v1lGx 16 | ``S3Boto3Storage`` can be found at 17 | https://github.com/jschneier/django-storages/blob/master/storages/backends/s3boto3.py 18 | """ 19 | 20 | logger = logging.getLogger(__name__) 21 | storage = "storages.backends.s3boto3.S3Boto3Storage" 22 | storage_alias = "default" 23 | 24 | def check_delete(self, file_name): 25 | storage = self.get_storage() 26 | if not storage.exists(file_name): 27 | raise ServiceUnavailable("File does not exist") 28 | storage.delete(file_name) 29 | -------------------------------------------------------------------------------- /health_check/management/commands/health_check.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.http import Http404 5 | 6 | from health_check.mixins import CheckMixin 7 | 8 | 9 | class Command(CheckMixin, BaseCommand): 10 | help = "Run health checks and exit 0 if everything went well." 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument("-s", "--subset", type=str, nargs=1) 14 | 15 | def handle(self, *args, **options): 16 | # perform all checks 17 | subset = options.get("subset", []) 18 | subset = subset[0] if subset else None 19 | try: 20 | errors = self.check(subset=subset) 21 | except Http404 as e: 22 | self.stdout.write(str(e)) 23 | sys.exit(1) 24 | 25 | for plugin_identifier, plugin in self.filter_plugins(subset=subset).items(): 26 | style_func = self.style.SUCCESS if not plugin.errors else self.style.ERROR 27 | self.stdout.write(f"{plugin_identifier:<24} ... {style_func(plugin.pretty_status())}\n") 28 | 29 | if errors: 30 | sys.exit(1) 31 | -------------------------------------------------------------------------------- /health_check/db/migrations/0002_alter_testmodel_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.20 on 2025-03-20 11:10 2 | 3 | from django.db import migrations 4 | 5 | 6 | def remove_test_model_default_permissions(apps, schema_editor): 7 | ContentType = apps.get_model("contenttypes", "ContentType") 8 | Permission = apps.get_model("auth", "Permission") 9 | db_alias = schema_editor.connection.alias 10 | 11 | try: 12 | test_model_content_type = ContentType.objects.using(db_alias).get(app_label="db", model="testmodel") 13 | Permission.objects.using(db_alias).filter(content_type=test_model_content_type).delete() 14 | except ContentType.DoesNotExist: 15 | return 16 | 17 | 18 | class Migration(migrations.Migration): 19 | dependencies = [ 20 | ("db", "0001_initial"), 21 | ] 22 | 23 | operations = [ 24 | migrations.AlterModelOptions( 25 | name="testmodel", 26 | options={"default_permissions": ()}, 27 | ), 28 | migrations.RunPython( 29 | code=remove_test_model_default_permissions, 30 | reverse_code=migrations.RunPython.noop, 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /tests/test_autodiscover.py: -------------------------------------------------------------------------------- 1 | from celery import current_app 2 | from django.conf import settings 3 | 4 | from health_check.contrib.celery.backends import CeleryHealthCheck 5 | from health_check.contrib.celery_ping.backends import CeleryPingHealthCheck 6 | from health_check.plugins import plugin_dir 7 | 8 | 9 | class TestAutoDiscover: 10 | def test_autodiscover(self): 11 | health_check_plugins = list( 12 | filter( 13 | lambda x: x.startswith("health_check.") and "celery" not in x, 14 | settings.INSTALLED_APPS, 15 | ) 16 | ) 17 | 18 | non_celery_plugins = [ 19 | x for x in plugin_dir._registry if not issubclass(x[0], (CeleryHealthCheck, CeleryPingHealthCheck)) 20 | ] 21 | 22 | # The number of installed apps excluding celery should equal to all plugins except celery 23 | assert len(non_celery_plugins) == len(health_check_plugins) 24 | 25 | def test_discover_celery_queues(self): 26 | celery_plugins = [x for x in plugin_dir._registry if issubclass(x[0], CeleryHealthCheck)] 27 | assert len(celery_plugins) == len(current_app.amqp.queues) 28 | -------------------------------------------------------------------------------- /health_check/contrib/migrations/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections 5 | from django.db.migrations.executor import MigrationExecutor 6 | 7 | from health_check.backends import BaseHealthCheckBackend 8 | from health_check.exceptions import ServiceUnavailable 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class MigrationsHealthCheck(BaseHealthCheckBackend): 14 | def get_migration_plan(self, executor): 15 | return executor.migration_plan(executor.loader.graph.leaf_nodes()) 16 | 17 | def check_status(self): 18 | db_alias = getattr(settings, "HEALTHCHECK_MIGRATIONS_DB", DEFAULT_DB_ALIAS) 19 | try: 20 | executor = MigrationExecutor(connections[db_alias]) 21 | plan = self.get_migration_plan(executor) 22 | if plan: 23 | self.add_error(ServiceUnavailable("There are migrations to apply")) 24 | except DatabaseError as e: 25 | self.add_error(ServiceUnavailable("Database is not ready"), e) 26 | except Exception as e: 27 | self.add_error(ServiceUnavailable("Unexpected error"), e) 28 | -------------------------------------------------------------------------------- /health_check/contrib/db_heartbeat/backends.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | from django.db.models import Expression 3 | 4 | from health_check.backends import BaseHealthCheckBackend 5 | from health_check.exceptions import ServiceUnavailable 6 | 7 | 8 | class SelectOne(Expression): 9 | """An expression that represents a simple SELECT 1; query.""" 10 | 11 | def as_sql(self, compiler, connection): 12 | return "SELECT 1", [] 13 | 14 | def as_oracle(self, compiler, connection): 15 | return "SELECT 1 FROM DUAL", [] 16 | 17 | 18 | class DatabaseHeartBeatCheck(BaseHealthCheckBackend): 19 | """Health check that runs a simple SELECT 1; query to test if the database connection is alive.""" 20 | 21 | def check_status(self): 22 | try: 23 | result = None 24 | compiler = connection.ops.compiler("SQLCompiler")(SelectOne(), connection, None) 25 | with connection.cursor() as cursor: 26 | cursor.execute(*compiler.compile(SelectOne())) 27 | result = cursor.fetchone() 28 | 29 | if result != (1,): 30 | raise ServiceUnavailable("Health Check query did not return the expected result.") 31 | except Exception as e: 32 | raise ServiceUnavailable(f"Database health check failed: {e}") 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-merge-conflict 7 | - id: check-ast 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: check-symlinks 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: no-commit-to-branch 14 | args: [--branch, main] 15 | - repo: https://github.com/asottile/pyupgrade 16 | rev: v3.21.2 17 | hooks: 18 | - id: pyupgrade 19 | - repo: https://github.com/adamchainz/django-upgrade 20 | rev: 1.29.1 21 | hooks: 22 | - id: django-upgrade 23 | - repo: https://github.com/hukkin/mdformat 24 | rev: 1.0.0 25 | hooks: 26 | - id: mdformat 27 | additional_dependencies: 28 | - mdformat-ruff 29 | - mdformat-footnote 30 | - mdformat-gfm 31 | - mdformat-gfm-alerts 32 | - repo: https://github.com/astral-sh/ruff-pre-commit 33 | rev: v0.14.10 34 | hooks: 35 | - id: ruff-check 36 | args: [--fix, --exit-non-zero-on-fix] 37 | - id: ruff-format 38 | - repo: https://github.com/google/yamlfmt 39 | rev: v0.20.0 40 | hooks: 41 | - id: yamlfmt 42 | ci: 43 | autoupdate_schedule: weekly 44 | skip: 45 | - no-commit-to-branch 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | gh-page-build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v6 11 | - uses: astral-sh/setup-uv@v7 12 | - run: uv run mkdocs build 13 | - uses: actions/upload-pages-artifact@v4 14 | with: 15 | path: site 16 | gh-page-deploy: 17 | needs: gh-page-build 18 | permissions: 19 | pages: write 20 | id-token: write 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | runs-on: ubuntu-latest 25 | steps: 26 | - id: deployment 27 | uses: actions/deploy-pages@v4 28 | pypi-build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v6 32 | - uses: astral-sh/setup-uv@v7 33 | - run: uvx --from build pyproject-build --sdist --wheel 34 | - uses: actions/upload-artifact@v6 35 | with: 36 | name: release-dists 37 | path: dist/ 38 | pypi-publish: 39 | runs-on: ubuntu-latest 40 | needs: 41 | - pypi-build 42 | permissions: 43 | id-token: write 44 | steps: 45 | - uses: actions/download-artifact@v7 46 | with: 47 | name: release-dists 48 | path: dist/ 49 | - uses: pypa/gh-action-pypi-publish@release/v1 50 | -------------------------------------------------------------------------------- /health_check/contrib/psutil/backends.py: -------------------------------------------------------------------------------- 1 | import locale 2 | import socket 3 | 4 | import psutil 5 | 6 | from health_check.backends import BaseHealthCheckBackend 7 | from health_check.conf import HEALTH_CHECK 8 | from health_check.exceptions import ServiceReturnedUnexpectedResult, ServiceWarning 9 | 10 | host = socket.gethostname() 11 | 12 | DISK_USAGE_MAX = HEALTH_CHECK["DISK_USAGE_MAX"] 13 | MEMORY_MIN = HEALTH_CHECK["MEMORY_MIN"] 14 | 15 | 16 | class DiskUsage(BaseHealthCheckBackend): 17 | def check_status(self): 18 | try: 19 | du = psutil.disk_usage("/") 20 | if DISK_USAGE_MAX and du.percent >= DISK_USAGE_MAX: 21 | raise ServiceWarning(f"{host} {du.percent}% disk usage exceeds {DISK_USAGE_MAX}%") 22 | except ValueError as e: 23 | self.add_error(ServiceReturnedUnexpectedResult("ValueError"), e) 24 | 25 | 26 | class MemoryUsage(BaseHealthCheckBackend): 27 | def check_status(self): 28 | try: 29 | memory = psutil.virtual_memory() 30 | if MEMORY_MIN and memory.available < (MEMORY_MIN * 1024 * 1024): 31 | locale.setlocale(locale.LC_ALL, "") 32 | avail = f"{int(memory.available / 1024 / 1024):n}" 33 | threshold = f"{MEMORY_MIN:n}" 34 | raise ServiceWarning(f"{host} {avail} MB available RAM below {threshold} MB") 35 | except ValueError as e: 36 | self.add_error(ServiceReturnedUnexpectedResult("ValueError"), e) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Django HealthCheck: Pluggable health checks for Django applications 6 | 7 |
8 | Documentation | 9 | Issues | 10 | Changelog | 11 | Funding 💚 12 |

13 | 14 | # Django HealthCheck 15 | 16 | _Pluggable health checks for Django applications_ 17 | 18 | [![version](https://img.shields.io/pypi/v/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 19 | [![coverage](https://codecov.io/gh/codingjoe/django-health-check/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-health-check) 20 | [![pyversion](https://img.shields.io/pypi/pyversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 21 | [![djversion](https://img.shields.io/pypi/djversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 22 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/django-health-check/) 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | dist: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - uses: astral-sh/setup-uv@v7 13 | - run: uvx --from build pyproject-build --sdist --wheel 14 | - run: uvx twine check dist/* 15 | - uses: actions/upload-artifact@v6 16 | with: 17 | path: dist/* 18 | docs: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | - uses: astral-sh/setup-uv@v7 23 | - run: uv run mkdocs build --strict 24 | PyTest: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | python-version: 29 | - "3.10" 30 | - "3.11" 31 | - "3.12" 32 | - "3.13" 33 | - "3.14" 34 | django-version: 35 | - "4.2" 36 | - "5.2" 37 | - "6.0" 38 | exclude: 39 | - python-version: "3.14" 40 | django-version: "4.2" 41 | - python-version: "3.10" 42 | django-version: "6.0" 43 | - python-version: "3.11" 44 | django-version: "6.0" 45 | steps: 46 | - uses: actions/checkout@v6 47 | - uses: astral-sh/setup-uv@v7 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - run: uv run --with Django~=${{ matrix.django-version }}.0 pytest 51 | - uses: codecov/codecov-action@v5 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | flags: python-${{ matrix.python-version }}-django-${{ matrix.django-version }} 55 | -------------------------------------------------------------------------------- /health_check/contrib/mail/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import smtplib 3 | 4 | from django.core.mail import get_connection 5 | from django.core.mail.backends.base import BaseEmailBackend 6 | 7 | from health_check import backends, conf, exceptions 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MailHealthCheck(backends.BaseHealthCheckBackend): 13 | """Check that mail backend is working.""" 14 | 15 | def check_status(self) -> None: 16 | """Open and close connection with email server.""" 17 | connection: BaseEmailBackend = get_connection(fail_silently=False) 18 | connection.timeout = conf.HEALTH_CHECK.get("MAIL_TIMEOUT", 15) 19 | logger.debug("Trying to open connection to mail backend.") 20 | try: 21 | connection.open() 22 | except smtplib.SMTPException as error: 23 | self.add_error( 24 | error=exceptions.ServiceUnavailable( 25 | "Failed to open connection with SMTP server", 26 | ), 27 | cause=error, 28 | ) 29 | except ConnectionRefusedError as error: 30 | self.add_error( 31 | error=exceptions.ServiceUnavailable( 32 | "Connection refused error", 33 | ), 34 | cause=error, 35 | ) 36 | except BaseException as error: 37 | self.add_error( 38 | error=exceptions.ServiceUnavailable( 39 | f"Unknown error {error.__class__}", 40 | ), 41 | cause=error, 42 | ) 43 | finally: 44 | connection.close() 45 | logger.debug("Connection established. Mail backend is healthy.") 46 | -------------------------------------------------------------------------------- /health_check/contrib/redis/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from redis import exceptions, from_url 5 | 6 | from health_check.backends import BaseHealthCheckBackend 7 | from health_check.exceptions import ServiceUnavailable 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class RedisHealthCheck(BaseHealthCheckBackend): 13 | """Health check for Redis.""" 14 | 15 | redis_url = getattr(settings, "REDIS_URL", "redis://localhost/1") 16 | redis_url_options = getattr(settings, "HEALTHCHECK_REDIS_URL_OPTIONS", {}) 17 | 18 | def check_status(self): 19 | """Check Redis service by pinging the redis instance with a redis connection.""" 20 | logger.debug("Got %s as the redis_url. Connecting to redis...", self.redis_url) 21 | 22 | logger.debug("Attempting to connect to redis...") 23 | try: 24 | # conn is used as a context to release opened resources later 25 | with from_url(self.redis_url, **self.redis_url_options) as conn: 26 | conn.ping() # exceptions may be raised upon ping 27 | except ConnectionRefusedError as e: 28 | self.add_error( 29 | ServiceUnavailable("Unable to connect to Redis: Connection was refused."), 30 | e, 31 | ) 32 | except exceptions.TimeoutError as e: 33 | self.add_error(ServiceUnavailable("Unable to connect to Redis: Timeout."), e) 34 | except exceptions.ConnectionError as e: 35 | self.add_error(ServiceUnavailable("Unable to connect to Redis: Connection Error"), e) 36 | except BaseException as e: 37 | self.add_error(ServiceUnavailable("Unknown error"), e) 38 | else: 39 | logger.debug("Connection established. Redis is healthy.") 40 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import uuid 3 | 4 | from kombu import Queue 5 | 6 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 7 | DEBUG = True 8 | 9 | DATABASES = { 10 | "default": { 11 | "ENGINE": "django.db.backends.sqlite3", 12 | "NAME": ":memory:", 13 | }, 14 | "other": { # 2nd database conneciton to ensure proper connection handling 15 | "ENGINE": "django.db.backends.sqlite3", 16 | "NAME": ":backup:", 17 | }, 18 | } 19 | 20 | INSTALLED_APPS = ( 21 | "django.contrib.auth", 22 | "django.contrib.contenttypes", 23 | "django.contrib.sessions", 24 | "django.contrib.staticfiles", 25 | "health_check", 26 | "health_check.cache", 27 | "health_check.db", 28 | "health_check.storage", 29 | "health_check.contrib.celery", 30 | "health_check.contrib.migrations", 31 | "health_check.contrib.celery_ping", 32 | "health_check.contrib.s3boto_storage", 33 | "health_check.contrib.db_heartbeat", 34 | "health_check.contrib.mail", 35 | "tests", 36 | ) 37 | 38 | MIDDLEWARE_CLASSES = ( 39 | "django.contrib.sessions.middleware.SessionMiddleware", 40 | "django.contrib.auth.middleware.AuthenticationMiddleware", 41 | "django.contrib.messages.middleware.MessageMiddleware", 42 | ) 43 | 44 | STATIC_URL = "/static/" 45 | 46 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 47 | 48 | SITE_ID = 1 49 | ROOT_URLCONF = "tests.testapp.urls" 50 | 51 | TEMPLATES = [ 52 | { 53 | "BACKEND": "django.template.backends.django.DjangoTemplates", 54 | "APP_DIRS": True, 55 | "OPTIONS": { 56 | "debug": True, 57 | }, 58 | }, 59 | ] 60 | 61 | SECRET_KEY = uuid.uuid4().hex 62 | 63 | USE_TZ = True 64 | 65 | CELERY_QUEUES = [ 66 | Queue("default"), 67 | Queue("queue2"), 68 | ] 69 | -------------------------------------------------------------------------------- /tests/test_db_heartbeat.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | 5 | from health_check.contrib.db_heartbeat.backends import DatabaseHeartBeatCheck, SelectOne 6 | from health_check.exceptions import ServiceUnavailable 7 | 8 | 9 | class TestSelectOne: 10 | def test_as_sql(self): 11 | select_one = SelectOne() 12 | sql, params = select_one.as_sql(None, None) 13 | assert sql == "SELECT 1" 14 | assert not params 15 | 16 | def test_as_oracle(self): 17 | select_one = SelectOne() 18 | sql, params = select_one.as_oracle(None, None) 19 | assert sql == "SELECT 1 FROM DUAL" 20 | assert not params 21 | 22 | 23 | class TestDatabaseHeartBeatCheck: 24 | @pytest.mark.django_db 25 | def test_check_status__success(self): 26 | health_check = DatabaseHeartBeatCheck() 27 | health_check.check_status() 28 | 29 | @patch("health_check.contrib.db_heartbeat.backends.connection") 30 | def test_check_status__failure(self, mock_connection): 31 | mock_cursor = MagicMock() 32 | mock_cursor.fetchone.return_value = (1,) 33 | mock_connection.cursor.return_value.__enter__.return_value = mock_cursor 34 | 35 | health_check = DatabaseHeartBeatCheck() 36 | try: 37 | health_check.check_status() 38 | except Exception as e: 39 | pytest.fail(f"check_status() raised an exception unexpectedly: {e}") 40 | 41 | @patch("health_check.contrib.db_heartbeat.backends.connection") 42 | def test_check_status_service_unavailable(self, mock_connection): 43 | mock_connection.cursor.side_effect = Exception("Database error") 44 | 45 | health_check = DatabaseHeartBeatCheck() 46 | with pytest.raises(ServiceUnavailable): 47 | health_check.check_status() 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # pytest 104 | .pytest_cache/ 105 | 106 | .envrc 107 | .direnv 108 | 109 | # mac 110 | .DS_Store 111 | .aider* 112 | 113 | # SCM 114 | _version.py 115 | 116 | # uv 117 | uv.lock 118 | -------------------------------------------------------------------------------- /health_check/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from timeit import default_timer as timer 3 | 4 | from django.utils.translation import gettext_lazy as _ # noqa: N812 5 | 6 | from health_check.exceptions import HealthCheckException 7 | 8 | logger = logging.getLogger("health-check") 9 | 10 | 11 | class BaseHealthCheckBackend: 12 | critical_service = True 13 | """ 14 | Define if service is critical to the operation of the site. 15 | 16 | If set to ``True`` service failures return 500 response code on the 17 | health check endpoint. 18 | """ 19 | 20 | def __init__(self): 21 | self.errors = [] 22 | 23 | def check_status(self): 24 | raise NotImplementedError 25 | 26 | def run_check(self): 27 | start = timer() 28 | self.errors = [] 29 | try: 30 | self.check_status() 31 | except HealthCheckException as e: 32 | self.add_error(e, e) 33 | except BaseException: 34 | logger.exception("Unexpected Error!") 35 | raise 36 | finally: 37 | self.time_taken = timer() - start 38 | 39 | def add_error(self, error, cause=None): 40 | if isinstance(error, HealthCheckException): 41 | pass 42 | elif isinstance(error, str): 43 | msg = error 44 | error = HealthCheckException(msg) 45 | else: 46 | msg = _("unknown error") 47 | error = HealthCheckException(msg) 48 | if isinstance(cause, BaseException): 49 | logger.exception(str(error)) 50 | else: 51 | logger.error(str(error)) 52 | self.errors.append(error) 53 | 54 | def pretty_status(self): 55 | if self.errors: 56 | return "\n".join(str(e) for e in self.errors) 57 | return _("working") 58 | 59 | @property 60 | def status(self): 61 | return int(not self.errors) 62 | 63 | def identifier(self): 64 | return self.__class__.__name__ 65 | -------------------------------------------------------------------------------- /health_check/contrib/rabbitmq/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from amqp.exceptions import AccessRefused 4 | from django.conf import settings 5 | from kombu import Connection 6 | 7 | from health_check.backends import BaseHealthCheckBackend 8 | from health_check.exceptions import ServiceUnavailable 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class RabbitMQHealthCheck(BaseHealthCheckBackend): 14 | """Health check for RabbitMQ.""" 15 | 16 | namespace = None 17 | 18 | def check_status(self): 19 | """Check RabbitMQ service by opening and closing a broker channel.""" 20 | logger.debug("Checking for a broker_url on django settings...") 21 | 22 | broker_url_setting_key = f"{self.namespace}_BROKER_URL" if self.namespace else "BROKER_URL" 23 | broker_url = getattr(settings, broker_url_setting_key, None) 24 | 25 | logger.debug("Got %s as the broker_url. Connecting to rabbit...", broker_url) 26 | 27 | logger.debug("Attempting to connect to rabbit...") 28 | try: 29 | # conn is used as a context to release opened resources later 30 | with Connection(broker_url) as conn: 31 | conn.connect() # exceptions may be raised upon calling connect 32 | except ConnectionRefusedError as e: 33 | self.add_error( 34 | ServiceUnavailable("Unable to connect to RabbitMQ: Connection was refused."), 35 | e, 36 | ) 37 | 38 | except AccessRefused as e: 39 | self.add_error( 40 | ServiceUnavailable("Unable to connect to RabbitMQ: Authentication error."), 41 | e, 42 | ) 43 | 44 | except OSError as e: 45 | self.add_error(ServiceUnavailable("IOError"), e) 46 | 47 | except BaseException as e: 48 | self.add_error(ServiceUnavailable("Unknown error"), e) 49 | else: 50 | logger.debug("Connection established. RabbitMQ is healthy.") 51 | -------------------------------------------------------------------------------- /health_check/contrib/celery/backends.py: -------------------------------------------------------------------------------- 1 | from celery.exceptions import TaskRevokedError, TimeoutError 2 | from django.conf import settings 3 | 4 | from health_check.backends import BaseHealthCheckBackend 5 | from health_check.exceptions import ServiceReturnedUnexpectedResult, ServiceUnavailable 6 | 7 | from .tasks import add 8 | 9 | 10 | class CeleryHealthCheck(BaseHealthCheckBackend): 11 | def check_status(self): 12 | timeout = getattr(settings, "HEALTHCHECK_CELERY_TIMEOUT", 3) 13 | result_timeout = getattr(settings, "HEALTHCHECK_CELERY_RESULT_TIMEOUT", timeout) 14 | queue_timeout = getattr(settings, "HEALTHCHECK_CELERY_QUEUE_TIMEOUT", timeout) 15 | priority = getattr(settings, "HEALTHCHECK_CELERY_PRIORITY", None) 16 | 17 | try: 18 | result = add.apply_async(args=[4, 4], expires=queue_timeout, queue=self.queue, priority=priority) 19 | result.get(timeout=result_timeout) 20 | if result.result != 8: 21 | self.add_error(ServiceReturnedUnexpectedResult("Celery returned wrong result")) 22 | except OSError as e: 23 | self.add_error(ServiceUnavailable("IOError"), e) 24 | except NotImplementedError as e: 25 | self.add_error( 26 | ServiceUnavailable("NotImplementedError: Make sure CELERY_RESULT_BACKEND is set"), 27 | e, 28 | ) 29 | except TaskRevokedError as e: 30 | self.add_error( 31 | ServiceUnavailable( 32 | "TaskRevokedError: The task was revoked, likely because it spent too long in the queue" 33 | ), 34 | e, 35 | ) 36 | except TimeoutError as e: 37 | self.add_error( 38 | ServiceUnavailable("TimeoutError: The task took too long to return a result"), 39 | e, 40 | ) 41 | except BaseException as e: 42 | self.add_error(ServiceUnavailable("Unknown error"), e) 43 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django HealthCheck 2 | repo_url: https://github.com/codingjoe/django-health-check 3 | watch: 4 | - docs/ 5 | - mkdocs.yml 6 | - health_check/ 7 | nav: 8 | - Home: index.md 9 | - Installation: install.md 10 | - Usage: usage.md 11 | - Settings: settings.md 12 | - Containers: container.md 13 | - Contrib: contrib.md 14 | plugins: 15 | - autorefs 16 | - search 17 | - mkdocstrings: 18 | default_handler: python 19 | handlers: 20 | python: 21 | load_external_modules: true 22 | options: 23 | docstring_style: google 24 | inventories: 25 | - https://docs.python.org/3/objects.inv 26 | - https://docs.djangoproject.com/en/stable/objects.inv 27 | - https://www.psycopg.org/psycopg3/docs/objects.inv 28 | - https://docs.celeryq.dev/en/stable/objects.inv 29 | - https://psutil.readthedocs.io/en/latest/objects.inv 30 | - https://django-storages.readthedocs.io/en/latest/objects.inv 31 | - https://redis.readthedocs.io/en/stable/objects.inv 32 | theme: 33 | name: material 34 | logo: images/icon.svg 35 | palette: 36 | - media: "(prefers-color-scheme)" 37 | primary: teal 38 | toggle: 39 | icon: material/brightness-auto 40 | name: Switch to light mode 41 | - media: "(prefers-color-scheme: light)" 42 | primary: teal 43 | scheme: default 44 | toggle: 45 | icon: material/brightness-7 46 | name: Switch to dark mode 47 | - media: "(prefers-color-scheme: dark)" 48 | primary: teal 49 | scheme: slate 50 | toggle: 51 | icon: material/brightness-4 52 | name: Switch to system preference 53 | features: 54 | - content.code.select 55 | - content.code.copy 56 | - search.suggest 57 | - search.share 58 | markdown_extensions: 59 | - github-callouts 60 | - pymdownx.highlight: 61 | use_pygments: true 62 | - pymdownx.superfences 63 | -------------------------------------------------------------------------------- /health_check/cache/backends.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import CacheKeyWarning, caches 3 | 4 | from health_check.backends import BaseHealthCheckBackend 5 | from health_check.exceptions import ServiceReturnedUnexpectedResult, ServiceUnavailable 6 | 7 | try: 8 | # Exceptions thrown by Redis do not subclass builtin exceptions like ConnectionError. 9 | # Additionally, not only connection errors (ConnectionError -> RedisError) can be raised, 10 | # but also errors for time-outs (TimeoutError -> RedisError) 11 | # and if the backend is read-only (ReadOnlyError -> ResponseError -> RedisError). 12 | # Since we know what we are trying to do here, we are not picky and catch the global exception RedisError. 13 | from redis.exceptions import RedisError 14 | except ModuleNotFoundError: 15 | # In case Redis is not installed and another cache backend is used. 16 | class RedisError(Exception): 17 | pass 18 | 19 | 20 | class CacheBackend(BaseHealthCheckBackend): 21 | def __init__(self, backend="default"): 22 | super().__init__() 23 | self.backend = backend 24 | self.cache_key = getattr(settings, "HEALTHCHECK_CACHE_KEY", "djangohealthcheck_test") 25 | 26 | def identifier(self): 27 | return f"Cache backend: {self.backend}" 28 | 29 | def check_status(self): 30 | cache = caches[self.backend] 31 | 32 | try: 33 | cache.set(self.cache_key, "itworks") 34 | if not cache.get(self.cache_key) == "itworks": 35 | raise ServiceUnavailable(f"Cache key {self.cache_key} does not match") 36 | except CacheKeyWarning as e: 37 | self.add_error(ServiceReturnedUnexpectedResult("Cache key warning"), e) 38 | except ValueError as e: 39 | self.add_error(ServiceReturnedUnexpectedResult("ValueError"), e) 40 | except (ConnectionError, RedisError) as e: 41 | self.add_error(ServiceReturnedUnexpectedResult("Connection Error"), e) 42 | -------------------------------------------------------------------------------- /health_check/templates/health_check/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block title %}System status{% endblock title %} 4 | 5 | 6 | 47 | {% block extra_head %}{% endblock extra_head %} 48 | 49 | 50 | {% block content %} 51 |

System status

52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {% for plugin in plugins %} 60 | 61 | 70 | 71 | 72 | 73 | 74 | {% endfor %} 75 | 76 |
ServiceStatusTime Taken
62 | 69 | {{ plugin.identifier }}{{ plugin.pretty_status | linebreaks }}{{ plugin.time_taken|floatformat:4 }} seconds
77 | {% endblock content %} 78 | 79 | 80 | -------------------------------------------------------------------------------- /tests/test_mail.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from health_check.contrib.mail.backends import MailHealthCheck 7 | 8 | 9 | class TestMailHealthCheck: 10 | """Test mail health check.""" 11 | 12 | @mock.patch("health_check.contrib.mail.backends.get_connection") 13 | def test_mail_conn_ok(self, mocked_backend): 14 | # instantiates the class 15 | mail_health_checker = MailHealthCheck() 16 | 17 | # invokes the method check_status() 18 | mail_health_checker.check_status() 19 | assert len(mail_health_checker.errors) == 0, mail_health_checker.errors 20 | 21 | # mock assertions 22 | assert mocked_backend.return_value.timeout == 15 23 | mocked_backend.return_value.open.assert_called_once() 24 | mocked_backend.return_value.close.assert_called_once() 25 | 26 | @mock.patch("health_check.contrib.mail.backends.get_connection") 27 | @pytest.mark.parametrize( 28 | argnames=[ 29 | "error", 30 | "expected_msg", 31 | ], 32 | argvalues=[ 33 | [ 34 | smtplib.SMTPException(), 35 | "Failed to open connection with SMTP server", 36 | ], 37 | [ 38 | ConnectionRefusedError(), 39 | "Connection refused error", 40 | ], 41 | [Exception(), f"Unknown error {Exception}"], 42 | ], 43 | ) 44 | def test_mail_conn_refused( 45 | self, 46 | mocked_backend, 47 | error: Exception, 48 | expected_msg: str, 49 | ): 50 | """Test case then connection refused.""" 51 | mocked_backend.return_value.open.side_effect = error 52 | # instantiates the class 53 | mail_health_checker = MailHealthCheck() 54 | 55 | # invokes the method check_status() 56 | mail_health_checker.check_status() 57 | assert len(mail_health_checker.errors) == 1, mail_health_checker.errors 58 | assert mail_health_checker.errors[0].message == expected_msg 59 | 60 | # mock assertions 61 | mocked_backend.return_value.open.assert_called_once() 62 | -------------------------------------------------------------------------------- /docs/container.md: -------------------------------------------------------------------------------- 1 | # Container (Docker/Podman) 2 | 3 | Django HealthCheck can be integrated into various container orchestration systems by defining health checks that utilize the `manage.py health_check` command. Below are examples for Containerfile/Dockerfile, Docker Compose, and Kubernetes. 4 | 5 | > [!TIP] 6 | > Utilizing the health check command is usually superior to simple HTTP checks, as Django's `ALLOWED_HOSTS` settings and other middleware may interfere with HTTP-based health checks. 7 | 8 | ## Subsets 9 | 10 | You may want to limit the checks performed by the health check command to a subset of all available checks. 11 | E.g. you might want to skip checks that are monitoring external services like databases, caches, or task queues. 12 | 13 | You can define subsets in your Django settings: 14 | 15 | ```python 16 | # settings.py 17 | HEALTH_CHECK = { 18 | "SUBSETS": { 19 | "container": [ 20 | "MemoryUsage", 21 | "DiskUsage", 22 | ], 23 | } 24 | } 25 | ``` 26 | 27 | ... and then run the health check command with the `--subset` option: 28 | 29 | ```shell 30 | python manage.py health_check --subset=container 31 | ``` 32 | 33 | ## Configuration Examples 34 | 35 | ### Container Image 36 | 37 | ```Dockerfile 38 | # Containerfile / Dockerfile 39 | HEALTHCHECK --interval=30s --timeout=10s \ 40 | CMD python manage.py health_check --subset=container || exit 1 41 | ``` 42 | 43 | ### Compose 44 | 45 | ```yaml 46 | # compose.yml / docker-compose.yml 47 | services: 48 | web: 49 | # ... your service definition ... 50 | healthcheck: 51 | test: ["CMD", "python", "manage.py", "health_check", "--subset", "container"] 52 | interval: 60s 53 | timeout: 10s 54 | ``` 55 | 56 | ### Kubernetes 57 | 58 | ```yaml 59 | # deployment.yaml 60 | apiVersion: apps/v1 61 | kind: Deployment 62 | metadata: 63 | name: my-django-app 64 | spec: 65 | template: 66 | spec: 67 | containers: 68 | - name: django-container 69 | image: my-django-image:latest 70 | livenessProbe: 71 | exec: 72 | command: 73 | - python 74 | - manage.py 75 | - health_check 76 | - --subset 77 | - container 78 | periodSeconds: 60 79 | timeoutSeconds: 10 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/contrib.md: -------------------------------------------------------------------------------- 1 | # contrib 2 | 3 | ## psutil 4 | 5 | Full disks and out-of-memory conditions are common causes of service 6 | outages. These situations can be averted by checking disk and memory 7 | utilization via the `psutil` package: 8 | 9 | ``` 10 | pip install psutil 11 | ``` 12 | 13 | Once that dependency has been installed, make sure that the 14 | corresponding Django app has been added to `INSTALLED_APPS`: 15 | 16 | ```python 17 | INSTALLED_APPS = [ 18 | # ... 19 | "health_check", # required 20 | "health_check.contrib.psutil", # disk and memory utilization; requires psutil 21 | # ... 22 | ] 23 | ``` 24 | 25 | The following default settings will be used to check for disk and memory 26 | utilization. If you prefer different thresholds, you can add the 27 | dictionary below to your Django settings file and adjust the values 28 | accordingly. If you want to disable any of these checks, set its value 29 | to `None`. 30 | 31 | ```python 32 | HEALTH_CHECK = { 33 | "DISK_USAGE_MAX": 90, # percent 34 | "MEMORY_MIN": 100, # in MB 35 | } 36 | ``` 37 | 38 | ## Celery 39 | 40 | If you are using Celery you may choose between two different Celery 41 | checks. 42 | 43 | `health_check.contrib.celery` sends a task 44 | to the queue and it expects that task to be executed in 45 | `HEALTHCHECK_CELERY_TIMEOUT` seconds which 46 | by default is three seconds. The task is sent with a priority of 47 | `HEALTHCHECK_CELERY_PRIORITY` (default 48 | priority by default). You may override that in your Django settings 49 | module. This check is suitable for use cases which require that tasks 50 | can be processed frequently all the time. 51 | 52 | `health_check.contrib.celery_ping` is a 53 | different check. It checks that each predefined Celery task queue has a 54 | consumer (i.e. worker) that responds `{"ok": "pong"}` in 55 | `HEALTHCHECK_CELERY_PING_TIMEOUT` seconds. 56 | The default for this is one second. You may override that in your Django 57 | settings module. This check is suitable for use cases which don't 58 | 59 | require that tasks are executed almost instantly but require that they 60 | are going to be executed some time in the future i.e. that the worker 61 | process is alive and processing tasks all the time. 62 | 63 | You may also use both of them. To use these checks add them to 64 | `INSTALLED_APPS` in your Django settings module. 65 | -------------------------------------------------------------------------------- /health_check/contrib/celery_ping/backends.py: -------------------------------------------------------------------------------- 1 | from celery.app import default_app as app 2 | from django.conf import settings 3 | 4 | from health_check.backends import BaseHealthCheckBackend 5 | from health_check.exceptions import ServiceUnavailable 6 | 7 | 8 | class CeleryPingHealthCheck(BaseHealthCheckBackend): 9 | CORRECT_PING_RESPONSE = {"ok": "pong"} 10 | 11 | def check_status(self): 12 | timeout = getattr(settings, "HEALTHCHECK_CELERY_PING_TIMEOUT", 1) 13 | 14 | try: 15 | ping_result = app.control.ping(timeout=timeout) 16 | except OSError as e: 17 | self.add_error(ServiceUnavailable("IOError"), e) 18 | except NotImplementedError as exc: 19 | self.add_error( 20 | ServiceUnavailable("NotImplementedError: Make sure CELERY_RESULT_BACKEND is set"), 21 | exc, 22 | ) 23 | except BaseException as exc: 24 | self.add_error(ServiceUnavailable("Unknown error"), exc) 25 | else: 26 | if not ping_result: 27 | self.add_error( 28 | ServiceUnavailable("Celery workers unavailable"), 29 | ) 30 | else: 31 | self._check_ping_result(ping_result) 32 | 33 | def _check_ping_result(self, ping_result): 34 | active_workers = [] 35 | 36 | for result in ping_result: 37 | worker, response = list(result.items())[0] 38 | if response != self.CORRECT_PING_RESPONSE: 39 | self.add_error( 40 | ServiceUnavailable(f"Celery worker {worker} response was incorrect"), 41 | ) 42 | continue 43 | active_workers.append(worker) 44 | 45 | if not self.errors: 46 | self._check_active_queues(active_workers) 47 | 48 | def _check_active_queues(self, active_workers): 49 | defined_queues = getattr(app.conf, "task_queues", None) or getattr(app.conf, "CELERY_QUEUES", None) 50 | 51 | if not defined_queues: 52 | return 53 | 54 | defined_queues = {queue.name for queue in defined_queues} 55 | active_queues = set() 56 | 57 | for queues in app.control.inspect(active_workers).active_queues().values(): 58 | active_queues.update([queue.get("name") for queue in queues]) 59 | 60 | for queue in defined_queues.difference(active_queues): 61 | self.add_error( 62 | ServiceUnavailable(f"No worker for Celery task queue {queue}"), 63 | ) 64 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Add the `django-health-check` package to your project: 4 | 5 | ```shell 6 | uv add django-health-check 7 | # or 8 | pip install django-health-check 9 | ``` 10 | 11 | Add the health checker to a URL you want to use: 12 | 13 | ```python 14 | # urls.py 15 | from django.urls import include, path 16 | 17 | urlpatterns = [ 18 | # ... 19 | path("ht/", include("health_check.urls")), 20 | ] 21 | ``` 22 | 23 | Add the `health_check` applications to your `INSTALLED_APPS`: 24 | 25 | ```python 26 | # settings.py 27 | INSTALLED_APPS = [ 28 | # ... 29 | "health_check", # required 30 | "health_check.db", # stock Django health checkers 31 | "health_check.cache", 32 | "health_check.storage", 33 | "health_check.contrib.migrations", 34 | "health_check.contrib.celery", # requires celery 35 | "health_check.contrib.celery_ping", # requires celery 36 | "health_check.contrib.psutil", # disk and memory utilization; requires psutil 37 | "health_check.contrib.s3boto3_storage", # requires boto3 and S3BotoStorage backend 38 | "health_check.contrib.rabbitmq", # requires RabbitMQ broker 39 | "health_check.contrib.redis", # requires Redis broker 40 | ] 41 | ``` 42 | 43 | (Optional) If using the `psutil` app, you can configure disk and memory 44 | threshold settings; otherwise below defaults are assumed. If you want to 45 | disable one of these checks, set its value to `None`. 46 | 47 | ```python 48 | # settings.py 49 | HEALTH_CHECK = { 50 | "DISK_USAGE_MAX": 90, # percent 51 | "MEMORY_MIN": 100, # in MB 52 | } 53 | ``` 54 | 55 | If using the DB check, run migrations: 56 | 57 | ```shell 58 | django-admin migrate 59 | ``` 60 | 61 | To use the RabbitMQ healthcheck, please make sure that there is a 62 | variable named `BROKER_URL` on django.conf.settings with the required 63 | format to connect to your rabbit server. For example: 64 | 65 | ```python 66 | # settings.py 67 | BROKER_URL = "amqp://myuser:mypassword@localhost:5672/myvhost" 68 | ``` 69 | 70 | To use the Redis healthcheck, please make sure that there is a variable 71 | named `REDIS_URL` on django.conf.settings with the required format to 72 | connect to your redis server. For example: 73 | 74 | ```python 75 | # settings.py 76 | REDIS_URL = "redis://localhost:6370" 77 | ``` 78 | 79 | The cache healthcheck tries to write and read a specific key within the 80 | cache backend. It can be customized by setting `HEALTHCHECK_CACHE_KEY` 81 | to another value: 82 | 83 | ```python 84 | # settings.py 85 | HEALTHCHECK_CACHE_KEY = "custom_healthcheck_key" 86 | ``` 87 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from django.db import DatabaseError, IntegrityError 5 | from django.db.models import Model 6 | 7 | from health_check.db.backends import DatabaseBackend 8 | 9 | 10 | class MockDBModel(Model): 11 | """ 12 | A Mock database used for testing. 13 | 14 | error_thrown - The Exception to be raised when save() is called, if any. 15 | """ 16 | 17 | error_thrown = None 18 | 19 | def __init__(self, error_thrown=None, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | self.error_thrown = error_thrown 22 | 23 | def save(self, *args, **kwargs): 24 | if self.error_thrown is not None: 25 | raise self.error_thrown 26 | else: 27 | return True 28 | 29 | def delete(self, *args, **kwargs): 30 | return True 31 | 32 | 33 | def raise_(ex): 34 | raise ex 35 | 36 | 37 | class TestHealthCheckDatabase: 38 | """ 39 | Tests health check behavior with a mocked database backend. 40 | 41 | Ensures check_status returns/raises the expected result when the database works or raises exceptions. 42 | """ 43 | 44 | @patch( 45 | "health_check.db.backends.TestModel.objects.create", 46 | lambda title=None: MockDBModel(), 47 | ) 48 | def test_check_status_works(self): 49 | db_backend = DatabaseBackend() 50 | db_backend.check_status() 51 | assert not db_backend.errors 52 | 53 | @patch( 54 | "health_check.db.backends.TestModel.objects.create", 55 | lambda title=None: raise_(IntegrityError), 56 | ) 57 | def test_raise_integrity_error(self): 58 | db_backend = DatabaseBackend() 59 | db_backend.run_check() 60 | assert db_backend.errors 61 | assert "unexpected result: Integrity Error" in db_backend.pretty_status() 62 | 63 | @patch( 64 | "health_check.db.backends.TestModel.objects.create", 65 | lambda title=None: MockDBModel(error_thrown=DatabaseError), 66 | ) 67 | def test_raise_database_error(self): 68 | db_backend = DatabaseBackend() 69 | db_backend.run_check() 70 | assert db_backend.errors 71 | assert "unavailable: Database error" in db_backend.pretty_status() 72 | 73 | @patch( 74 | "health_check.db.backends.TestModel.objects.create", 75 | lambda title=None: MockDBModel(error_thrown=Exception), 76 | ) 77 | def test_raise_exception(self): 78 | db_backend = DatabaseBackend() 79 | with pytest.raises(Exception): 80 | db_backend.run_check() 81 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | 6 | from health_check.backends import BaseHealthCheckBackend 7 | from health_check.conf import HEALTH_CHECK 8 | from health_check.plugins import plugin_dir 9 | 10 | 11 | class FailPlugin(BaseHealthCheckBackend): 12 | def check_status(self): 13 | self.add_error("Oops") 14 | 15 | 16 | class OkPlugin(BaseHealthCheckBackend): 17 | def check_status(self): 18 | pass 19 | 20 | 21 | class TestCommand: 22 | @pytest.fixture(autouse=True) 23 | def setup(self): 24 | plugin_dir.reset() 25 | plugin_dir.register(FailPlugin) 26 | plugin_dir.register(OkPlugin) 27 | yield 28 | plugin_dir.reset() 29 | 30 | def test_command(self): 31 | stdout = StringIO() 32 | with pytest.raises(SystemExit): 33 | call_command("health_check", stdout=stdout) 34 | stdout.seek(0) 35 | assert stdout.read() == ( 36 | "FailPlugin ... unknown error: Oops\nOkPlugin ... working\n" 37 | ) 38 | 39 | def test_command_with_subset(self): 40 | SUBSET_NAME_1 = "subset-1" 41 | SUBSET_NAME_2 = "subset-2" 42 | HEALTH_CHECK["SUBSETS"] = { 43 | SUBSET_NAME_1: ["OkPlugin"], 44 | SUBSET_NAME_2: ["OkPlugin", "FailPlugin"], 45 | } 46 | 47 | stdout = StringIO() 48 | call_command("health_check", f"--subset={SUBSET_NAME_1}", stdout=stdout) 49 | stdout.seek(0) 50 | assert stdout.read() == ("OkPlugin ... working\n") 51 | 52 | def test_command_with_failed_check_subset(self): 53 | SUBSET_NAME = "subset-2" 54 | HEALTH_CHECK["SUBSETS"] = {SUBSET_NAME: ["OkPlugin", "FailPlugin"]} 55 | 56 | stdout = StringIO() 57 | with pytest.raises(SystemExit): 58 | call_command("health_check", f"--subset={SUBSET_NAME}", stdout=stdout) 59 | stdout.seek(0) 60 | assert stdout.read() == ( 61 | "FailPlugin ... unknown error: Oops\nOkPlugin ... working\n" 62 | ) 63 | 64 | def test_command_with_non_existence_subset(self): 65 | SUBSET_NAME = "subset-2" 66 | NON_EXISTENCE_SUBSET_NAME = "abcdef12" 67 | HEALTH_CHECK["SUBSETS"] = {SUBSET_NAME: ["OkPlugin"]} 68 | 69 | stdout = StringIO() 70 | with pytest.raises(SystemExit): 71 | call_command("health_check", f"--subset={NON_EXISTENCE_SUBSET_NAME}", stdout=stdout) 72 | stdout.seek(0) 73 | assert stdout.read() == (f"Subset: '{NON_EXISTENCE_SUBSET_NAME}' does not exist.\n") 74 | -------------------------------------------------------------------------------- /health_check/storage/backends.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.files.base import ContentFile 4 | from django.core.files.storage import InvalidStorageError, default_storage, storages 5 | 6 | from health_check.backends import BaseHealthCheckBackend 7 | from health_check.exceptions import ServiceUnavailable 8 | 9 | 10 | class StorageHealthCheck(BaseHealthCheckBackend): 11 | """ 12 | Tests the status of a `StorageBackend`. 13 | 14 | Can be extended to test any storage backend by subclassing: 15 | 16 | class MyStorageHealthCheck(StorageHealthCheck): 17 | storage = 'some.other.StorageBackend' 18 | plugin_dir.register(MyStorageHealthCheck) 19 | 20 | storage must be either a string pointing to a storage class 21 | (e.g 'django.core.files.storage.FileSystemStorage') or a Storage instance. 22 | """ 23 | 24 | storage_alias = None 25 | storage = None 26 | 27 | def get_storage(self): 28 | try: 29 | return storages[self.storage_alias] 30 | except InvalidStorageError: 31 | return None 32 | 33 | def get_file_name(self): 34 | return f"health_check_storage_test/test-{uuid.uuid4()}.txt" 35 | 36 | def get_file_content(self): 37 | return b"this is the healthtest file content" 38 | 39 | def check_save(self, file_name, file_content): 40 | storage = self.get_storage() 41 | # save the file 42 | file_name = storage.save(file_name, ContentFile(content=file_content)) 43 | # read the file and compare 44 | if not storage.exists(file_name): 45 | raise ServiceUnavailable("File does not exist") 46 | with storage.open(file_name) as f: 47 | if not f.read() == file_content: 48 | raise ServiceUnavailable("File content does not match") 49 | return file_name 50 | 51 | def check_delete(self, file_name): 52 | storage = self.get_storage() 53 | # delete the file and make sure it is gone 54 | storage.delete(file_name) 55 | if storage.exists(file_name): 56 | raise ServiceUnavailable("File was not deleted") 57 | 58 | def check_status(self): 59 | try: 60 | # write the file to the storage backend 61 | file_name = self.get_file_name() 62 | file_content = self.get_file_content() 63 | file_name = self.check_save(file_name, file_content) 64 | self.check_delete(file_name) 65 | return True 66 | except ServiceUnavailable as e: 67 | raise e 68 | except Exception as e: 69 | raise ServiceUnavailable("Unknown exception") from e 70 | 71 | 72 | class DefaultFileStorageHealthCheck(StorageHealthCheck): 73 | storage_alias = "default" 74 | storage = default_storage 75 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | Django HealthCheck: Pluggable health checks for Django applications 6 | 7 |
8 | Documentation | 9 | Issues | 10 | Changelog | 11 | Funding 💚 12 |

13 | 14 | # Django HealthCheck 15 | 16 | [![version](https://img.shields.io/pypi/v/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 17 | [![pyversion](https://img.shields.io/pypi/pyversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 18 | [![djversion](https://img.shields.io/pypi/djversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 19 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/django-health-check/) 20 | 21 | This project checks for various conditions and provides reports when 22 | anomalous behavior is detected. 23 | 24 | [Documentation](https://codingjoe.dev/django-health-check/) | [Issues](https://github.com/codingjoe/django-health-check/issues/new/choose) | [Changelog](https://github.com/codingjoe/django-health-check/releases) | [Funding](https://github.com/sponsors/codingjoe) 💚 25 | 26 | The following health checks are bundled with this project: 27 | 28 | - cache 29 | - database 30 | - storage 31 | - disk and memory utilization (via `psutil`) 32 | - AWS S3 storage 33 | - Celery task queue 34 | - Celery ping 35 | - RabbitMQ 36 | - Migrations 37 | 38 | Writing your own custom health checks is also very quick and easy. 39 | 40 | We also like contributions, so don’t be afraid to make a pull request. 41 | 42 | ## Use Cases 43 | 44 | The primary intended use case is to monitor conditions via HTTP(S), with 45 | responses available in HTML and JSON formats. When you get back a 46 | response that includes one or more problems, you can then decide the 47 | appropriate course of action, which could include generating 48 | notifications and/or automating the replacement of a failing node with a 49 | new one. If you are monitoring health in a high-availability environment 50 | with a load balancer that returns responses from multiple nodes, please 51 | note that certain checks (e.g., disk and memory usage) will return 52 | responses specific to the node selected by the load balancer. 53 | -------------------------------------------------------------------------------- /tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from health_check.backends import BaseHealthCheckBackend 6 | from health_check.conf import HEALTH_CHECK 7 | from health_check.mixins import CheckMixin 8 | from health_check.plugins import plugin_dir 9 | 10 | 11 | class FailPlugin(BaseHealthCheckBackend): 12 | def check_status(self): 13 | self.add_error("Oops") 14 | 15 | 16 | class OkPlugin(BaseHealthCheckBackend): 17 | def check_status(self): 18 | pass 19 | 20 | 21 | class Checker(CheckMixin): 22 | pass 23 | 24 | 25 | class TestCheckMixin: 26 | @pytest.fixture(autouse=True) 27 | def setup(self): 28 | plugin_dir.reset() 29 | plugin_dir.register(FailPlugin) 30 | plugin_dir.register(OkPlugin) 31 | yield 32 | plugin_dir.reset() 33 | 34 | @pytest.mark.parametrize("disable_threading", [(True,), (False,)]) 35 | def test_plugins(self, monkeypatch, disable_threading): 36 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", disable_threading) 37 | 38 | assert len(Checker().plugins) == 2 39 | 40 | @pytest.mark.parametrize("disable_threading", [(True,), (False,)]) 41 | def test_errors(self, monkeypatch, disable_threading): 42 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", disable_threading) 43 | 44 | assert len(Checker().errors) == 1 45 | 46 | @pytest.mark.parametrize("disable_threading", [(True,), (False,)]) 47 | def test_run_check(self, monkeypatch, disable_threading): 48 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", disable_threading) 49 | 50 | assert len(Checker().run_check()) == 1 51 | 52 | def test_run_check_threading_enabled(self, monkeypatch): 53 | """Ensure threading used when not disabled.""" 54 | # Ensure threading is enabled. 55 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", False) 56 | 57 | # Ensure ThreadPoolExecutor is used 58 | with patch("health_check.mixins.ThreadPoolExecutor") as tpe: 59 | Checker().run_check() 60 | tpe.assert_called() 61 | 62 | # Ensure ThreadPoolExecutor is used 63 | with patch( 64 | "django.db.connections.close_all", 65 | ) as close_all: 66 | Checker().run_check() 67 | close_all.assert_called() 68 | 69 | def test_run_check_threading_disabled(self, monkeypatch): 70 | """Ensure threading not used when disabled.""" 71 | # Ensure threading is disabled. 72 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", True) 73 | 74 | # Ensure ThreadPoolExecutor is not used 75 | with patch("health_check.mixins.ThreadPoolExecutor") as tpe: 76 | Checker().run_check() 77 | tpe.assert_not_called() 78 | 79 | # Ensure ThreadPoolExecutor is used 80 | with patch( 81 | "django.db.connections.close_all", 82 | ) as close_all: 83 | Checker().run_check() 84 | close_all.assert_not_called() 85 | -------------------------------------------------------------------------------- /health_check/mixins.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from collections import OrderedDict 3 | from concurrent.futures import ThreadPoolExecutor 4 | 5 | from django.db import connections 6 | from django.http import Http404 7 | 8 | from health_check.conf import HEALTH_CHECK 9 | from health_check.exceptions import ServiceWarning 10 | from health_check.plugins import plugin_dir 11 | 12 | 13 | class CheckMixin: 14 | _errors = None 15 | _plugins = None 16 | 17 | @property 18 | def errors(self): 19 | if not self._errors: 20 | self._errors = self.run_check() 21 | return self._errors 22 | 23 | def check(self, subset=None): 24 | return self.run_check(subset=subset) 25 | 26 | @property 27 | def plugins(self): 28 | if not plugin_dir._registry: 29 | return OrderedDict({}) 30 | 31 | if not self._plugins: 32 | registering_plugins = ( 33 | plugin_class(**copy.deepcopy(options)) for plugin_class, options in plugin_dir._registry 34 | ) 35 | registering_plugins = sorted(registering_plugins, key=lambda plugin: plugin.identifier()) 36 | self._plugins = OrderedDict({plugin.identifier(): plugin for plugin in registering_plugins}) 37 | return self._plugins 38 | 39 | def filter_plugins(self, subset=None): 40 | if subset is None: 41 | return self.plugins 42 | 43 | health_check_subsets = HEALTH_CHECK["SUBSETS"] 44 | if subset not in health_check_subsets or not self.plugins: 45 | raise Http404(f"Subset: '{subset}' does not exist.") 46 | 47 | selected_subset = set(health_check_subsets[subset]) 48 | return { 49 | plugin_identifier: v 50 | for plugin_identifier, v in self.plugins.items() 51 | if plugin_identifier in selected_subset 52 | } 53 | 54 | def run_check(self, subset=None): 55 | errors = [] 56 | 57 | def _run(plugin): 58 | plugin.run_check() 59 | try: 60 | return plugin 61 | finally: 62 | if not HEALTH_CHECK["DISABLE_THREADING"]: 63 | # DB connections are thread-local so we need to close them here 64 | connections.close_all() 65 | 66 | def _collect_errors(plugin): 67 | if plugin.critical_service: 68 | if not HEALTH_CHECK["WARNINGS_AS_ERRORS"]: 69 | errors.extend(e for e in plugin.errors if not isinstance(e, ServiceWarning)) 70 | else: 71 | errors.extend(plugin.errors) 72 | 73 | plugins = self.filter_plugins(subset=subset) 74 | plugin_instances = plugins.values() 75 | 76 | if HEALTH_CHECK["DISABLE_THREADING"]: 77 | for plugin in plugin_instances: 78 | _run(plugin) 79 | _collect_errors(plugin) 80 | else: 81 | with ThreadPoolExecutor(max_workers=len(plugin_instances) or 1) as executor: 82 | for plugin in executor.map(_run, plugin_instances): 83 | _collect_errors(plugin) 84 | return errors 85 | -------------------------------------------------------------------------------- /tests/test_rabbitmq.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from amqp.exceptions import AccessRefused 4 | 5 | from health_check.contrib.rabbitmq.backends import RabbitMQHealthCheck 6 | 7 | 8 | class TestRabbitMQHealthCheck: 9 | """Test RabbitMQ health check.""" 10 | 11 | @mock.patch("health_check.contrib.rabbitmq.backends.getattr") 12 | @mock.patch("health_check.contrib.rabbitmq.backends.Connection") 13 | def test_broker_refused_connection(self, mocked_connection, mocked_getattr): 14 | """Test when the connection to RabbitMQ is refused.""" 15 | mocked_getattr.return_value = "broker_url" 16 | 17 | conn_exception = ConnectionRefusedError("Refused connection") 18 | 19 | # mock returns 20 | mocked_conn = mock.MagicMock() 21 | mocked_connection.return_value.__enter__.return_value = mocked_conn 22 | mocked_conn.connect.side_effect = conn_exception 23 | 24 | # instantiates the class 25 | rabbitmq_healthchecker = RabbitMQHealthCheck() 26 | 27 | # invokes the method check_status() 28 | rabbitmq_healthchecker.check_status() 29 | assert len(rabbitmq_healthchecker.errors), 1 30 | 31 | # mock assertions 32 | mocked_connection.assert_called_once_with("broker_url") 33 | 34 | @mock.patch("health_check.contrib.rabbitmq.backends.getattr") 35 | @mock.patch("health_check.contrib.rabbitmq.backends.Connection") 36 | def test_broker_auth_error(self, mocked_connection, mocked_getattr): 37 | """Test that the connection to RabbitMQ has an authentication error.""" 38 | mocked_getattr.return_value = "broker_url" 39 | 40 | conn_exception = AccessRefused("Refused connection") 41 | 42 | # mock returns 43 | mocked_conn = mock.MagicMock() 44 | mocked_connection.return_value.__enter__.return_value = mocked_conn 45 | mocked_conn.connect.side_effect = conn_exception 46 | 47 | # instantiates the class 48 | rabbitmq_healthchecker = RabbitMQHealthCheck() 49 | 50 | # invokes the method check_status() 51 | rabbitmq_healthchecker.check_status() 52 | assert len(rabbitmq_healthchecker.errors), 1 53 | 54 | # mock assertions 55 | mocked_connection.assert_called_once_with("broker_url") 56 | 57 | @mock.patch("health_check.contrib.rabbitmq.backends.getattr") 58 | @mock.patch("health_check.contrib.rabbitmq.backends.Connection") 59 | def test_broker_connection_upon_none_url(self, mocked_connection, mocked_getattr): 60 | """Thest when the connection to RabbitMQ has no ``broker_url``.""" 61 | mocked_getattr.return_value = None 62 | # if the variable BROKER_URL is not set, AccessRefused exception is raised 63 | conn_exception = AccessRefused("Refused connection") 64 | 65 | # mock returns 66 | mocked_conn = mock.MagicMock() 67 | mocked_connection.return_value.__enter__.return_value = mocked_conn 68 | mocked_conn.connect.side_effect = conn_exception 69 | 70 | # instantiates the class 71 | rabbitmq_healthchecker = RabbitMQHealthCheck() 72 | 73 | # invokes the method check_status() 74 | rabbitmq_healthchecker.check_status() 75 | assert len(rabbitmq_healthchecker.errors), 1 76 | 77 | # mock assertions 78 | mocked_connection.assert_called_once_with(None) 79 | -------------------------------------------------------------------------------- /tests/test_backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from io import StringIO 3 | 4 | import pytest 5 | 6 | from health_check.backends import BaseHealthCheckBackend 7 | from health_check.exceptions import HealthCheckException 8 | 9 | 10 | class TestBaseHealthCheckBackend: 11 | def test_run_check(self): 12 | with pytest.raises(NotImplementedError): 13 | BaseHealthCheckBackend().run_check() 14 | 15 | def test_identifier(self): 16 | assert BaseHealthCheckBackend().identifier() == "BaseHealthCheckBackend" 17 | 18 | class MyHeathCheck(BaseHealthCheckBackend): 19 | pass 20 | 21 | assert MyHeathCheck().identifier() == "MyHeathCheck" 22 | 23 | class MyHeathCheck(BaseHealthCheckBackend): 24 | foo = "bar" 25 | 26 | def identifier(self): 27 | return self.foo 28 | 29 | assert MyHeathCheck().identifier() == "bar" 30 | 31 | def test_status(self): 32 | ht = BaseHealthCheckBackend() 33 | assert ht.status == 1 34 | ht.errors = [1] 35 | assert ht.status == 0 36 | 37 | def test_pretty_status(self): 38 | ht = BaseHealthCheckBackend() 39 | assert ht.pretty_status() == "working" 40 | ht.errors = ["foo"] 41 | assert ht.pretty_status() == "foo" 42 | ht.errors.append("bar") 43 | assert ht.pretty_status() == "foo\nbar" 44 | ht.errors.append(123) 45 | assert ht.pretty_status() == "foo\nbar\n123" 46 | 47 | def test_add_error(self): 48 | ht = BaseHealthCheckBackend() 49 | e = HealthCheckException("foo") 50 | ht.add_error(e) 51 | assert ht.errors[0] is e 52 | 53 | ht = BaseHealthCheckBackend() 54 | ht.add_error("bar") 55 | assert isinstance(ht.errors[0], HealthCheckException) 56 | assert str(ht.errors[0]) == "unknown error: bar" 57 | 58 | ht = BaseHealthCheckBackend() 59 | ht.add_error(type) 60 | assert isinstance(ht.errors[0], HealthCheckException) 61 | assert str(ht.errors[0]) == "unknown error: unknown error" 62 | 63 | def test_add_error_cause(self): 64 | ht = BaseHealthCheckBackend() 65 | logger = logging.getLogger("health-check") 66 | with StringIO() as stream: 67 | stream_handler = logging.StreamHandler(stream) 68 | logger.addHandler(stream_handler) 69 | try: 70 | raise Exception("bar") 71 | except Exception as e: 72 | ht.add_error("foo", e) 73 | 74 | stream.seek(0) 75 | log = stream.read() 76 | assert "foo" in log 77 | assert "bar" in log 78 | assert "Traceback" in log 79 | assert "Exception: bar" in log 80 | logger.removeHandler(stream_handler) 81 | 82 | with StringIO() as stream: 83 | stream_handler = logging.StreamHandler(stream) 84 | logger.addHandler(stream_handler) 85 | try: 86 | raise Exception("bar") 87 | except Exception: 88 | ht.add_error("foo") 89 | 90 | stream.seek(0) 91 | log = stream.read() 92 | assert "foo" in log 93 | assert "bar" not in log 94 | assert "Traceback" not in log 95 | assert "Exception: bar" not in log 96 | logger.removeHandler(stream_handler) 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2", "flit_scm", "wheel"] 3 | build-backend = "flit_scm:buildapi" 4 | 5 | [project] 6 | name = "django-health-check" 7 | readme = { file = "README.md", content-type = "text/markdown" } 8 | license = { file = "LICENSE" } 9 | authors = [ 10 | { name = "Kristian Ollegaard", email = "kristian@oellegaard.com" }, 11 | { name = "Johannes Maron", email = "johannes@maron.family" } 12 | ] 13 | keywords = ["django", "postgresql"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Framework :: Django", 17 | "Framework :: Django :: 4.2", 18 | "Framework :: Django :: 5.2", 19 | "Framework :: Django :: 6.0", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Programming Language :: Python :: 3.14", 31 | "Topic :: Software Development :: Quality Assurance", 32 | "Topic :: System :: Logging", 33 | "Topic :: System :: Monitoring", 34 | "Topic :: Utilities" 35 | ] 36 | requires-python = ">=3.10" 37 | dynamic = [ 38 | "version", 39 | "description", 40 | ] 41 | dependencies = [ 42 | "Django>=4.2", 43 | ] 44 | 45 | [project.urls] 46 | # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels 47 | Homepage = "https://codingjoe.dev/django-health-check/" 48 | Changelog = "https://github.com/codingjoe/django-health-check/releases" 49 | Source = "https://github.com/codingjoe/django-health-check" 50 | Releasenotes = "https://github.com/codingjoe/django-health-check/releases/latest" 51 | Documentation = "https://codingjoe.dev/django-health-check/" 52 | Issues = "https://github.com/codingjoe/django-health-check/issues" 53 | 54 | [dependency-groups] 55 | dev = [ 56 | { include-group = "test" }, 57 | { include-group = "docs" }, 58 | ] 59 | test = [ 60 | "pytest", 61 | "pytest-cov", 62 | "pytest-django", 63 | "celery", 64 | "redis", 65 | "django-storages", 66 | "boto3", 67 | ] 68 | docs = [ 69 | "mkdocs", 70 | "mkdocstrings[python]>=0.18", 71 | "mkdocs-material", 72 | "markdown-callouts", 73 | "Pygments", 74 | ] 75 | 76 | [tool.flit.module] 77 | name = "health_check" 78 | 79 | [tool.setuptools_scm] 80 | write_to = "health_check/_version.py" 81 | 82 | [tool.pytest.ini_options] 83 | addopts = "--cov --strict-markers --cov-report=xml --cov-report=term --doctest-modules" 84 | testpaths = [ 85 | "tests", 86 | ] 87 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 88 | norecursedirs = ".git" 89 | python_files = "test_*.py" 90 | xfail_strict = true 91 | 92 | [tool.coverage.run] 93 | source = ["health_check"] 94 | 95 | [tool.coverage.report] 96 | show_missing = true 97 | skip_covered = true 98 | 99 | [tool.ruff] 100 | line-length = 120 101 | target-version = "py39" 102 | 103 | [tool.ruff.lint] 104 | select = [ 105 | "D", # pydocstyle 106 | "E", # pycodestyle errors 107 | "EXE", # flake8-executable 108 | "F", # pyflakes 109 | "I", # isort 110 | "S", # flake8-bandit 111 | "SIM", # flake8-simplify 112 | "UP", # pyupgrade 113 | "W", # pycodestyle warnings 114 | ] 115 | ignore = ["D1", "D203", "D212"] 116 | 117 | [tool.ruff.lint.per-file-ignores] 118 | "tests/*.py" = ["S101", "S105", "PLR2004"] 119 | 120 | [tool.ruff.lint.isort] 121 | known-first-party = ["measurement", "tests"] 122 | -------------------------------------------------------------------------------- /tests/test_redis.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from redis.exceptions import ConnectionError, TimeoutError 4 | 5 | from health_check.contrib.redis.backends import RedisHealthCheck 6 | 7 | 8 | class TestRedisHealthCheck: 9 | """Test Redis health check.""" 10 | 11 | @mock.patch("health_check.contrib.redis.backends.getattr") 12 | @mock.patch("health_check.contrib.redis.backends.from_url", autospec=True) 13 | def test_redis_refused_connection(self, mocked_connection, mocked_getattr): 14 | """Test when the connection to Redis is refused.""" 15 | mocked_getattr.return_value = "redis_url" 16 | 17 | # mock returns 18 | mocked_connection.return_value = mock.MagicMock() 19 | mocked_connection.return_value.__enter__.side_effect = ConnectionRefusedError("Refused connection") 20 | 21 | # instantiates the class 22 | redis_healthchecker = RedisHealthCheck() 23 | 24 | # invokes the method check_status() 25 | redis_healthchecker.check_status() 26 | assert len(redis_healthchecker.errors), 1 27 | 28 | # mock assertions 29 | mocked_connection.assert_called_once_with("redis://localhost/1", **{}) 30 | 31 | @mock.patch("health_check.contrib.redis.backends.getattr") 32 | @mock.patch("health_check.contrib.redis.backends.from_url") 33 | def test_redis_timeout_error(self, mocked_connection, mocked_getattr): 34 | """Test Redis TimeoutError.""" 35 | mocked_getattr.return_value = "redis_url" 36 | 37 | # mock returns 38 | mocked_connection.return_value = mock.MagicMock() 39 | mocked_connection.return_value.__enter__.side_effect = TimeoutError("Timeout Error") 40 | 41 | # instantiates the class 42 | redis_healthchecker = RedisHealthCheck() 43 | 44 | # invokes the method check_status() 45 | redis_healthchecker.check_status() 46 | assert len(redis_healthchecker.errors), 1 47 | 48 | # mock assertions 49 | mocked_connection.assert_called_once_with("redis://localhost/1", **{}) 50 | 51 | @mock.patch("health_check.contrib.redis.backends.getattr") 52 | @mock.patch("health_check.contrib.redis.backends.from_url") 53 | def test_redis_con_limit_exceeded(self, mocked_connection, mocked_getattr): 54 | """Test Connection Limit Exceeded error.""" 55 | mocked_getattr.return_value = "redis_url" 56 | 57 | # mock returns 58 | mocked_connection.return_value = mock.MagicMock() 59 | mocked_connection.return_value.__enter__.side_effect = ConnectionError("Connection Error") 60 | 61 | # instantiates the class 62 | redis_healthchecker = RedisHealthCheck() 63 | 64 | # invokes the method check_status() 65 | redis_healthchecker.check_status() 66 | assert len(redis_healthchecker.errors), 1 67 | 68 | # mock assertions 69 | mocked_connection.assert_called_once_with("redis://localhost/1", **{}) 70 | 71 | @mock.patch("health_check.contrib.redis.backends.getattr") 72 | @mock.patch("health_check.contrib.redis.backends.from_url") 73 | def test_redis_conn_ok(self, mocked_connection, mocked_getattr): 74 | """Test everything is OK.""" 75 | mocked_getattr.return_value = "redis_url" 76 | 77 | # mock returns 78 | mocked_connection.return_value = mock.MagicMock() 79 | mocked_connection.return_value.__enter__.side_effect = True 80 | 81 | # instantiates the class 82 | redis_healthchecker = RedisHealthCheck() 83 | 84 | # invokes the method check_status() 85 | redis_healthchecker.check_status() 86 | assert len(redis_healthchecker.errors), 0 87 | 88 | # mock assertions 89 | mocked_connection.assert_called_once_with("redis://localhost/1", **{}) 90 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from django.core.cache.backends.base import BaseCache, CacheKeyWarning 5 | 6 | from health_check.cache.backends import CacheBackend 7 | 8 | 9 | # A Mock version of the cache to use for testing 10 | class MockCache(BaseCache): 11 | """ 12 | A Mock Cache used for testing. 13 | 14 | set_works - set to False to make the mocked set method fail, but not raise 15 | set_raises - The Exception to be raised when set() is called, if any. 16 | """ 17 | 18 | key = None 19 | value = None 20 | set_works = None 21 | set_raises = None 22 | 23 | def __init__(self, set_works=True, set_raises=None): 24 | super().__init__(params={}) 25 | self.set_works = set_works 26 | self.set_raises = set_raises 27 | 28 | def set(self, key, value, *args, **kwargs): 29 | if self.set_raises is not None: 30 | raise self.set_raises 31 | elif self.set_works: 32 | self.key = key 33 | self.value = value 34 | else: 35 | self.key = key 36 | self.value = None 37 | 38 | def get(self, key, *args, **kwargs): 39 | if key == self.key: 40 | return self.value 41 | else: 42 | return None 43 | 44 | 45 | class TestHealthCheckCache: 46 | """ 47 | Tests health check behavior with a mocked cache backend. 48 | 49 | Ensures check_status returns/raises the expected result when the cache works, fails, or raises exceptions. 50 | """ 51 | 52 | @patch("health_check.cache.backends.caches", dict(default=MockCache())) 53 | def test_check_status_working(self): 54 | cache_backend = CacheBackend() 55 | cache_backend.run_check() 56 | assert not cache_backend.errors 57 | 58 | @patch( 59 | "health_check.cache.backends.caches", 60 | dict(default=MockCache(), broken=MockCache(set_works=False)), 61 | ) 62 | def test_multiple_backends_check_default(self): 63 | # default backend works while other is broken 64 | cache_backend = CacheBackend("default") 65 | cache_backend.run_check() 66 | assert not cache_backend.errors 67 | 68 | @patch( 69 | "health_check.cache.backends.caches", 70 | dict(default=MockCache(), broken=MockCache(set_works=False)), 71 | ) 72 | def test_multiple_backends_check_broken(self): 73 | cache_backend = CacheBackend("broken") 74 | cache_backend.run_check() 75 | assert cache_backend.errors 76 | assert "does not match" in cache_backend.pretty_status() 77 | 78 | # check_status should raise ServiceUnavailable when values at cache key do not match 79 | @patch("health_check.cache.backends.caches", dict(default=MockCache(set_works=False))) 80 | def test_set_fails(self): 81 | cache_backend = CacheBackend() 82 | cache_backend.run_check() 83 | assert cache_backend.errors 84 | assert "does not match" in cache_backend.pretty_status() 85 | 86 | # check_status should catch generic exceptions raised by set and convert to ServiceUnavailable 87 | @patch( 88 | "health_check.cache.backends.caches", 89 | dict(default=MockCache(set_raises=Exception)), 90 | ) 91 | def test_set_raises_generic(self): 92 | cache_backend = CacheBackend() 93 | with pytest.raises(Exception): 94 | cache_backend.run_check() 95 | 96 | # check_status should catch CacheKeyWarning and convert to ServiceReturnedUnexpectedResult 97 | @patch( 98 | "health_check.cache.backends.caches", 99 | dict(default=MockCache(set_raises=CacheKeyWarning)), 100 | ) 101 | def test_set_raises_cache_key_warning(self): 102 | cache_backend = CacheBackend() 103 | cache_backend.check_status() 104 | cache_backend.run_check() 105 | assert "unexpected result: Cache key warning" in cache_backend.pretty_status() 106 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | Settings can be configured via the 4 | `HEALTH_CHECK` dictionary. 5 | 6 | ## `WARNINGS_AS_ERRORS` 7 | 8 | Treats `ServiceWarning` as errors, meaning they will cause the views to 9 | respond with a 500 status code. Default is 10 | `True`. If set to 11 | `False` warnings will be displayed in the 12 | template or in the JSON response but the status code will remain a 200. 13 | 14 | ## Security 15 | 16 | Django health check can be used as a possible DOS attack vector as it 17 | can put your system under a lot of stress. As a default the view is also 18 | not cached by CDNs. Therefore, we recommend to use a secure token to 19 | protect your application servers from an attacker. 20 | 21 | 1. Setup HTTPS. Seriously... 22 | 1. Add a secure token to your URL. 23 | 24 | Create a secure token: 25 | 26 | ```shell 27 | python -c "import secrets; print(secrets.token_urlsafe())" 28 | ``` 29 | 30 | Add it to your URL: 31 | 32 | ```python 33 | # urls.py 34 | from django.urls import include, path 35 | 36 | 37 | urlpatterns = [ 38 | # ... 39 | path("ht/super_secret_token/", include("health_check.urls")), 40 | ] 41 | ``` 42 | 43 | You can still use any uptime bot that is URL based while enjoying token 44 | protection. 45 | 46 | > [!WARNING] 47 | > Do NOT use Django's `SECRET_KEY` setting. 48 | > This should never be exposed, to any third party. Not even your 49 | > trusted uptime bot. 50 | 51 | ## `cache` 52 | 53 | The cache backend uses the following setting: 54 | 55 | | Name | Type | Default | Description | 56 | | ------------------------------- | ------ | ------------------------ | --------------------------------------------------------------------------------------------------------- | 57 | | `HEALTHCHECK_CACHE_KEY` | String | `djangohealthcheck_test` | Specifies the name of the key to write to and read from to validate that the cache is working. | 58 | | `HEALTHCHECK_REDIS_URL_OPTIONS` | Dict | {} | Additional arguments which will be passed as keyword arguments to the Redis connection class initialiser. | 59 | 60 | ## `psutil` 61 | 62 | The following default settings will be used to check for disk and memory 63 | utilization. If you would prefer different thresholds, you can add the 64 | dictionary below to your Django settings file and adjust the values 65 | accordingly. If you want to disable any of these checks, set its value 66 | to `None`. 67 | 68 | ```python 69 | HEALTH_CHECK = { 70 | "DISK_USAGE_MAX": 90, # percent 71 | "MEMORY_MIN": 100, # in MB 72 | } 73 | ``` 74 | 75 | With the above default settings, warnings will be reported when disk 76 | utilization exceeds 90% or available memory drops below 100 MB. 77 | 78 | ### `DISK_USAGE_MAX` 79 | 80 | Specify the desired disk utilization threshold, in percent. When disk 81 | usage exceeds the specified value, a warning will be reported. 82 | 83 | ### `MEMORY_MIN` 84 | 85 | Specify the desired memory utilization threshold, in megabytes. When 86 | available memory falls below the specified value, a warning will be 87 | reported. 88 | 89 | ## Celery Health Check 90 | 91 | Using `django.settings` you may exert more 92 | fine-grained control over the behavior of the celery health check 93 | 94 | | Name | Type | Default | Description | 95 | | ----------------------------------- | ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------- | 96 | | `HEALTHCHECK_CELERY_QUEUE_TIMEOUT` | Number | `3` | Specifies the maximum amount of time a task may spend in the queue before being automatically revoked with a `TaskRevokedError`. | 97 | | `HEALTHCHECK_CELERY_RESULT_TIMEOUT` | Number | `3` | Specifies the maximum total time for a task to complete and return a result, including queue time. | 98 | | `HEALTHCHECK_CELERY_PRIORITY` | Number | `None` | Specifies the healthcheck task priority. | 99 | 100 | ## Threading 101 | 102 | Django Health Check runs each check in a separate thread by default to 103 | improve performance. 104 | 105 | In some cases, this might cause unwanted side effects and can be 106 | disabled with: 107 | 108 | ```python 109 | HEALTH_CHECK = {"DISABLE_THREADING": True} 110 | ``` 111 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Setting up monitoring 4 | 5 | You can use tools like Pingdom, StatusCake or other uptime robots to 6 | monitor service status. The `/ht/` endpoint will respond with an HTTP 7 | 200 if all checks passed and with an HTTP 500 if any of the tests 8 | failed. Getting machine-readable JSON reports 9 | 10 | If you want machine-readable status reports you can request the `/ht/` 11 | endpoint with the `Accept` HTTP header set to `application/json` or pass 12 | `format=json` as a query parameter. 13 | 14 | The backend will return a JSON response: 15 | 16 | ```shell 17 | $ curl -v -X GET -H "Accept: application/json" http://www.example.com/ht/ 18 | 19 | > GET /ht/ HTTP/1.1 20 | > Host: www.example.com 21 | > Accept: application/json 22 | > 23 | < HTTP/1.1 200 OK 24 | < Content-Type: application/json 25 | 26 | { 27 | "CacheBackend": "working", 28 | "DatabaseBackend": "working", 29 | "S3BotoStorageHealthCheck": "working" 30 | } 31 | 32 | $ curl -v -X GET http://www.example.com/ht/?format=json 33 | 34 | > GET /ht/?format=json HTTP/1.1 35 | > Host: www.example.com 36 | > 37 | < HTTP/1.1 200 OK 38 | < Content-Type: application/json 39 | 40 | { 41 | "CacheBackend": "working", 42 | "DatabaseBackend": "working", 43 | "S3BotoStorageHealthCheck": "working" 44 | } 45 | ``` 46 | 47 | ## Writing a custom health check 48 | 49 | Writing a health check is quick and easy: 50 | 51 | ```python 52 | from health_check.backends import BaseHealthCheckBackend 53 | 54 | 55 | class MyHealthCheckBackend(BaseHealthCheckBackend): 56 | #: The status endpoints will respond with a 200 status code 57 | #: even if the check errors. 58 | critical_service = False 59 | 60 | def check_status(self): 61 | # The test code goes here. 62 | # You can use `self.add_error` or 63 | # raise a `HealthCheckException`, 64 | # similar to Django's form validation. 65 | pass 66 | 67 | def identifier(self): 68 | return self.__class__.__name__ # Display name on the endpoint. 69 | ``` 70 | 71 | After writing a custom checker, register it in your app configuration: 72 | 73 | ```python 74 | from django.apps import AppConfig 75 | 76 | from health_check.plugins import plugin_dir 77 | 78 | 79 | class MyAppConfig(AppConfig): 80 | name = "my_app" 81 | 82 | def ready(self): 83 | from .backends import MyHealthCheckBackend 84 | 85 | plugin_dir.register(MyHealthCheckBackend) 86 | ``` 87 | 88 | Make sure the application you write the checker into is registered in 89 | your `INSTALLED_APPS`. 90 | 91 | ## Customizing output 92 | 93 | You can customize HTML or JSON rendering by inheriting from `MainView` 94 | in `health_check.views` and customizing the `template_name`, `get`, 95 | `render_to_response` and `render_to_response_json` properties: 96 | 97 | ```python 98 | # views.py 99 | from django.http import HttpResponse, JsonResponse 100 | 101 | from health_check.views import MainView 102 | 103 | 104 | class HealthCheckCustomView(MainView): 105 | template_name = "myapp/health_check_dashboard.html" # customize the used templates 106 | 107 | def get(self, request, *args, **kwargs): 108 | plugins = [] 109 | status = 200 # needs to be filled status you need 110 | # ... 111 | if "application/json" in request.META.get("HTTP_ACCEPT", ""): 112 | return self.render_to_response_json(plugins, status) 113 | return self.render_to_response(plugins, status) 114 | 115 | def render_to_response(self, plugins, status): # customize HTML output 116 | return HttpResponse("COOL" if status == 200 else "SWEATY", status=status) 117 | 118 | def render_to_response_json(self, plugins, status): # customize JSON output 119 | return JsonResponse( 120 | {str(p.identifier()): "COOL" if status == 200 else "SWEATY" for p in plugins}, status=status 121 | ) 122 | 123 | 124 | # urls.py 125 | from django.urls import path 126 | 127 | from . import views 128 | 129 | urlpatterns = [ 130 | # ... 131 | path(r"^ht/$", views.HealthCheckCustomView.as_view(), name="health_check_custom"), 132 | ] 133 | ``` 134 | 135 | ## Django command 136 | 137 | You can run the Django command `health_check` to perform your health 138 | checks via the command line, or periodically with a cron, as follows: 139 | 140 | ```shell 141 | django-admin health_check 142 | ``` 143 | 144 | This should yield the following output: 145 | 146 | ``` 147 | DatabaseHealthCheck ... working 148 | CustomHealthCheck ... unavailable: Something went wrong! 149 | ``` 150 | 151 | Similar to the http version, a critical error will cause the command to 152 | quit with the exit code `1`. 153 | -------------------------------------------------------------------------------- /health_check/views.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.db import transaction 4 | from django.http import HttpResponse, JsonResponse 5 | from django.utils.decorators import method_decorator 6 | from django.views.decorators.cache import never_cache 7 | from django.views.generic import TemplateView 8 | 9 | from health_check.mixins import CheckMixin 10 | 11 | 12 | class MediaType: 13 | """ 14 | Sortable object representing HTTP's accept header. 15 | 16 | .. seealso:: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept 17 | """ 18 | 19 | pattern = re.compile( 20 | r""" 21 | ^ 22 | (?P 23 | (\w+|\*) # Media type, or wildcard 24 | / 25 | ([\w\d\-+.]+|\*) # subtype, or wildcard 26 | ) 27 | ( 28 | \s*;\s* # parameter separator with optional whitespace 29 | q= # q is expected to be the first parameter, by RFC2616 30 | (?P 31 | 1([.]0{1,3})? # 1 with up to three digits of precision 32 | | 33 | 0([.]\d{1,3})? # 0.000 to 0.999 with optional precision 34 | ) 35 | )? 36 | ( 37 | \s*;\s* # parameter separator with optional whitespace 38 | [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any token from legal characters 39 | = 40 | [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any value from legal characters 41 | )* 42 | $ 43 | """, 44 | re.VERBOSE, 45 | ) 46 | 47 | def __init__(self, mime_type, weight=1.0): 48 | self.mime_type = mime_type 49 | self.weight = float(weight) 50 | 51 | @classmethod 52 | def from_string(cls, value): 53 | """Return single instance parsed from given accept header string.""" 54 | match = cls.pattern.search(value) 55 | if match is None: 56 | raise ValueError(f'"{value}" is not a valid media type') 57 | try: 58 | return cls(match.group("mime_type"), float(match.group("weight") or 1)) 59 | except ValueError: 60 | return cls(value) 61 | 62 | @classmethod 63 | def parse_header(cls, value="*/*"): 64 | """Parse HTTP accept header and return instances sorted by weight.""" 65 | yield from sorted( 66 | (cls.from_string(token.strip()) for token in value.split(",") if token.strip()), 67 | reverse=True, 68 | ) 69 | 70 | def __str__(self): 71 | return f"{self.mime_type}; q={self.weight}" 72 | 73 | def __repr__(self): 74 | return f"{type(self).__name__}: {self.__str__()}" 75 | 76 | def __eq__(self, other): 77 | return self.weight == other.weight and self.mime_type == other.mime_type 78 | 79 | def __lt__(self, other): 80 | return self.weight.__lt__(other.weight) 81 | 82 | 83 | @method_decorator(transaction.non_atomic_requests, name="dispatch") 84 | class MainView(CheckMixin, TemplateView): 85 | template_name = "health_check/index.html" 86 | 87 | @method_decorator(never_cache) 88 | def get(self, request, *args, **kwargs): 89 | subset = kwargs.get("subset") 90 | health_check_has_error = self.check(subset) 91 | status_code = 500 if health_check_has_error else 200 92 | format_override = request.GET.get("format") 93 | 94 | if format_override == "json": 95 | return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) 96 | 97 | accept_header = request.headers.get("accept", "*/*") 98 | for media in MediaType.parse_header(accept_header): 99 | if media.mime_type in ( 100 | "text/html", 101 | "application/xhtml+xml", 102 | "text/*", 103 | "*/*", 104 | ): 105 | context = self.get_context_data(**kwargs) 106 | return self.render_to_response(context, status=status_code) 107 | elif media.mime_type in ("application/json", "application/*"): 108 | return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) 109 | return HttpResponse( 110 | "Not Acceptable: Supported content types: text/html, application/json", 111 | status=406, 112 | content_type="text/plain", 113 | ) 114 | 115 | def get_context_data(self, **kwargs): 116 | subset = kwargs.get("subset") 117 | return { 118 | **super().get_context_data(**kwargs), 119 | "plugins": self.filter_plugins(subset=subset).values(), 120 | } 121 | 122 | def render_to_response_json(self, plugins, status): 123 | return JsonResponse( 124 | {str(plugin_identifier): str(p.pretty_status()) for plugin_identifier, p in plugins.items()}, 125 | status=status, 126 | ) 127 | -------------------------------------------------------------------------------- /tests/test_celery_ping.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from django.apps import apps 5 | from django.conf import settings 6 | 7 | from health_check.contrib.celery_ping.apps import HealthCheckConfig 8 | from health_check.contrib.celery_ping.backends import CeleryPingHealthCheck 9 | 10 | 11 | class TestCeleryPingHealthCheck: 12 | CELERY_APP_CONTROL_PING = "health_check.contrib.celery_ping.backends.app.control.ping" 13 | CELERY_APP_CONTROL_INSPECT_ACTIVE_QUEUES = ( 14 | "health_check.contrib.celery_ping.backends.app.control.inspect.active_queues" 15 | ) 16 | 17 | @pytest.fixture 18 | def health_check(self): 19 | return CeleryPingHealthCheck() 20 | 21 | def test_check_status_doesnt_add_errors_when_ping_successful(self, health_check): 22 | celery_worker = "celery@4cc150a7b49b" 23 | 24 | with ( 25 | patch( 26 | self.CELERY_APP_CONTROL_PING, 27 | return_value=[ 28 | {celery_worker: CeleryPingHealthCheck.CORRECT_PING_RESPONSE}, 29 | {f"{celery_worker}-2": CeleryPingHealthCheck.CORRECT_PING_RESPONSE}, 30 | ], 31 | ), 32 | patch( 33 | self.CELERY_APP_CONTROL_INSPECT_ACTIVE_QUEUES, 34 | return_value={celery_worker: [{"name": queue.name} for queue in settings.CELERY_QUEUES]}, 35 | ), 36 | ): 37 | health_check.check_status() 38 | 39 | assert not health_check.errors 40 | 41 | def test_check_status_reports_errors_if_ping_responses_are_incorrect(self, health_check): 42 | with patch( 43 | self.CELERY_APP_CONTROL_PING, 44 | return_value=[ 45 | {"celery1@4cc150a7b49b": CeleryPingHealthCheck.CORRECT_PING_RESPONSE}, 46 | {"celery2@4cc150a7b49b": {}}, 47 | {"celery3@4cc150a7b49b": {"error": "pong"}}, 48 | ], 49 | ): 50 | health_check.check_status() 51 | 52 | assert len(health_check.errors) == 2 53 | 54 | def test_check_status_adds_errors_when_ping_successfull_but_not_all_defined_queues_have_consumers( 55 | self, 56 | health_check, 57 | ): 58 | celery_worker = "celery@4cc150a7b49b" 59 | queues = list(settings.CELERY_QUEUES) 60 | 61 | with ( 62 | patch( 63 | self.CELERY_APP_CONTROL_PING, 64 | return_value=[{celery_worker: CeleryPingHealthCheck.CORRECT_PING_RESPONSE}], 65 | ), 66 | patch( 67 | self.CELERY_APP_CONTROL_INSPECT_ACTIVE_QUEUES, 68 | return_value={celery_worker: [{"name": queues.pop().name}]}, 69 | ), 70 | ): 71 | health_check.check_status() 72 | 73 | assert len(health_check.errors) == len(queues) 74 | 75 | @pytest.mark.parametrize( 76 | "exception_to_raise", 77 | [ 78 | IOError, 79 | TimeoutError, 80 | ], 81 | ) 82 | def test_check_status_add_error_when_io_error_raised_from_ping(self, exception_to_raise, health_check): 83 | with patch(self.CELERY_APP_CONTROL_PING, side_effect=exception_to_raise): 84 | health_check.check_status() 85 | 86 | assert len(health_check.errors) == 1 87 | assert "ioerror" in health_check.errors[0].message.lower() 88 | 89 | @pytest.mark.parametrize("exception_to_raise", [ValueError, SystemError, IndexError, MemoryError]) 90 | def test_check_status_add_error_when_any_exception_raised_from_ping(self, exception_to_raise, health_check): 91 | with patch(self.CELERY_APP_CONTROL_PING, side_effect=exception_to_raise): 92 | health_check.check_status() 93 | 94 | assert len(health_check.errors) == 1 95 | assert health_check.errors[0].message.lower() == "unknown error" 96 | 97 | def test_check_status_when_raised_exception_notimplementederror(self, health_check): 98 | expected_error_message = "notimplementederror: make sure celery_result_backend is set" 99 | 100 | with patch(self.CELERY_APP_CONTROL_PING, side_effect=NotImplementedError): 101 | health_check.check_status() 102 | 103 | assert len(health_check.errors) == 1 104 | assert health_check.errors[0].message.lower() == expected_error_message 105 | 106 | @pytest.mark.parametrize("ping_result", [None, list()]) 107 | def test_check_status_add_error_when_ping_result_failed(self, ping_result, health_check): 108 | with patch(self.CELERY_APP_CONTROL_PING, return_value=ping_result): 109 | health_check.check_status() 110 | 111 | assert len(health_check.errors) == 1 112 | assert "workers unavailable" in health_check.errors[0].message.lower() 113 | 114 | 115 | class TestCeleryPingHealthCheckApps: 116 | def test_apps(self): 117 | assert HealthCheckConfig.name == "health_check.contrib.celery_ping" 118 | 119 | celery_ping = apps.get_app_config("celery_ping") 120 | assert celery_ping.name == "health_check.contrib.celery_ping" 121 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from io import BytesIO 3 | from unittest import mock 4 | 5 | import django 6 | import pytest 7 | from django.core.files.base import File 8 | from django.core.files.storage import Storage 9 | 10 | from health_check.contrib.s3boto3_storage.backends import S3Boto3StorageHealthCheck 11 | from health_check.exceptions import ServiceUnavailable 12 | from health_check.storage.backends import ( 13 | DefaultFileStorageHealthCheck, 14 | StorageHealthCheck, 15 | ) 16 | 17 | 18 | class CustomStorage(Storage): 19 | pass 20 | 21 | 22 | class MockStorage(Storage): 23 | """ 24 | A Mock Storage backend used for testing. 25 | 26 | saves - Determines whether save will mock a successful or unsuccessful save 27 | deletes - Determines whether save will mock a successful or unsuccessful deletion. 28 | """ 29 | 30 | MOCK_FILE_COUNT = 0 31 | saves = None 32 | deletes = None 33 | 34 | def __init__(self, saves=True, deletes=True): 35 | super().__init__() 36 | self.MOCK_FILE_COUNT = 0 37 | self.saves = saves 38 | self.deletes = deletes 39 | 40 | def exists(self, file_name): 41 | return self.MOCK_FILE_COUNT != 0 42 | 43 | def delete(self, name): 44 | if self.deletes: 45 | self.MOCK_FILE_COUNT -= 1 46 | 47 | def save(self, name, content, max_length=None): 48 | if self.saves: 49 | self.MOCK_FILE_COUNT += 1 50 | 51 | 52 | # Mocking the S3Boto3Storage backend 53 | class MockS3Boto3Storage: 54 | """S3Boto3Storage backend mock to simulate interactions with AWS S3.""" 55 | 56 | def __init__(self, saves=True, deletes=True): 57 | self.saves = saves 58 | self.deletes = deletes 59 | self.files = {} 60 | 61 | def open(self, name, mode="rb"): 62 | """ 63 | Simulate file opening from the mocked S3 storage. 64 | 65 | For simplicity, this doesn't differentiate between read and write modes. 66 | """ 67 | if name in self.files: 68 | # Assuming file content is stored as bytes 69 | file_content = self.files[name] 70 | if isinstance(file_content, bytes): 71 | return File(BytesIO(file_content)) 72 | else: 73 | raise ValueError("File content must be bytes.") 74 | else: 75 | raise FileNotFoundError(f"The file {name} does not exist.") 76 | 77 | def save(self, name, content): 78 | """ 79 | Ensure content is stored as bytes in a way compatible with open method. 80 | 81 | Assumes content is either a ContentFile, bytes, or a string that needs conversion. 82 | """ 83 | if self.saves: 84 | # Check if content is a ContentFile or similar and read bytes 85 | if hasattr(content, "read"): 86 | file_content = content.read() 87 | elif isinstance(content, bytes): 88 | file_content = content 89 | elif isinstance(content, str): 90 | file_content = content.encode() # Convert string to bytes 91 | else: 92 | raise ValueError("Unsupported file content type.") 93 | 94 | self.files[name] = file_content 95 | return name 96 | raise Exception("Failed to save file.") 97 | 98 | def delete(self, name): 99 | if self.deletes: 100 | self.files.pop(name, None) 101 | else: 102 | raise Exception("Failed to delete file.") 103 | 104 | def exists(self, name): 105 | return name in self.files 106 | 107 | 108 | def get_file_name(*args, **kwargs): 109 | return "mockfile.txt" 110 | 111 | 112 | def get_file_content(*args, **kwargs): 113 | return b"mockcontent" 114 | 115 | 116 | @mock.patch("health_check.storage.backends.StorageHealthCheck.get_file_name", get_file_name) 117 | @mock.patch( 118 | "health_check.storage.backends.StorageHealthCheck.get_file_content", 119 | get_file_content, 120 | ) 121 | class TestHealthCheckStorage: 122 | """ 123 | Tests health check behavior with a mocked storage backend. 124 | 125 | Ensures check_status returns/raises the expected result when the storage works or raises exceptions. 126 | """ 127 | 128 | def test_get_storage(self): 129 | """Test get_storage method returns None on the base class, but a Storage instance on default.""" 130 | base_storage = StorageHealthCheck() 131 | assert base_storage.get_storage() is None 132 | 133 | default_storage = DefaultFileStorageHealthCheck() 134 | assert isinstance(default_storage.get_storage(), Storage) 135 | 136 | @unittest.skipUnless((4, 2) <= django.VERSION < (5, 0), "Only for Django 4.2 - 5.0") 137 | def test_get_storage_django_between_42_and_50(self, settings): 138 | """Check that the old DEFAULT_FILE_STORAGE setting keeps being supported.""" 139 | # Note: this test doesn't work on Django<4.2 because the setting value is 140 | # evaluated when the class attribute DefaultFileStorageHealthCheck.store is 141 | # read, which is at import time, before we can mock the setting. 142 | settings.DEFAULT_FILE_STORAGE = "tests.test_storage.CustomStorage" 143 | default_storage = DefaultFileStorageHealthCheck() 144 | assert isinstance(default_storage.get_storage(), CustomStorage) 145 | 146 | def test_get_storage_django_42_plus(self, settings): 147 | """Check that the new STORAGES setting is supported.""" 148 | settings.STORAGES = {"default": {"BACKEND": "tests.test_storage.CustomStorage"}} 149 | default_storage = DefaultFileStorageHealthCheck() 150 | assert isinstance(default_storage.get_storage(), CustomStorage) 151 | 152 | @mock.patch( 153 | "health_check.storage.backends.DefaultFileStorageHealthCheck.storage", 154 | MockStorage(), 155 | ) 156 | def test_check_status_working(self): 157 | """Test check_status returns True when storage is working properly.""" 158 | default_storage_health = DefaultFileStorageHealthCheck() 159 | 160 | default_storage = default_storage_health.get_storage() 161 | 162 | default_storage_open = f"{default_storage.__module__}.{default_storage.__class__.__name__}.open" 163 | 164 | with mock.patch( 165 | default_storage_open, 166 | mock.mock_open(read_data=default_storage_health.get_file_content()), 167 | ): 168 | assert default_storage_health.check_status() 169 | 170 | @mock.patch( 171 | "health_check.storage.backends.storages", 172 | {"default": MockStorage(saves=False)}, 173 | ) 174 | def test_file_does_not_exist(self): 175 | """Test check_status raises ServiceUnavailable when file is not saved.""" 176 | default_storage_health = DefaultFileStorageHealthCheck() 177 | with pytest.raises(ServiceUnavailable): 178 | default_storage_health.check_status() 179 | 180 | @mock.patch( 181 | "health_check.storage.backends.storages", 182 | {"default": MockStorage(deletes=False)}, 183 | ) 184 | def test_file_not_deleted(self): 185 | """Test check_status raises ServiceUnavailable when file is not deleted.""" 186 | default_storage_health = DefaultFileStorageHealthCheck() 187 | with pytest.raises(ServiceUnavailable): 188 | default_storage_health.check_status() 189 | 190 | 191 | @mock.patch("storages.backends.s3boto3.S3Boto3Storage", new=MockS3Boto3Storage) 192 | @pytest.mark.django_db 193 | class TestHealthCheckS3Boto3Storage: 194 | """Tests health check behavior with a mocked S3Boto3Storage backend.""" 195 | 196 | def test_check_delete_success(self, settings): 197 | """Test that check_delete correctly deletes a file when S3Boto3Storage is working.""" 198 | settings.STORAGES = { 199 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 200 | } 201 | health_check = S3Boto3StorageHealthCheck() 202 | mock_storage = health_check.get_storage() 203 | file_name = "testfile.txt" 204 | content = BytesIO(b"Test content") 205 | mock_storage.save(file_name, content) 206 | 207 | health_check.check_delete(file_name) 208 | assert not mock_storage.exists(file_name) 209 | 210 | def test_check_delete_failure(self, settings): 211 | """Test that check_delete raises ServiceUnavailable when deletion fails.""" 212 | settings.STORAGES = { 213 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 214 | } 215 | with mock.patch.object( 216 | MockS3Boto3Storage, 217 | "delete", 218 | side_effect=Exception("Failed to delete file."), 219 | ): 220 | health_check = S3Boto3StorageHealthCheck() 221 | with pytest.raises(ServiceUnavailable): 222 | health_check.check_delete("testfile.txt") 223 | 224 | def test_check_status_working(self, settings): 225 | """Test check_status returns True when S3Boto3Storage can save and delete files.""" 226 | settings.STORAGES = { 227 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 228 | } 229 | health_check = S3Boto3StorageHealthCheck() 230 | assert health_check.check_status() 231 | 232 | def test_check_status_failure_on_save(self, settings): 233 | """Test check_status raises ServiceUnavailable when file cannot be saved.""" 234 | settings.STORAGES = { 235 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 236 | } 237 | with mock.patch.object(MockS3Boto3Storage, "save", side_effect=Exception("Failed to save file.")): 238 | health_check = S3Boto3StorageHealthCheck() 239 | with pytest.raises(ServiceUnavailable): 240 | health_check.check_status() 241 | 242 | def test_check_status_failure_on_delete(self, settings): 243 | """Test check_status raises ServiceUnavailable when file cannot be deleted.""" 244 | settings.STORAGES = { 245 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 246 | } 247 | with mock.patch.object(MockS3Boto3Storage, "exists", new_callable=mock.PropertyMock) as mock_exists: 248 | mock_exists.return_value = False 249 | health_check = S3Boto3StorageHealthCheck() 250 | with pytest.raises(ServiceUnavailable): 251 | health_check.check_status() 252 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from django.db import DatabaseError 6 | from django.urls import reverse 7 | 8 | from health_check.backends import BaseHealthCheckBackend 9 | from health_check.conf import HEALTH_CHECK 10 | from health_check.exceptions import ServiceWarning 11 | from health_check.plugins import plugin_dir 12 | from health_check.views import MediaType 13 | 14 | 15 | class TestMediaType: 16 | def test_lt(self): 17 | assert not MediaType("*/*") < MediaType("*/*") 18 | assert not MediaType("*/*") < MediaType("*/*", 0.9) 19 | assert MediaType("*/*", 0.9) < MediaType("*/*") 20 | 21 | def test_str(self): 22 | assert str(MediaType("*/*")) == "*/*; q=1.0" 23 | assert str(MediaType("image/*", 0.6)) == "image/*; q=0.6" 24 | 25 | def test_repr(self): 26 | assert repr(MediaType("*/*")) == "MediaType: */*; q=1.0" 27 | 28 | def test_eq(self): 29 | assert MediaType("*/*") == MediaType("*/*") 30 | assert MediaType("*/*", 0.9) != MediaType("*/*") 31 | 32 | valid_strings = [ 33 | ("*/*", MediaType("*/*")), 34 | ("*/*; q=0.9", MediaType("*/*", 0.9)), 35 | ("*/*; q=0", MediaType("*/*", 0.0)), 36 | ("*/*; q=0.0", MediaType("*/*", 0.0)), 37 | ("*/*; q=0.1", MediaType("*/*", 0.1)), 38 | ("*/*; q=0.12", MediaType("*/*", 0.12)), 39 | ("*/*; q=0.123", MediaType("*/*", 0.123)), 40 | ("*/*; q=1.000", MediaType("*/*", 1.0)), 41 | ("*/*; q=1", MediaType("*/*", 1.0)), 42 | ("*/*;q=0.9", MediaType("*/*", 0.9)), 43 | ("*/* ;q=0.9", MediaType("*/*", 0.9)), 44 | ("*/* ; q=0.9", MediaType("*/*", 0.9)), 45 | ("*/* ; q=0.9", MediaType("*/*", 0.9)), 46 | ("*/*;v=b3", MediaType("*/*")), 47 | ("*/*; q=0.5; v=b3", MediaType("*/*", 0.5)), 48 | ] 49 | 50 | @pytest.mark.parametrize("type, expected", valid_strings) 51 | def test_from_valid_strings(self, type, expected): 52 | assert MediaType.from_string(type) == expected 53 | 54 | invalid_strings = [ 55 | "*/*;0.9", 56 | 'text/html;z=""', 57 | "text/html; xxx", 58 | "text/html; =a", 59 | ] 60 | 61 | @pytest.mark.parametrize("type", invalid_strings) 62 | def test_from_invalid_strings(self, type): 63 | with pytest.raises(ValueError) as e: 64 | MediaType.from_string(type) 65 | expected_error = f'"{type}" is not a valid media type' 66 | assert expected_error in str(e.value) 67 | 68 | def test_parse_header(self): 69 | assert list(MediaType.parse_header()) == [ 70 | MediaType("*/*"), 71 | ] 72 | assert list(MediaType.parse_header("text/html; q=0.1, application/xhtml+xml; q=0.1 ,application/json")) == [ 73 | MediaType("application/json"), 74 | MediaType("text/html", 0.1), 75 | MediaType("application/xhtml+xml", 0.1), 76 | ] 77 | 78 | 79 | class TestMainView: 80 | url = reverse("health_check:health_check_home") 81 | 82 | def test_success(self, client): 83 | response = client.get(self.url) 84 | assert response.status_code == 200, response.content.decode("utf-8") 85 | assert response["content-type"] == "text/html; charset=utf-8" 86 | 87 | def test_error(self, client): 88 | class MyBackend(BaseHealthCheckBackend): 89 | def check_status(self): 90 | self.add_error("Super Fail!") 91 | 92 | plugin_dir.reset() 93 | plugin_dir.register(MyBackend) 94 | response = client.get(self.url) 95 | assert response.status_code == 500, response.content.decode("utf-8") 96 | assert response["content-type"] == "text/html; charset=utf-8" 97 | assert b"Super Fail!" in response.content 98 | 99 | def test_warning(self, client): 100 | class MyBackend(BaseHealthCheckBackend): 101 | def check_status(self): 102 | raise ServiceWarning("so so") 103 | 104 | plugin_dir.reset() 105 | plugin_dir.register(MyBackend) 106 | response = client.get(self.url) 107 | assert response.status_code == 500, response.content.decode("utf-8") 108 | assert b"so so" in response.content, response.content 109 | 110 | HEALTH_CHECK["WARNINGS_AS_ERRORS"] = False 111 | 112 | response = client.get(self.url) 113 | assert response.status_code == 200, response.content.decode("utf-8") 114 | assert response["content-type"] == "text/html; charset=utf-8" 115 | assert b"so so" in response.content, response.content 116 | 117 | def test_non_critical(self, client): 118 | class MyBackend(BaseHealthCheckBackend): 119 | critical_service = False 120 | 121 | def check_status(self): 122 | self.add_error("Super Fail!") 123 | 124 | plugin_dir.reset() 125 | plugin_dir.register(MyBackend) 126 | response = client.get(self.url) 127 | assert response.status_code == 200, response.content.decode("utf-8") 128 | assert response["content-type"] == "text/html; charset=utf-8" 129 | assert b"Super Fail!" in response.content 130 | 131 | def test_success_accept_json(self, client): 132 | class JSONSuccessBackend(BaseHealthCheckBackend): 133 | def run_check(self): 134 | pass 135 | 136 | plugin_dir.reset() 137 | plugin_dir.register(JSONSuccessBackend) 138 | response = client.get(self.url, HTTP_ACCEPT="application/json") 139 | assert response["content-type"] == "application/json" 140 | assert response.status_code == 200 141 | 142 | def test_success_prefer_json(self, client): 143 | class JSONSuccessBackend(BaseHealthCheckBackend): 144 | def run_check(self): 145 | pass 146 | 147 | plugin_dir.reset() 148 | plugin_dir.register(JSONSuccessBackend) 149 | response = client.get(self.url, HTTP_ACCEPT="application/json; q=0.8, text/html; q=0.5") 150 | assert response["content-type"] == "application/json" 151 | assert response.status_code == 200 152 | 153 | def test_success_accept_xhtml(self, client): 154 | class SuccessBackend(BaseHealthCheckBackend): 155 | def run_check(self): 156 | pass 157 | 158 | plugin_dir.reset() 159 | plugin_dir.register(SuccessBackend) 160 | response = client.get(self.url, HTTP_ACCEPT="application/xhtml+xml") 161 | assert response["content-type"] == "text/html; charset=utf-8" 162 | assert response.status_code == 200 163 | 164 | def test_success_unsupported_accept(self, client): 165 | class SuccessBackend(BaseHealthCheckBackend): 166 | def run_check(self): 167 | pass 168 | 169 | plugin_dir.reset() 170 | plugin_dir.register(SuccessBackend) 171 | response = client.get(self.url, HTTP_ACCEPT="application/octet-stream") 172 | assert response["content-type"] == "text/plain" 173 | assert response.status_code == 406 174 | assert response.content == b"Not Acceptable: Supported content types: text/html, application/json" 175 | 176 | def test_success_unsupported_and_supported_accept(self, client): 177 | class SuccessBackend(BaseHealthCheckBackend): 178 | def run_check(self): 179 | pass 180 | 181 | plugin_dir.reset() 182 | plugin_dir.register(SuccessBackend) 183 | response = client.get(self.url, HTTP_ACCEPT="application/octet-stream, application/json; q=0.9") 184 | assert response["content-type"] == "application/json" 185 | assert response.status_code == 200 186 | 187 | def test_success_accept_order(self, client): 188 | class JSONSuccessBackend(BaseHealthCheckBackend): 189 | def run_check(self): 190 | pass 191 | 192 | plugin_dir.reset() 193 | plugin_dir.register(JSONSuccessBackend) 194 | response = client.get( 195 | self.url, 196 | HTTP_ACCEPT="text/html, application/xhtml+xml, application/json; q=0.9, */*; q=0.1", 197 | ) 198 | assert response["content-type"] == "text/html; charset=utf-8" 199 | assert response.status_code == 200 200 | 201 | def test_success_accept_order__reverse(self, client): 202 | class JSONSuccessBackend(BaseHealthCheckBackend): 203 | def run_check(self): 204 | pass 205 | 206 | plugin_dir.reset() 207 | plugin_dir.register(JSONSuccessBackend) 208 | response = client.get( 209 | self.url, 210 | HTTP_ACCEPT="text/html; q=0.1, application/xhtml+xml; q=0.1, application/json", 211 | ) 212 | assert response["content-type"] == "application/json" 213 | assert response.status_code == 200 214 | 215 | def test_format_override(self, client): 216 | class JSONSuccessBackend(BaseHealthCheckBackend): 217 | def run_check(self): 218 | pass 219 | 220 | plugin_dir.reset() 221 | plugin_dir.register(JSONSuccessBackend) 222 | response = client.get(self.url + "?format=json", HTTP_ACCEPT="text/html") 223 | assert response["content-type"] == "application/json" 224 | assert response.status_code == 200 225 | 226 | def test_format_no_accept_header(self, client): 227 | class JSONSuccessBackend(BaseHealthCheckBackend): 228 | def run_check(self): 229 | pass 230 | 231 | plugin_dir.reset() 232 | plugin_dir.register(JSONSuccessBackend) 233 | response = client.get(self.url) 234 | assert response.status_code == 200, response.content.decode("utf-8") 235 | assert response["content-type"] == "text/html; charset=utf-8" 236 | 237 | def test_error_accept_json(self, client): 238 | class JSONErrorBackend(BaseHealthCheckBackend): 239 | def run_check(self): 240 | self.add_error("JSON Error") 241 | 242 | plugin_dir.reset() 243 | plugin_dir.register(JSONErrorBackend) 244 | response = client.get(self.url, HTTP_ACCEPT="application/json") 245 | assert response.status_code == 500, response.content.decode("utf-8") 246 | assert response["content-type"] == "application/json" 247 | assert "JSON Error" in json.loads(response.content.decode("utf-8"))[JSONErrorBackend().identifier()] 248 | 249 | def test_success_param_json(self, client): 250 | class JSONSuccessBackend(BaseHealthCheckBackend): 251 | def run_check(self): 252 | pass 253 | 254 | plugin_dir.reset() 255 | plugin_dir.register(JSONSuccessBackend) 256 | response = client.get(self.url, {"format": "json"}) 257 | assert response.status_code == 200, response.content.decode("utf-8") 258 | assert response["content-type"] == "application/json" 259 | assert json.loads(response.content.decode("utf-8")) == { 260 | JSONSuccessBackend().identifier(): JSONSuccessBackend().pretty_status() 261 | } 262 | 263 | def test_success_subset_define(self, client): 264 | class SuccessOneBackend(BaseHealthCheckBackend): 265 | def run_check(self): 266 | pass 267 | 268 | class SuccessTwoBackend(BaseHealthCheckBackend): 269 | def run_check(self): 270 | pass 271 | 272 | plugin_dir.reset() 273 | plugin_dir.register(SuccessOneBackend) 274 | plugin_dir.register(SuccessTwoBackend) 275 | 276 | HEALTH_CHECK["SUBSETS"] = { 277 | "startup-probe": ["SuccessOneBackend", "SuccessTwoBackend"], 278 | "liveness-probe": ["SuccessTwoBackend"], 279 | } 280 | 281 | response_startup_probe = client.get(self.url + "startup-probe/", {"format": "json"}) 282 | assert response_startup_probe.status_code == 200, response_startup_probe.content.decode("utf-8") 283 | assert response_startup_probe["content-type"] == "application/json" 284 | assert json.loads(response_startup_probe.content.decode("utf-8")) == { 285 | SuccessOneBackend().identifier(): SuccessOneBackend().pretty_status(), 286 | SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), 287 | } 288 | 289 | response_liveness_probe = client.get(self.url + "liveness-probe/", {"format": "json"}) 290 | assert response_liveness_probe.status_code == 200, response_liveness_probe.content.decode("utf-8") 291 | assert response_liveness_probe["content-type"] == "application/json" 292 | assert json.loads(response_liveness_probe.content.decode("utf-8")) == { 293 | SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), 294 | } 295 | 296 | def test_error_subset_not_found(self, client): 297 | plugin_dir.reset() 298 | response = client.get(self.url + "liveness-probe/", {"format": "json"}) 299 | print(f"content: {response.content}") 300 | print(f"code: {response.status_code}") 301 | assert response.status_code == 404, response.content.decode("utf-8") 302 | 303 | def test_error_param_json(self, client): 304 | class JSONErrorBackend(BaseHealthCheckBackend): 305 | def run_check(self): 306 | self.add_error("JSON Error") 307 | 308 | plugin_dir.reset() 309 | plugin_dir.register(JSONErrorBackend) 310 | response = client.get(self.url, {"format": "json"}) 311 | assert response.status_code == 500, response.content.decode("utf-8") 312 | assert response["content-type"] == "application/json" 313 | assert "JSON Error" in json.loads(response.content.decode("utf-8"))[JSONErrorBackend().identifier()] 314 | 315 | @pytest.mark.django_db(transaction=True) 316 | def test_non_native_atomic_request(self, settings, monkeypatch, client): 317 | # See also: https://github.com/codingjoe/django-health-check/pull/469 318 | settings.DATABASES["default"]["ATOMIC_REQUESTS"] = True 319 | # disable the ensure_connection 320 | monkeypatch.setattr( 321 | "django.db.backends.base.base.BaseDatabaseWrapper.ensure_connection", Mock(side_effect=DatabaseError()) 322 | ) 323 | response = client.get(self.url) 324 | assert response.status_code == 500 325 | assert b"System status" in response.content 326 | --------------------------------------------------------------------------------