├── .bandit ├── .editorconfig ├── .git-blame-ignore-revs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── README.md ├── docs ├── changelog.rst ├── conf.py ├── contrib.rst ├── index.rst ├── readme.rst └── settings.rst ├── health_check ├── __init__.py ├── backends.py ├── cache │ ├── __init__.py │ ├── apps.py │ └── backends.py ├── conf.py ├── contrib │ ├── __init__.py │ ├── celery │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── backends.py │ │ └── tasks.py │ ├── celery_ping │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── db_heartbeat │ │ ├── __init__.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 │ ├── redis │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ ├── s3boto3_storage │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py │ └── s3boto_storage │ │ ├── __init__.py │ │ ├── apps.py │ │ └── backends.py ├── db │ ├── __init__.py │ ├── apps.py │ ├── backends.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ └── models.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── health_check.py ├── mixins.py ├── plugins.py ├── storage │ ├── __init__.py │ ├── apps.py │ └── backends.py ├── templates │ └── health_check │ │ └── index.html ├── urls.py └── views.py ├── pyproject.toml └── tests ├── __init__.py ├── test_autodiscover.py ├── test_backends.py ├── test_cache.py ├── test_celery_ping.py ├── test_commands.py ├── test_db.py ├── test_db_heartbeat.py ├── test_migrations.py ├── test_mixins.py ├── test_plugins.py ├── test_rabbitmq.py ├── test_redis.py ├── test_storage.py ├── test_views.py └── testapp ├── __init__.py ├── celery.py ├── manage.py ├── settings.py └── urls.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: tests 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Ruff format 2 | b24886fa7f02425fda25dd5c2f987b01a4127d03qq 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | jobs: 9 | 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | lint-command: 15 | - "ruff format --check --diff ." 16 | - "ruff check --output-format=github ." 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | cache: 'pip' 23 | cache-dependency-path: '**/pyproject.toml' 24 | - run: python -m pip install -e .[lint] 25 | - run: ${{ matrix.lint-command }} 26 | 27 | dist: 28 | runs-on: ubuntu-latest 29 | needs: [lint] 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: "3.x" 35 | cache: 'pip' 36 | cache-dependency-path: '**/pyproject.toml' 37 | - run: python -m pip install --upgrade pip build twine 38 | - run: python -m build --sdist --wheel 39 | - run: python -m twine check dist/* 40 | 41 | 42 | docs: 43 | runs-on: ubuntu-latest 44 | needs: [lint] 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: setup Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: "3.10" 51 | cache: 'pip' 52 | cache-dependency-path: '**/pyproject.toml' 53 | - run: python -m pip install -e .[docs] 54 | - run: python -m sphinx -b html -W docs docs/_build 55 | 56 | PyTest: 57 | runs-on: ubuntu-latest 58 | needs: [lint] 59 | strategy: 60 | matrix: 61 | python-version: 62 | - "3.9" 63 | - "3.10" 64 | - "3.11" 65 | - "3.12" 66 | - "3.13" 67 | django-version: 68 | - "4.2" 69 | - "5.2" 70 | exclude: 71 | - python-version: "3.9" 72 | django-version: "5.2" 73 | steps: 74 | - uses: actions/checkout@v3 75 | - name: Setup Python version ${{ matrix.python-version }} 76 | uses: actions/setup-python@v5 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | cache: 'pip' 80 | cache-dependency-path: '**/pyproject.toml' 81 | - run: python -m pip install .[test] 82 | - run: python -m pip install Django~="${{ matrix.django-version }}.0" 83 | - run: python -m pytest 84 | - uses: codecov/codecov-action@v3 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | id-token: write 10 | 11 | jobs: 12 | 13 | release-build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | - run: python -m pip install --upgrade pip build 22 | - run: python -m build --sdist --wheel 23 | - uses: actions/upload-artifact@v4 24 | with: 25 | name: release-dists 26 | path: dist/ 27 | 28 | pypi-publish: 29 | runs-on: ubuntu-latest 30 | needs: 31 | - release-build 32 | permissions: 33 | id-token: write 34 | 35 | steps: 36 | - uses: actions/download-artifact@v4 37 | with: 38 | name: release-dists 39 | path: dist/ 40 | - uses: pypa/gh-action-pypi-publish@release/v1 41 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-20.04 9 | tools: 10 | python: "3.10" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | 16 | python: 17 | install: 18 | - method: pip 19 | path: . 20 | extra_requirements: 21 | - docs 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2011-2019 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-health-check 2 | 3 | [![version](https://img.shields.io/pypi/v/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 4 | [![pyversion](https://img.shields.io/pypi/pyversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 5 | [![djversion](https://img.shields.io/pypi/djversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) 6 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/django-health-check/) 7 | 8 | 9 | This project checks for various conditions and provides reports when anomalous 10 | behavior is detected. 11 | 12 | The following health checks are bundled with this project: 13 | 14 | - cache 15 | - database 16 | - storage 17 | - disk and memory utilization (via `psutil`) 18 | - AWS S3 storage 19 | - Celery task queue 20 | - Celery ping 21 | - RabbitMQ 22 | - Migrations 23 | - Database Heartbeat (Lightweight version of `health_check.db`) 24 | 25 | Writing your own custom health checks is also very quick and easy. 26 | 27 | We also like contributions, so don't be afraid to make a pull request. 28 | 29 | ## Use Cases 30 | 31 | The primary intended use case is to monitor conditions via HTTP(S), with 32 | responses available in HTML and JSON formats. When you get back a response that 33 | includes one or more problems, you can then decide the appropriate course of 34 | action, which could include generating notifications and/or automating the 35 | replacement of a failing node with a new one. If you are monitoring health in a 36 | high-availability environment with a load balancer that returns responses from 37 | multiple nodes, please note that certain checks (e.g., disk and memory usage) 38 | will return responses specific to the node selected by the load balancer. 39 | 40 | ## Supported Versions 41 | 42 | We officially only support the latest version of Python as well as the 43 | latest version of Django and the latest Django LTS version. 44 | 45 | ## Installation 46 | 47 | First, install the `django-health-check` package: 48 | 49 | ```shell 50 | $ pip install django-health-check 51 | ``` 52 | 53 | Add the health checker to a URL you want to use: 54 | 55 | ```python 56 | urlpatterns = [ 57 | # ... 58 | path(r'ht/', include('health_check.urls')), 59 | ] 60 | ``` 61 | 62 | Add the `health_check` applications to your `INSTALLED_APPS`: 63 | 64 | ```python 65 | INSTALLED_APPS = [ 66 | # ... 67 | 'health_check', # required 68 | 'health_check.db', # stock Django health checkers 69 | 'health_check.cache', 70 | 'health_check.storage', 71 | 'health_check.contrib.migrations', 72 | 'health_check.contrib.celery', # requires celery 73 | 'health_check.contrib.celery_ping', # requires celery 74 | 'health_check.contrib.psutil', # disk and memory utilization; requires psutil 75 | 'health_check.contrib.s3boto3_storage', # requires boto3 and S3BotoStorage backend 76 | 'health_check.contrib.rabbitmq', # requires RabbitMQ broker 77 | 'health_check.contrib.redis', # requires Redis broker 78 | 'health_check.contrib.db_heartbeat', 79 | ] 80 | ``` 81 | 82 | **Note:** If using `boto 2.x.x` use `health_check.contrib.s3boto_storage` 83 | 84 | (Optional) If using the `psutil` app, you can configure disk and memory 85 | threshold settings; otherwise below defaults are assumed. If you want to disable 86 | one of these checks, set its value to `None`. 87 | 88 | ```python 89 | HEALTH_CHECK = { 90 | 'DISK_USAGE_MAX': 90, # percent 91 | 'MEMORY_MIN': 100, # in MB 92 | } 93 | ``` 94 | 95 | To use Health Check Subsets, Specify a subset name and associate it with the relevant health check services to utilize Health Check Subsets. (New in version 3.18.0) 96 | ```python 97 | HEALTH_CHECK = { 98 | # ..... 99 | "SUBSETS": { 100 | "startup-probe": ["MigrationsHealthCheck", "DatabaseBackend"], 101 | "liveness-probe": ["DatabaseBackend"], 102 | "": [""] 103 | }, 104 | # ..... 105 | } 106 | ``` 107 | 108 | To only execute specific subset of health check 109 | ```shell 110 | curl -X GET -H "Accept: application/json" http://www.example.com/ht/startup-probe/ 111 | ``` 112 | 113 | If using the DB check, run migrations: 114 | 115 | ```shell 116 | $ django-admin migrate 117 | ``` 118 | 119 | To use the RabbitMQ healthcheck, please make sure that there is a variable named 120 | `BROKER_URL` on django.conf.settings with the required format to connect to your 121 | rabbit server. For example: 122 | 123 | ```python 124 | BROKER_URL = "amqp://myuser:mypassword@localhost:5672/myvhost" 125 | ``` 126 | 127 | To use the Redis healthcheck, please make sure that there is a variable named ``REDIS_URL`` 128 | on django.conf.settings with the required format to connect to your redis server. For example: 129 | 130 | ```python 131 | REDIS_URL = "redis://localhost:6370" 132 | ``` 133 | 134 | The cache healthcheck tries to write and read a specific key within the cache backend. 135 | It can be customized by setting `HEALTHCHECK_CACHE_KEY` to another value: 136 | 137 | ```python 138 | HEALTHCHECK_CACHE_KEY = "custom_healthcheck_key" 139 | ``` 140 | 141 | Additional connection options may be specified by defining a variable ``HEALTHCHECK_REDIS_URL_OPTIONS`` on the settings module. 142 | 143 | ## Setting up monitoring 144 | 145 | You can use tools like Pingdom, StatusCake or other uptime robots to monitor service status. 146 | The `/ht/` endpoint will respond with an HTTP 200 if all checks passed 147 | and with an HTTP 500 if any of the tests failed. 148 | Getting machine-readable JSON reports 149 | 150 | If you want machine-readable status reports you can request the `/ht/` 151 | endpoint with the `Accept` HTTP header set to `application/json` 152 | or pass `format=json` as a query parameter. 153 | 154 | The backend will return a JSON response: 155 | 156 | ```shell 157 | $ curl -v -X GET -H "Accept: application/json" http://www.example.com/ht/ 158 | 159 | > GET /ht/ HTTP/1.1 160 | > Host: www.example.com 161 | > Accept: application/json 162 | > 163 | < HTTP/1.1 200 OK 164 | < Content-Type: application/json 165 | 166 | { 167 | "CacheBackend": "working", 168 | "DatabaseBackend": "working", 169 | "S3BotoStorageHealthCheck": "working" 170 | } 171 | 172 | $ curl -v -X GET http://www.example.com/ht/?format=json 173 | 174 | > GET /ht/?format=json HTTP/1.1 175 | > Host: www.example.com 176 | > 177 | < HTTP/1.1 200 OK 178 | < Content-Type: application/json 179 | 180 | { 181 | "CacheBackend": "working", 182 | "DatabaseBackend": "working", 183 | "S3BotoStorageHealthCheck": "working" 184 | } 185 | ``` 186 | 187 | ## Writing a custom health check 188 | 189 | Writing a health check is quick and easy: 190 | 191 | ```python 192 | from health_check.backends import BaseHealthCheckBackend 193 | 194 | class MyHealthCheckBackend(BaseHealthCheckBackend): 195 | #: The status endpoints will respond with a 200 status code 196 | #: even if the check errors. 197 | critical_service = False 198 | 199 | def check_status(self): 200 | # The test code goes here. 201 | # You can use `self.add_error` or 202 | # raise a `HealthCheckException`, 203 | # similar to Django's form validation. 204 | pass 205 | 206 | def identifier(self): 207 | return self.__class__.__name__ # Display name on the endpoint. 208 | ``` 209 | 210 | After writing a custom checker, register it in your app configuration: 211 | 212 | ```python 213 | from django.apps import AppConfig 214 | 215 | from health_check.plugins import plugin_dir 216 | 217 | class MyAppConfig(AppConfig): 218 | name = 'my_app' 219 | 220 | def ready(self): 221 | from .backends import MyHealthCheckBackend 222 | plugin_dir.register(MyHealthCheckBackend) 223 | ``` 224 | 225 | Make sure the application you write the checker into is registered in your 226 | `INSTALLED_APPS`. 227 | 228 | ## Customizing output 229 | 230 | You can customize HTML or JSON rendering by inheriting from `MainView` in 231 | `health_check.views` and customizing the `template_name`, `get`, `render_to_response` 232 | and `render_to_response_json` properties: 233 | 234 | ```python 235 | # views.py 236 | from health_check.views import MainView 237 | 238 | class HealthCheckCustomView(MainView): 239 | template_name = 'myapp/health_check_dashboard.html' # customize the used templates 240 | 241 | def get(self, request, *args, **kwargs): 242 | plugins = [] 243 | status = 200 # needs to be filled status you need 244 | # ... 245 | if 'application/json' in request.META.get('HTTP_ACCEPT', ''): 246 | return self.render_to_response_json(plugins, status) 247 | return self.render_to_response(plugins, status) 248 | 249 | def render_to_response(self, plugins, status): # customize HTML output 250 | return HttpResponse('COOL' if status == 200 else 'SWEATY', status=status) 251 | 252 | def render_to_response_json(self, plugins, status): # customize JSON output 253 | return JsonResponse( 254 | {str(p.identifier()): 'COOL' if status == 200 else 'SWEATY' for p in plugins}, 255 | status=status 256 | ) 257 | 258 | # urls.py 259 | import views 260 | 261 | urlpatterns = [ 262 | # ... 263 | path(r'ht/', views.HealthCheckCustomView.as_view(), name='health_check_custom'), 264 | ] 265 | ``` 266 | 267 | ## Django command 268 | 269 | You can run the Django command `health_check` to perform your health checks via the command line, 270 | or periodically with a cron, as follow: 271 | 272 | ```shell 273 | django-admin health_check 274 | ``` 275 | 276 | This should yield the following output: 277 | 278 | ``` 279 | DatabaseHealthCheck ... working 280 | CustomHealthCheck ... unavailable: Something went wrong! 281 | ``` 282 | 283 | Similar to the http version, a critical error will cause the command to quit with the exit code `1`. 284 | 285 | 286 | ## Other resources 287 | 288 | - [django-watchman](https://github.com/mwarkentin/django-watchman) is a package that does some of the same things in a slightly different way. 289 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ChangeLog 2 | ========= 3 | 4 | This package is released on GitHub. Please refer to the GitHub 5 | release page to review the changes in each version. 6 | 7 | https://github.com/revsys/django-health-check/releases 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | try: 2 | import sphinx_rtd_theme 3 | except ImportError: 4 | sphinx_rtd_theme = None 5 | 6 | master_doc = "index" 7 | 8 | if sphinx_rtd_theme: 9 | html_theme = "sphinx_rtd_theme" 10 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 11 | -------------------------------------------------------------------------------- /docs/contrib.rst: -------------------------------------------------------------------------------- 1 | contrib 2 | ======= 3 | 4 | `psutil` 5 | -------- 6 | 7 | Full disks and out-of-memory conditions are common causes of service outages. 8 | These situations can be averted by checking disk and memory utilization via the 9 | `psutil` package: 10 | 11 | .. code:: 12 | 13 | pip install psutil 14 | 15 | Once that dependency has been installed, make sure that the corresponding Django 16 | app has been added to `INSTALLED_APPS`: 17 | 18 | .. code:: python 19 | 20 | INSTALLED_APPS = [ 21 | # ... 22 | 'health_check', # required 23 | 'health_check.contrib.psutil', # disk and memory utilization; requires psutil 24 | # ... 25 | ] 26 | 27 | The following default settings will be used to check for disk and memory 28 | utilization. If you would prefer different thresholds, you can add the dictionary 29 | below to your Django settings file and adjust the values accordingly. If you want 30 | to disable any of these checks, set its value to ``None``. 31 | 32 | .. code:: python 33 | 34 | HEALTH_CHECK = { 35 | 'DISK_USAGE_MAX': 90, # percent 36 | 'MEMORY_MIN' = 100, # in MB 37 | } 38 | 39 | `celery` 40 | -------- 41 | 42 | If you are using Celery you may choose between two different Celery checks. 43 | 44 | `health_check.contrib.celery` sends a task to the queue and it expects that task 45 | to be executed in `HEALTHCHECK_CELERY_TIMEOUT` seconds which by default is three seconds. 46 | The task is sent with a priority of `HEALTHCHECK_CELERY_PRIORITY` (default priority by default). 47 | You may override that in your Django settings module. This check is suitable for use cases 48 | which require that tasks can be processed frequently all the time. 49 | 50 | `health_check.contrib.celery_ping` is a different check. It checks that each predefined 51 | Celery task queue has a consumer (i.e. worker) that responds `{"ok": "pong"}` in 52 | `HEALTHCHECK_CELERY_PING_TIMEOUT` seconds. The default for this is one second. 53 | You may override that in your Django settings module. This check is suitable for use cases 54 | which don't require that tasks are executed almost instantly but require that they are going 55 | to be executed in sometime the future i.e. that the worker process is alive and processing tasks 56 | all the time. 57 | 58 | You may also use both of them. To use these checks add them to `INSTALLED_APPS` in your 59 | Django settings module. 60 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-health-check 2 | ------------------- 3 | 4 | This project checks for various conditions and provides reports when anomalous 5 | behavior is detected. Many of these checks involve connecting to back-end 6 | services and ensuring basic operations are successful. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :caption: Contents: 11 | 12 | readme 13 | contrib 14 | settings 15 | changelog 16 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | django-health-check 2 | =================== 3 | 4 | |version| |pyversion| |djversion| |license| 5 | 6 | This project checks for various conditions and provides reports when 7 | anomalous behavior is detected. 8 | 9 | The following health checks are bundled with this project: 10 | 11 | - cache 12 | - database 13 | - storage 14 | - disk and memory utilization (via ``psutil``) 15 | - AWS S3 storage 16 | - Celery task queue 17 | - Celery ping 18 | - RabbitMQ 19 | - Migrations 20 | 21 | Writing your own custom health checks is also very quick and easy. 22 | 23 | We also like contributions, so don’t be afraid to make a pull request. 24 | 25 | Use Cases 26 | --------- 27 | 28 | The primary intended use case is to monitor conditions via HTTP(S), with 29 | responses available in HTML and JSON formats. When you get back a 30 | response that includes one or more problems, you can then decide the 31 | appropriate course of action, which could include generating 32 | notifications and/or automating the replacement of a failing node with a 33 | new one. If you are monitoring health in a high-availability environment 34 | with a load balancer that returns responses from multiple nodes, please 35 | note that certain checks (e.g., disk and memory usage) will return 36 | responses specific to the node selected by the load balancer. 37 | 38 | Supported Versions 39 | ------------------ 40 | 41 | We officially only support the latest version of Python as well as the 42 | latest version of Django and the latest Django LTS version. 43 | 44 | Installation 45 | ------------ 46 | 47 | First, install the ``django-health-check`` package: 48 | 49 | .. code:: shell 50 | 51 | $ pip install django-health-check 52 | 53 | Add the health checker to a URL you want to use: 54 | 55 | .. code:: python 56 | 57 | urlpatterns = [ 58 | # ... 59 | url(r'^ht/', include('health_check.urls')), 60 | ] 61 | 62 | Add the ``health_check`` applications to your ``INSTALLED_APPS``: 63 | 64 | .. code:: python 65 | 66 | INSTALLED_APPS = [ 67 | # ... 68 | 'health_check', # required 69 | 'health_check.db', # stock Django health checkers 70 | 'health_check.cache', 71 | 'health_check.storage', 72 | 'health_check.contrib.migrations', 73 | 'health_check.contrib.celery', # requires celery 74 | 'health_check.contrib.celery_ping', # requires celery 75 | 'health_check.contrib.psutil', # disk and memory utilization; requires psutil 76 | 'health_check.contrib.s3boto3_storage', # requires boto3 and S3BotoStorage backend 77 | 'health_check.contrib.rabbitmq', # requires RabbitMQ broker 78 | 'health_check.contrib.redis', # requires Redis broker 79 | ] 80 | 81 | **Note:** If using ``boto 2.x.x`` use 82 | ``health_check.contrib.s3boto_storage`` 83 | 84 | (Optional) If using the ``psutil`` app, you can configure disk and 85 | memory threshold settings; otherwise below defaults are assumed. If you 86 | want to disable one of these checks, set its value to ``None``. 87 | 88 | .. code:: python 89 | 90 | HEALTH_CHECK = { 91 | 'DISK_USAGE_MAX': 90, # percent 92 | 'MEMORY_MIN': 100, # in MB 93 | } 94 | 95 | If using the DB check, run migrations: 96 | 97 | .. code:: shell 98 | 99 | $ django-admin migrate 100 | 101 | To use the RabbitMQ healthcheck, please make sure that there is a 102 | variable named ``BROKER_URL`` on django.conf.settings with the required 103 | format to connect to your rabbit server. For example: 104 | 105 | .. code:: python 106 | 107 | BROKER_URL = "amqp://myuser:mypassword@localhost:5672/myvhost" 108 | 109 | To use the Redis healthcheck, please make sure that there is a variable 110 | named ``REDIS_URL`` on django.conf.settings with the required format to 111 | connect to your redis server. For example: 112 | 113 | .. code:: python 114 | 115 | REDIS_URL = "redis://localhost:6370" 116 | 117 | The cache healthcheck tries to write and read a specific key within the 118 | cache backend. It can be customized by setting ``HEALTHCHECK_CACHE_KEY`` 119 | to another value: 120 | 121 | .. code:: python 122 | 123 | HEALTHCHECK_CACHE_KEY = "custom_healthcheck_key" 124 | 125 | Setting up monitoring 126 | --------------------- 127 | 128 | You can use tools like Pingdom, StatusCake or other uptime robots to 129 | monitor service status. The ``/ht/`` endpoint will respond with an HTTP 130 | 200 if all checks passed and with an HTTP 500 if any of the tests 131 | failed. Getting machine-readable JSON reports 132 | 133 | If you want machine-readable status reports you can request the ``/ht/`` 134 | endpoint with the ``Accept`` HTTP header set to ``application/json`` or 135 | pass ``format=json`` as a query parameter. 136 | 137 | The backend will return a JSON response: 138 | 139 | .. code:: shell 140 | 141 | $ curl -v -X GET -H "Accept: application/json" http://www.example.com/ht/ 142 | 143 | > GET /ht/ HTTP/1.1 144 | > Host: www.example.com 145 | > Accept: application/json 146 | > 147 | < HTTP/1.1 200 OK 148 | < Content-Type: application/json 149 | 150 | { 151 | "CacheBackend": "working", 152 | "DatabaseBackend": "working", 153 | "S3BotoStorageHealthCheck": "working" 154 | } 155 | 156 | $ curl -v -X GET http://www.example.com/ht/?format=json 157 | 158 | > GET /ht/?format=json HTTP/1.1 159 | > Host: www.example.com 160 | > 161 | < HTTP/1.1 200 OK 162 | < Content-Type: application/json 163 | 164 | { 165 | "CacheBackend": "working", 166 | "DatabaseBackend": "working", 167 | "S3BotoStorageHealthCheck": "working" 168 | } 169 | 170 | Writing a custom health check 171 | ----------------------------- 172 | 173 | Writing a health check is quick and easy: 174 | 175 | .. code:: python 176 | 177 | from health_check.backends import BaseHealthCheckBackend 178 | 179 | class MyHealthCheckBackend(BaseHealthCheckBackend): 180 | #: The status endpoints will respond with a 200 status code 181 | #: even if the check errors. 182 | critical_service = False 183 | 184 | def check_status(self): 185 | # The test code goes here. 186 | # You can use `self.add_error` or 187 | # raise a `HealthCheckException`, 188 | # similar to Django's form validation. 189 | pass 190 | 191 | def identifier(self): 192 | return self.__class__.__name__ # Display name on the endpoint. 193 | 194 | After writing a custom checker, register it in your app configuration: 195 | 196 | .. code:: python 197 | 198 | from django.apps import AppConfig 199 | 200 | from health_check.plugins import plugin_dir 201 | 202 | class MyAppConfig(AppConfig): 203 | name = 'my_app' 204 | 205 | def ready(self): 206 | from .backends import MyHealthCheckBackend 207 | plugin_dir.register(MyHealthCheckBackend) 208 | 209 | Make sure the application you write the checker into is registered in 210 | your ``INSTALLED_APPS``. 211 | 212 | Customizing output 213 | ------------------ 214 | 215 | You can customize HTML or JSON rendering by inheriting from ``MainView`` 216 | in ``health_check.views`` and customizing the ``template_name``, 217 | ``get``, ``render_to_response`` and ``render_to_response_json`` 218 | properties: 219 | 220 | .. code:: python 221 | 222 | # views.py 223 | from health_check.views import MainView 224 | 225 | class HealthCheckCustomView(MainView): 226 | template_name = 'myapp/health_check_dashboard.html' # customize the used templates 227 | 228 | def get(self, request, *args, **kwargs): 229 | plugins = [] 230 | status = 200 # needs to be filled status you need 231 | # ... 232 | if 'application/json' in request.META.get('HTTP_ACCEPT', ''): 233 | return self.render_to_response_json(plugins, status) 234 | return self.render_to_response(plugins, status) 235 | 236 | def render_to_response(self, plugins, status): # customize HTML output 237 | return HttpResponse('COOL' if status == 200 else 'SWEATY', status=status) 238 | 239 | def render_to_response_json(self, plugins, status): # customize JSON output 240 | return JsonResponse( 241 | {str(p.identifier()): 'COOL' if status == 200 else 'SWEATY' for p in plugins}, 242 | status=status 243 | ) 244 | 245 | # urls.py 246 | import views 247 | 248 | urlpatterns = [ 249 | # ... 250 | url(r'^ht/$', views.HealthCheckCustomView.as_view(), name='health_check_custom'), 251 | ] 252 | 253 | Django command 254 | -------------- 255 | 256 | You can run the Django command ``health_check`` to perform your health 257 | checks via the command line, or periodically with a cron, as follow: 258 | 259 | .. code:: shell 260 | 261 | django-admin health_check 262 | 263 | This should yield the following output: 264 | 265 | :: 266 | 267 | DatabaseHealthCheck ... working 268 | CustomHealthCheck ... unavailable: Something went wrong! 269 | 270 | Similar to the http version, a critical error will cause the command to 271 | quit with the exit code ``1``. 272 | 273 | Other resources 274 | --------------- 275 | 276 | - `django-watchman `__ 277 | is a package that does some of the same things in a slightly 278 | different way. 279 | 280 | .. |version| image:: https://img.shields.io/pypi/v/django-health-check.svg 281 | :target: https://pypi.python.org/pypi/django-health-check/ 282 | .. |pyversion| image:: https://img.shields.io/pypi/pyversions/django-health-check.svg 283 | :target: https://pypi.python.org/pypi/django-health-check/ 284 | .. |djversion| image:: https://img.shields.io/pypi/djversions/django-health-check.svg 285 | :target: https://pypi.python.org/pypi/django-health-check/ 286 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg 287 | :target: https://pypi.python.org/pypi/django-health-check/ 288 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | Settings can be configured via the `HEALTH_CHECK` dictionary. 5 | 6 | .. data:: WARNINGS_AS_ERRORS 7 | 8 | Treats :class:`ServiceWarning` as errors, meaning they will cause the views 9 | to respond with a 500 status code. Default is `True`. If set to 10 | `False` warnings will be displayed in the template on in the JSON 11 | response but the status code will remain a 200. 12 | 13 | Security 14 | -------- 15 | 16 | Django health check can be used as a possible DOS attack vector as it can put 17 | your system under a lot of stress. As a default the view is also not cached by 18 | CDNs. Therefore we recommend to use a secure token to protect you application 19 | servers from an attacker. 20 | 21 | 1. Setup HTTPS. Seriously... 22 | 2. Add a secure token to your URL. 23 | 24 | Create a secure token: 25 | 26 | .. code:: shell 27 | 28 | python -c "import secrets; print(secrets.token_urlsafe())" 29 | 30 | Add it to your URL: 31 | 32 | .. code:: python 33 | 34 | urlpatterns = [ 35 | # ... 36 | url(r'^ht/super_secret_token/'), include('health_check.urls')), 37 | ] 38 | 39 | You can still use any uptime bot that is URL based while enjoying token protection. 40 | 41 | .. warning:: 42 | Do NOT use Django's `SECRET_KEY` setting. This should never be exposed, 43 | to any third party. Not even your trusted uptime bot. 44 | 45 | `cache` 46 | ------- 47 | 48 | The cache backend uses the following setting: 49 | 50 | .. list-table:: 51 | :widths: 25 10 10 55 52 | :header-rows: 1 53 | 54 | * - Name 55 | - Type 56 | - Default 57 | - Description 58 | * - `HEALTHCHECK_CACHE_KEY` 59 | - String 60 | - `djangohealthcheck_test` 61 | - Specifies the name of the key to write to and read from to validate that the cache is working. 62 | * - `HEALTHCHECK_REDIS_URL_OPTIONS` 63 | - Dict 64 | - {} 65 | - Additional arguments which will be passed as keyword arguments to the Redis connection class initialiser. 66 | 67 | `psutil` 68 | -------- 69 | 70 | The following default settings will be used to check for disk and memory 71 | utilization. If you would prefer different thresholds, you can add the dictionary 72 | below to your Django settings file and adjust the values accordingly. If you want 73 | to disable any of these checks, set its value to `None`. 74 | 75 | .. code:: python 76 | 77 | HEALTH_CHECK = { 78 | 'DISK_USAGE_MAX': 90, # percent 79 | 'MEMORY_MIN' = 100, # in MB 80 | } 81 | 82 | With the above default settings, warnings will be reported when disk utilization 83 | exceeds 90% or available memory drops below 100 MB. 84 | 85 | .. data:: DISK_USAGE_MAX 86 | 87 | Specify the desired disk utilization threshold, in percent. When disk usage 88 | exceeds the specified value, a warning will be reported. 89 | 90 | .. data:: MEMORY_MIN 91 | 92 | Specify the desired memory utilization threshold, in megabytes. When available 93 | memory falls below the specified value, a warning will be reported. 94 | 95 | Celery Health Check 96 | ------------------- 97 | 98 | Using `django.settings` you may exert more fine-grained control over the behavior of the celery health check 99 | 100 | .. list-table:: Additional Settings 101 | :widths: 25 10 10 55 102 | :header-rows: 1 103 | 104 | * - Name 105 | - Type 106 | - Default 107 | - Description 108 | * - `HEALTHCHECK_CELERY_QUEUE_TIMEOUT` 109 | - Number 110 | - `3` 111 | - Specifies the maximum amount of time a task may spend in the queue before being automatically revoked with a `TaskRevokedError`. 112 | * - `HEALTHCHECK_CELERY_RESULT_TIMEOUT` 113 | - Number 114 | - `3` 115 | - Specifies the maximum total time for a task to complete and return a result, including queue time. 116 | * - `HEALTHCHECK_CELERY_PRIORITY` 117 | - Number 118 | - `None` 119 | - Specifies the healthcheck task priority. 120 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/cache/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.cache.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-health-check/79791c5b2505c37050ef69d454af3da1cd7f60e3/health_check/contrib/__init__.py -------------------------------------------------------------------------------- /health_check/contrib/celery/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.celery.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /health_check/contrib/celery_ping/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.celery_ping.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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 = set([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 | -------------------------------------------------------------------------------- /health_check/contrib/db_heartbeat/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.db_heartbeat.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/db_heartbeat/backends.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | 3 | from health_check.backends import BaseHealthCheckBackend 4 | from health_check.exceptions import ServiceUnavailable 5 | 6 | 7 | class DatabaseHeartBeatCheck(BaseHealthCheckBackend): 8 | """Health check that runs a simple SELECT 1; query to test if the database connection is alive.""" 9 | 10 | def check_status(self): 11 | try: 12 | result = None 13 | with connection.cursor() as cursor: 14 | cursor.execute("SELECT 1;") 15 | result = cursor.fetchone() 16 | 17 | if result != (1,): 18 | raise ServiceUnavailable("Health Check query did not return the expected result.") 19 | except Exception as e: 20 | raise ServiceUnavailable(f"Database health check failed: {e}") 21 | -------------------------------------------------------------------------------- /health_check/contrib/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.migrations.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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/psutil/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.psutil.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /health_check/contrib/rabbitmq/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.rabbitmq.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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/redis/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.redis.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /health_check/contrib/s3boto3_storage/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.s3boto3_storage.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/contrib/s3boto_storage/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.contrib.s3boto_storage.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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/db/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.db.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/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/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/db/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-health-check/79791c5b2505c37050ef69d454af3da1cd7f60e3/health_check/db/migrations/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /health_check/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-health-check/79791c5b2505c37050ef69d454af3da1cd7f60e3/health_check/management/__init__.py -------------------------------------------------------------------------------- /health_check/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-health-check/79791c5b2505c37050ef69d454af3da1cd7f60e3/health_check/management/commands/__init__.py -------------------------------------------------------------------------------- /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/mixins.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from collections import OrderedDict 3 | from concurrent.futures import ThreadPoolExecutor 4 | 5 | from django.http import Http404 6 | 7 | from health_check.conf import HEALTH_CHECK 8 | from health_check.exceptions import ServiceWarning 9 | from health_check.plugins import plugin_dir 10 | 11 | 12 | class CheckMixin: 13 | _errors = None 14 | _plugins = None 15 | 16 | @property 17 | def errors(self): 18 | if not self._errors: 19 | self._errors = self.run_check() 20 | return self._errors 21 | 22 | def check(self, subset=None): 23 | return self.run_check(subset=subset) 24 | 25 | @property 26 | def plugins(self): 27 | if not plugin_dir._registry: 28 | return OrderedDict({}) 29 | 30 | if not self._plugins: 31 | registering_plugins = ( 32 | plugin_class(**copy.deepcopy(options)) for plugin_class, options in plugin_dir._registry 33 | ) 34 | registering_plugins = sorted(registering_plugins, key=lambda plugin: plugin.identifier()) 35 | self._plugins = OrderedDict({plugin.identifier(): plugin for plugin in registering_plugins}) 36 | return self._plugins 37 | 38 | def filter_plugins(self, subset=None): 39 | if subset is None: 40 | return self.plugins 41 | 42 | health_check_subsets = HEALTH_CHECK["SUBSETS"] 43 | if subset not in health_check_subsets or not self.plugins: 44 | raise Http404(f"Subset: '{subset}' does not exist.") 45 | 46 | selected_subset = set(health_check_subsets[subset]) 47 | return { 48 | plugin_identifier: v 49 | for plugin_identifier, v in self.plugins.items() 50 | if plugin_identifier in selected_subset 51 | } 52 | 53 | def run_check(self, subset=None): 54 | errors = [] 55 | 56 | def _run(plugin): 57 | plugin.run_check() 58 | try: 59 | return plugin 60 | finally: 61 | from django.db import connections 62 | 63 | connections.close_all() 64 | 65 | def _collect_errors(plugin): 66 | if plugin.critical_service: 67 | if not HEALTH_CHECK["WARNINGS_AS_ERRORS"]: 68 | errors.extend(e for e in plugin.errors if not isinstance(e, ServiceWarning)) 69 | else: 70 | errors.extend(plugin.errors) 71 | 72 | plugins = self.filter_plugins(subset=subset) 73 | plugin_instances = plugins.values() 74 | 75 | if HEALTH_CHECK["DISABLE_THREADING"]: 76 | for plugin in plugin_instances: 77 | _run(plugin) 78 | _collect_errors(plugin) 79 | else: 80 | with ThreadPoolExecutor(max_workers=len(plugin_instances) or 1) as executor: 81 | for plugin in executor.map(_run, plugin_instances): 82 | _collect_errors(plugin) 83 | return errors 84 | -------------------------------------------------------------------------------- /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/storage/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION < (3, 2): 4 | default_app_config = "health_check.storage.apps.HealthCheckConfig" 5 | -------------------------------------------------------------------------------- /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/storage/backends.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import django 4 | from django.core.files.base import ContentFile 5 | from django.core.files.storage import default_storage 6 | 7 | if django.VERSION >= (4, 2): 8 | from django.core.files.storage import InvalidStorageError, storages 9 | else: 10 | from django.core.files.storage import get_storage_class 11 | 12 | from health_check.backends import BaseHealthCheckBackend 13 | from health_check.exceptions import ServiceUnavailable 14 | 15 | 16 | class StorageHealthCheck(BaseHealthCheckBackend): 17 | """ 18 | Tests the status of a `StorageBackend`. 19 | 20 | Can be extended to test any storage backend by subclassing: 21 | 22 | class MyStorageHealthCheck(StorageHealthCheck): 23 | storage = 'some.other.StorageBackend' 24 | plugin_dir.register(MyStorageHealthCheck) 25 | 26 | storage must be either a string pointing to a storage class 27 | (e.g 'django.core.files.storage.FileSystemStorage') or a Storage instance. 28 | """ 29 | 30 | storage_alias = None 31 | storage = None 32 | 33 | def get_storage(self): 34 | if django.VERSION >= (4, 2): 35 | try: 36 | return storages[self.storage_alias] 37 | except InvalidStorageError: 38 | return None 39 | else: 40 | if isinstance(self.storage, str): 41 | return get_storage_class(self.storage)() 42 | else: 43 | return self.storage 44 | 45 | def get_file_name(self): 46 | return f"health_check_storage_test/test-{uuid.uuid4()}.txt" 47 | 48 | def get_file_content(self): 49 | return b"this is the healthtest file content" 50 | 51 | def check_save(self, file_name, file_content): 52 | storage = self.get_storage() 53 | # save the file 54 | file_name = storage.save(file_name, ContentFile(content=file_content)) 55 | # read the file and compare 56 | if not storage.exists(file_name): 57 | raise ServiceUnavailable("File does not exist") 58 | with storage.open(file_name) as f: 59 | if not f.read() == file_content: 60 | raise ServiceUnavailable("File content does not match") 61 | return file_name 62 | 63 | def check_delete(self, file_name): 64 | storage = self.get_storage() 65 | # delete the file and make sure it is gone 66 | storage.delete(file_name) 67 | if storage.exists(file_name): 68 | raise ServiceUnavailable("File was not deleted") 69 | 70 | def check_status(self): 71 | try: 72 | # write the file to the storage backend 73 | file_name = self.get_file_name() 74 | file_content = self.get_file_content() 75 | file_name = self.check_save(file_name, file_content) 76 | self.check_delete(file_name) 77 | return True 78 | except ServiceUnavailable as e: 79 | raise e 80 | except Exception as e: 81 | raise ServiceUnavailable("Unknown exception") from e 82 | 83 | 84 | class DefaultFileStorageHealthCheck(StorageHealthCheck): 85 | storage_alias = "default" 86 | storage = default_storage 87 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/views.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.http import HttpResponse, JsonResponse 4 | from django.utils.decorators import method_decorator 5 | from django.views.decorators.cache import never_cache 6 | from django.views.generic import TemplateView 7 | 8 | from health_check.mixins import CheckMixin 9 | 10 | 11 | class MediaType: 12 | """ 13 | Sortable object representing HTTP's accept header. 14 | 15 | .. seealso:: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept 16 | """ 17 | 18 | pattern = re.compile( 19 | r""" 20 | ^ 21 | (?P 22 | (\w+|\*) # Media type, or wildcard 23 | / 24 | ([\w\d\-+.]+|\*) # subtype, or wildcard 25 | ) 26 | ( 27 | \s*;\s* # parameter separator with optional whitespace 28 | q= # q is expected to be the first parameter, by RFC2616 29 | (?P 30 | 1([.]0{1,3})? # 1 with up to three digits of precision 31 | | 32 | 0([.]\d{1,3})? # 0.000 to 0.999 with optional precision 33 | ) 34 | )? 35 | ( 36 | \s*;\s* # parameter separator with optional whitespace 37 | [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any token from legal characters 38 | = 39 | [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any value from legal characters 40 | )* 41 | $ 42 | """, 43 | re.VERBOSE, 44 | ) 45 | 46 | def __init__(self, mime_type, weight=1.0): 47 | self.mime_type = mime_type 48 | self.weight = float(weight) 49 | 50 | @classmethod 51 | def from_string(cls, value): 52 | """Return single instance parsed from given accept header string.""" 53 | match = cls.pattern.search(value) 54 | if match is None: 55 | raise ValueError(f'"{value}" is not a valid media type') 56 | try: 57 | return cls(match.group("mime_type"), float(match.group("weight") or 1)) 58 | except ValueError: 59 | return cls(value) 60 | 61 | @classmethod 62 | def parse_header(cls, value="*/*"): 63 | """Parse HTTP accept header and return instances sorted by weight.""" 64 | yield from sorted( 65 | (cls.from_string(token.strip()) for token in value.split(",") if token.strip()), 66 | reverse=True, 67 | ) 68 | 69 | def __str__(self): 70 | return f"{self.mime_type}; q={self.weight}" 71 | 72 | def __repr__(self): 73 | return f"{type(self).__name__}: {self.__str__()}" 74 | 75 | def __eq__(self, other): 76 | return self.weight == other.weight and self.mime_type == other.mime_type 77 | 78 | def __lt__(self, other): 79 | return self.weight.__lt__(other.weight) 80 | 81 | 82 | class MainView(CheckMixin, TemplateView): 83 | template_name = "health_check/index.html" 84 | 85 | @method_decorator(never_cache) 86 | def get(self, request, *args, **kwargs): 87 | subset = kwargs.get("subset") 88 | health_check_has_error = self.check(subset) 89 | status_code = 500 if health_check_has_error else 200 90 | format_override = request.GET.get("format") 91 | 92 | if format_override == "json": 93 | return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) 94 | 95 | accept_header = request.META.get("HTTP_ACCEPT", "*/*") 96 | for media in MediaType.parse_header(accept_header): 97 | if media.mime_type in ( 98 | "text/html", 99 | "application/xhtml+xml", 100 | "text/*", 101 | "*/*", 102 | ): 103 | context = self.get_context_data(**kwargs) 104 | return self.render_to_response(context, status=status_code) 105 | elif media.mime_type in ("application/json", "application/*"): 106 | return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) 107 | return HttpResponse( 108 | "Not Acceptable: Supported content types: text/html, application/json", 109 | status=406, 110 | content_type="text/plain", 111 | ) 112 | 113 | def get_context_data(self, **kwargs): 114 | subset = kwargs.get("subset") 115 | return { 116 | **super().get_context_data(**kwargs), 117 | "plugins": self.filter_plugins(subset=subset).values(), 118 | } 119 | 120 | def render_to_response_json(self, plugins, status): 121 | return JsonResponse( 122 | {str(plugin_identifier): str(p.pretty_status()) for plugin_identifier, p in plugins.items()}, 123 | status=status, 124 | ) 125 | -------------------------------------------------------------------------------- /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 | homepage = "https://github.com/revsys/django-health-check" 14 | keywords = ["django", "postgresql"] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Framework :: Django", 18 | "Framework :: Django :: 4.2", 19 | "Framework :: Django :: 5.2", 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.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Topic :: Software Development :: Quality Assurance", 32 | "Topic :: System :: Logging", 33 | "Topic :: System :: Monitoring", 34 | "Topic :: Utilities" 35 | ] 36 | requires-python = ">=3.9" 37 | dynamic = [ 38 | "version", 39 | "description", 40 | ] 41 | dependencies = [ 42 | "Django>=4.2", 43 | ] 44 | 45 | [project.optional-dependencies] 46 | test = [ 47 | "pytest", 48 | "pytest-cov", 49 | "pytest-django", 50 | "celery", 51 | "redis", 52 | "django-storages", 53 | "boto3", 54 | ] 55 | docs = ["sphinx"] 56 | lint = [ 57 | "ruff==0.11.13", 58 | ] 59 | 60 | [tool.flit.module] 61 | name = "health_check" 62 | 63 | [tool.setuptools_scm] 64 | write_to = "health_check/_version.py" 65 | 66 | [tool.pytest.ini_options] 67 | addopts = "--nomigrations --reuse-db --strict-markers" 68 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 69 | norecursedirs = ".git" 70 | python_files = "test_*.py" 71 | xfail_strict = true 72 | 73 | [tool.coverage.run] 74 | branch = true 75 | omit = [ 76 | "*/migrations/*", 77 | "*/tests/*", 78 | "*/test_*.py", 79 | ".eggs/*" 80 | ] 81 | 82 | [tool.coverage.report] 83 | ignore_errors = true 84 | show_missing = true 85 | skip_covered = true 86 | sort = "Cover" 87 | 88 | [tool.ruff] 89 | line-length = 120 90 | target-version = "py39" 91 | 92 | [tool.ruff.lint] 93 | select = [ 94 | "D", # pydocstyle 95 | "E", # pycodestyle errors 96 | "EXE", # flake8-executable 97 | "F", # pyflakes 98 | "I", # isort 99 | "S", # flake8-bandit 100 | "SIM", # flake8-simplify 101 | "UP", # pyupgrade 102 | "W", # pycodestyle warnings 103 | ] 104 | ignore = ["D1", "D203", "D212"] 105 | 106 | [tool.ruff.lint.per-file-ignores] 107 | "tests/*.py" = ["S101", "S105", "PLR2004"] 108 | 109 | [tool.ruff.lint.isort] 110 | known-first-party = ["measurement", "tests"] 111 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-health-check/79791c5b2505c37050ef69d454af3da1cd7f60e3/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.core.cache.backends.base import BaseCache, CacheKeyWarning 4 | from django.test import TestCase 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 HealthCheckCacheTests(TestCase): 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 | self.assertFalse(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 | self.assertFalse(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 | self.assertTrue(cache_backend.errors) 76 | self.assertIn("does not match", 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 | self.assertTrue(cache_backend.errors) 84 | self.assertIn("does not match", 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 self.assertRaises(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 | self.assertIn("unexpected result: Cache key warning", cache_backend.pretty_status()) 106 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.db import DatabaseError, IntegrityError 4 | from django.db.models import Model 5 | from django.test import TestCase 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 HealthCheckDatabaseTests(TestCase): 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 | self.assertFalse(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 | self.assertTrue(db_backend.errors) 61 | self.assertIn("unexpected result: Integrity Error", 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 | self.assertTrue(db_backend.errors) 71 | self.assertIn("unavailable: Database error", 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 self.assertRaises(Exception): 80 | db_backend.run_check() 81 | -------------------------------------------------------------------------------- /tests/test_db_heartbeat.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | from health_check.contrib.db_heartbeat.backends import DatabaseHeartBeatCheck 5 | from health_check.exceptions import ServiceUnavailable 6 | 7 | 8 | class TestDatabaseHeartBeatCheck(unittest.TestCase): 9 | @patch("health_check.contrib.db_heartbeat.backends.connection") 10 | def test_check_status_success(self, mock_connection): 11 | mock_cursor = MagicMock() 12 | mock_cursor.fetchone.return_value = (1,) 13 | mock_connection.cursor.return_value.__enter__.return_value = mock_cursor 14 | 15 | health_check = DatabaseHeartBeatCheck() 16 | try: 17 | health_check.check_status() 18 | except Exception as e: 19 | self.fail(f"check_status() raised an exception unexpectedly: {e}") 20 | 21 | @patch("health_check.contrib.db_heartbeat.backends.connection") 22 | def test_check_status_service_unavailable(self, mock_connection): 23 | mock_connection.cursor.side_effect = Exception("Database error") 24 | 25 | health_check = DatabaseHeartBeatCheck() 26 | with self.assertRaises(ServiceUnavailable): 27 | health_check.check_status() 28 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.db.migrations import Migration 4 | from django.test import TestCase 5 | 6 | from health_check.contrib.migrations.backends import MigrationsHealthCheck 7 | 8 | 9 | class MockMigration(Migration): ... 10 | 11 | 12 | class TestMigrationsHealthCheck(TestCase): 13 | def test_check_status_work(self): 14 | with patch( 15 | "health_check.contrib.migrations.backends.MigrationsHealthCheck.get_migration_plan", 16 | return_value=[], 17 | ): 18 | backend = MigrationsHealthCheck() 19 | backend.run_check() 20 | self.assertFalse(backend.errors) 21 | 22 | def test_check_status_raises_error_if_there_are_migrations(self): 23 | with patch( 24 | "health_check.contrib.migrations.backends.MigrationsHealthCheck.get_migration_plan", 25 | return_value=[(MockMigration, False)], 26 | ): 27 | backend = MigrationsHealthCheck() 28 | backend.run_check() 29 | self.assertTrue(backend.errors) 30 | -------------------------------------------------------------------------------- /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 | def test_run_check_threading_disabled(self, monkeypatch): 63 | """Ensure threading not used when disabled.""" 64 | # Ensure threading is disabled. 65 | monkeypatch.setitem(HEALTH_CHECK, "DISABLE_THREADING", True) 66 | 67 | # Ensure ThreadPoolExecutor is not used 68 | with patch("health_check.mixins.ThreadPoolExecutor") as tpe: 69 | Checker().run_check() 70 | tpe.assert_not_called() 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_storage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from io import BytesIO 3 | from unittest import mock 4 | 5 | import django 6 | from django.core.files.base import File 7 | from django.core.files.storage import Storage 8 | from django.test import TestCase, override_settings 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 HealthCheckStorageTests(TestCase): 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 | self.assertIsNone(base_storage.get_storage()) 132 | 133 | default_storage = DefaultFileStorageHealthCheck() 134 | self.assertIsInstance(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): 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 | with self.settings(DEFAULT_FILE_STORAGE="tests.test_storage.CustomStorage"): 143 | default_storage = DefaultFileStorageHealthCheck() 144 | self.assertIsInstance(default_storage.get_storage(), CustomStorage) 145 | 146 | @unittest.skipUnless(django.VERSION >= (4, 2), "Django 4.2+ required") 147 | def test_get_storage_django_42_plus(self): 148 | """Check that the new STORAGES setting is supported.""" 149 | with self.settings(STORAGES={"default": {"BACKEND": "tests.test_storage.CustomStorage"}}): 150 | default_storage = DefaultFileStorageHealthCheck() 151 | self.assertIsInstance(default_storage.get_storage(), CustomStorage) 152 | 153 | @mock.patch( 154 | "health_check.storage.backends.DefaultFileStorageHealthCheck.storage", 155 | MockStorage(), 156 | ) 157 | def test_check_status_working(self): 158 | """Test check_status returns True when storage is working properly.""" 159 | default_storage_health = DefaultFileStorageHealthCheck() 160 | 161 | default_storage = default_storage_health.get_storage() 162 | 163 | default_storage_open = f"{default_storage.__module__}.{default_storage.__class__.__name__}.open" 164 | 165 | with mock.patch( 166 | default_storage_open, 167 | mock.mock_open(read_data=default_storage_health.get_file_content()), 168 | ): 169 | self.assertTrue(default_storage_health.check_status()) 170 | 171 | @unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier") 172 | @mock.patch( 173 | "health_check.storage.backends.DefaultFileStorageHealthCheck.storage", 174 | MockStorage(saves=False), 175 | ) 176 | def test_file_does_not_exist_django_41_earlier(self): 177 | """Test check_status raises ServiceUnavailable when file is not saved.""" 178 | default_storage_health = DefaultFileStorageHealthCheck() 179 | with self.assertRaises(ServiceUnavailable): 180 | default_storage_health.check_status() 181 | 182 | @unittest.skipUnless(django.VERSION >= (4, 2), "Only for Django 4.2+") 183 | @mock.patch( 184 | "health_check.storage.backends.storages", 185 | {"default": MockStorage(saves=False)}, 186 | ) 187 | def test_file_does_not_exist_django_42_plus(self): 188 | """Test check_status raises ServiceUnavailable when file is not saved.""" 189 | default_storage_health = DefaultFileStorageHealthCheck() 190 | with self.assertRaises(ServiceUnavailable): 191 | default_storage_health.check_status() 192 | 193 | @unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier") 194 | @mock.patch( 195 | "health_check.storage.backends.DefaultFileStorageHealthCheck.storage", 196 | MockStorage(deletes=False), 197 | ) 198 | def test_file_not_deleted_django_41_earlier(self): 199 | """Test check_status raises ServiceUnavailable when file is not deleted.""" 200 | default_storage_health = DefaultFileStorageHealthCheck() 201 | with self.assertRaises(ServiceUnavailable): 202 | default_storage_health.check_status() 203 | 204 | @unittest.skipUnless(django.VERSION >= (4, 2), "Only for Django 4.2+") 205 | @mock.patch( 206 | "health_check.storage.backends.storages", 207 | {"default": MockStorage(deletes=False)}, 208 | ) 209 | def test_file_not_deleted_django_42_plus(self): 210 | """Test check_status raises ServiceUnavailable when file is not deleted.""" 211 | default_storage_health = DefaultFileStorageHealthCheck() 212 | with self.assertRaises(ServiceUnavailable): 213 | default_storage_health.check_status() 214 | 215 | 216 | @mock.patch("storages.backends.s3boto3.S3Boto3Storage", new=MockS3Boto3Storage) 217 | @override_settings( 218 | STORAGES={ 219 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 220 | } 221 | ) 222 | class HealthCheckS3Boto3StorageTests(TestCase): 223 | """Tests health check behavior with a mocked S3Boto3Storage backend.""" 224 | 225 | @unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier") 226 | @mock.patch( 227 | "storages.backends.s3boto3.S3Boto3Storage", 228 | MockS3Boto3Storage(deletes=False), 229 | ) 230 | def test_check_delete_success_django_41_earlier(self): 231 | """Test that check_delete correctly deletes a file when S3Boto3Storage is working.""" 232 | health_check = S3Boto3StorageHealthCheck() 233 | mock_storage = health_check.get_storage() 234 | file_name = "testfile.txt" 235 | content = BytesIO(b"Test content") 236 | mock_storage.save(file_name, content) 237 | 238 | with self.assertRaises(ServiceUnavailable): 239 | health_check.check_delete(file_name) 240 | 241 | @unittest.skipUnless(django.VERSION >= (4, 2), "Only for Django 4.2+") 242 | def test_check_delete_success(self): 243 | """Test that check_delete correctly deletes a file when S3Boto3Storage is working.""" 244 | health_check = S3Boto3StorageHealthCheck() 245 | mock_storage = health_check.get_storage() 246 | file_name = "testfile.txt" 247 | content = BytesIO(b"Test content") 248 | mock_storage.save(file_name, content) 249 | 250 | health_check.check_delete(file_name) 251 | self.assertFalse(mock_storage.exists(file_name)) 252 | 253 | def test_check_delete_failure(self): 254 | """Test that check_delete raises ServiceUnavailable when deletion fails.""" 255 | with mock.patch.object( 256 | MockS3Boto3Storage, 257 | "delete", 258 | side_effect=Exception("Failed to delete file."), 259 | ): 260 | health_check = S3Boto3StorageHealthCheck() 261 | with self.assertRaises(ServiceUnavailable): 262 | health_check.check_delete("testfile.txt") 263 | 264 | @unittest.skipUnless(django.VERSION <= (4, 1), "Only for Django 4.1 and earlier") 265 | @mock.patch( 266 | "storages.backends.s3boto3.S3Boto3Storage", 267 | MockS3Boto3Storage(deletes=False), 268 | ) 269 | def test_check_status_working_django_41_earlier(self): 270 | """Test check_status returns True when S3Boto3Storage can save and delete files.""" 271 | health_check = S3Boto3StorageHealthCheck() 272 | with self.assertRaises(ServiceUnavailable): 273 | self.assertTrue(health_check.check_status()) 274 | 275 | @unittest.skipUnless(django.VERSION >= (4, 2), "Only for Django 4.2+") 276 | def test_check_status_working(self): 277 | """Test check_status returns True when S3Boto3Storage can save and delete files.""" 278 | health_check = S3Boto3StorageHealthCheck() 279 | self.assertTrue(health_check.check_status()) 280 | 281 | def test_check_status_failure_on_save(self): 282 | """Test check_status raises ServiceUnavailable when file cannot be saved.""" 283 | with mock.patch.object(MockS3Boto3Storage, "save", side_effect=Exception("Failed to save file.")): 284 | health_check = S3Boto3StorageHealthCheck() 285 | with self.assertRaises(ServiceUnavailable): 286 | health_check.check_status() 287 | 288 | def test_check_status_failure_on_delete(self): 289 | """Test check_status raises ServiceUnavailable when file cannot be deleted.""" 290 | with mock.patch.object(MockS3Boto3Storage, "exists", new_callable=mock.PropertyMock) as mock_exists: 291 | mock_exists.return_value = False 292 | health_check = S3Boto3StorageHealthCheck() 293 | with self.assertRaises(ServiceUnavailable): 294 | health_check.check_status() 295 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from health_check.backends import BaseHealthCheckBackend 6 | from health_check.conf import HEALTH_CHECK 7 | from health_check.exceptions import ServiceWarning 8 | from health_check.plugins import plugin_dir 9 | from health_check.views import MediaType 10 | 11 | try: 12 | from django.urls import reverse 13 | except ImportError: 14 | from django.core.urlresolvers import reverse 15 | 16 | 17 | class TestMediaType: 18 | def test_lt(self): 19 | assert not MediaType("*/*") < MediaType("*/*") 20 | assert not MediaType("*/*") < MediaType("*/*", 0.9) 21 | assert MediaType("*/*", 0.9) < MediaType("*/*") 22 | 23 | def test_str(self): 24 | assert str(MediaType("*/*")) == "*/*; q=1.0" 25 | assert str(MediaType("image/*", 0.6)) == "image/*; q=0.6" 26 | 27 | def test_repr(self): 28 | assert repr(MediaType("*/*")) == "MediaType: */*; q=1.0" 29 | 30 | def test_eq(self): 31 | assert MediaType("*/*") == MediaType("*/*") 32 | assert MediaType("*/*", 0.9) != MediaType("*/*") 33 | 34 | valid_strings = [ 35 | ("*/*", MediaType("*/*")), 36 | ("*/*; q=0.9", MediaType("*/*", 0.9)), 37 | ("*/*; q=0", MediaType("*/*", 0.0)), 38 | ("*/*; q=0.0", MediaType("*/*", 0.0)), 39 | ("*/*; q=0.1", MediaType("*/*", 0.1)), 40 | ("*/*; q=0.12", MediaType("*/*", 0.12)), 41 | ("*/*; q=0.123", MediaType("*/*", 0.123)), 42 | ("*/*; q=1.000", MediaType("*/*", 1.0)), 43 | ("*/*; q=1", MediaType("*/*", 1.0)), 44 | ("*/*;q=0.9", MediaType("*/*", 0.9)), 45 | ("*/* ;q=0.9", MediaType("*/*", 0.9)), 46 | ("*/* ; q=0.9", MediaType("*/*", 0.9)), 47 | ("*/* ; q=0.9", MediaType("*/*", 0.9)), 48 | ("*/*;v=b3", MediaType("*/*")), 49 | ("*/*; q=0.5; v=b3", MediaType("*/*", 0.5)), 50 | ] 51 | 52 | @pytest.mark.parametrize("type, expected", valid_strings) 53 | def test_from_valid_strings(self, type, expected): 54 | assert MediaType.from_string(type) == expected 55 | 56 | invalid_strings = [ 57 | "*/*;0.9", 58 | 'text/html;z=""', 59 | "text/html; xxx", 60 | "text/html; =a", 61 | ] 62 | 63 | @pytest.mark.parametrize("type", invalid_strings) 64 | def test_from_invalid_strings(self, type): 65 | with pytest.raises(ValueError) as e: 66 | MediaType.from_string(type) 67 | expected_error = f'"{type}" is not a valid media type' 68 | assert expected_error in str(e.value) 69 | 70 | def test_parse_header(self): 71 | assert list(MediaType.parse_header()) == [ 72 | MediaType("*/*"), 73 | ] 74 | assert list(MediaType.parse_header("text/html; q=0.1, application/xhtml+xml; q=0.1 ,application/json")) == [ 75 | MediaType("application/json"), 76 | MediaType("text/html", 0.1), 77 | MediaType("application/xhtml+xml", 0.1), 78 | ] 79 | 80 | 81 | class TestMainView: 82 | url = reverse("health_check:health_check_home") 83 | 84 | def test_success(self, client): 85 | response = client.get(self.url) 86 | assert response.status_code == 200, response.content.decode("utf-8") 87 | assert response["content-type"] == "text/html; charset=utf-8" 88 | 89 | def test_error(self, client): 90 | class MyBackend(BaseHealthCheckBackend): 91 | def check_status(self): 92 | self.add_error("Super Fail!") 93 | 94 | plugin_dir.reset() 95 | plugin_dir.register(MyBackend) 96 | response = client.get(self.url) 97 | assert response.status_code == 500, response.content.decode("utf-8") 98 | assert response["content-type"] == "text/html; charset=utf-8" 99 | assert b"Super Fail!" in response.content 100 | 101 | def test_warning(self, client): 102 | class MyBackend(BaseHealthCheckBackend): 103 | def check_status(self): 104 | raise ServiceWarning("so so") 105 | 106 | plugin_dir.reset() 107 | plugin_dir.register(MyBackend) 108 | response = client.get(self.url) 109 | assert response.status_code == 500, response.content.decode("utf-8") 110 | assert b"so so" in response.content, response.content 111 | 112 | HEALTH_CHECK["WARNINGS_AS_ERRORS"] = False 113 | 114 | response = client.get(self.url) 115 | assert response.status_code == 200, response.content.decode("utf-8") 116 | assert response["content-type"] == "text/html; charset=utf-8" 117 | assert b"so so" in response.content, response.content 118 | 119 | def test_non_critical(self, client): 120 | class MyBackend(BaseHealthCheckBackend): 121 | critical_service = False 122 | 123 | def check_status(self): 124 | self.add_error("Super Fail!") 125 | 126 | plugin_dir.reset() 127 | plugin_dir.register(MyBackend) 128 | response = client.get(self.url) 129 | assert response.status_code == 200, response.content.decode("utf-8") 130 | assert response["content-type"] == "text/html; charset=utf-8" 131 | assert b"Super Fail!" in response.content 132 | 133 | def test_success_accept_json(self, client): 134 | class JSONSuccessBackend(BaseHealthCheckBackend): 135 | def run_check(self): 136 | pass 137 | 138 | plugin_dir.reset() 139 | plugin_dir.register(JSONSuccessBackend) 140 | response = client.get(self.url, HTTP_ACCEPT="application/json") 141 | assert response["content-type"] == "application/json" 142 | assert response.status_code == 200 143 | 144 | def test_success_prefer_json(self, client): 145 | class JSONSuccessBackend(BaseHealthCheckBackend): 146 | def run_check(self): 147 | pass 148 | 149 | plugin_dir.reset() 150 | plugin_dir.register(JSONSuccessBackend) 151 | response = client.get(self.url, HTTP_ACCEPT="application/json; q=0.8, text/html; q=0.5") 152 | assert response["content-type"] == "application/json" 153 | assert response.status_code == 200 154 | 155 | def test_success_accept_xhtml(self, client): 156 | class SuccessBackend(BaseHealthCheckBackend): 157 | def run_check(self): 158 | pass 159 | 160 | plugin_dir.reset() 161 | plugin_dir.register(SuccessBackend) 162 | response = client.get(self.url, HTTP_ACCEPT="application/xhtml+xml") 163 | assert response["content-type"] == "text/html; charset=utf-8" 164 | assert response.status_code == 200 165 | 166 | def test_success_unsupported_accept(self, client): 167 | class SuccessBackend(BaseHealthCheckBackend): 168 | def run_check(self): 169 | pass 170 | 171 | plugin_dir.reset() 172 | plugin_dir.register(SuccessBackend) 173 | response = client.get(self.url, HTTP_ACCEPT="application/octet-stream") 174 | assert response["content-type"] == "text/plain" 175 | assert response.status_code == 406 176 | assert response.content == b"Not Acceptable: Supported content types: text/html, application/json" 177 | 178 | def test_success_unsupported_and_supported_accept(self, client): 179 | class SuccessBackend(BaseHealthCheckBackend): 180 | def run_check(self): 181 | pass 182 | 183 | plugin_dir.reset() 184 | plugin_dir.register(SuccessBackend) 185 | response = client.get(self.url, HTTP_ACCEPT="application/octet-stream, application/json; q=0.9") 186 | assert response["content-type"] == "application/json" 187 | assert response.status_code == 200 188 | 189 | def test_success_accept_order(self, client): 190 | class JSONSuccessBackend(BaseHealthCheckBackend): 191 | def run_check(self): 192 | pass 193 | 194 | plugin_dir.reset() 195 | plugin_dir.register(JSONSuccessBackend) 196 | response = client.get( 197 | self.url, 198 | HTTP_ACCEPT="text/html, application/xhtml+xml, application/json; q=0.9, */*; q=0.1", 199 | ) 200 | assert response["content-type"] == "text/html; charset=utf-8" 201 | assert response.status_code == 200 202 | 203 | def test_success_accept_order__reverse(self, client): 204 | class JSONSuccessBackend(BaseHealthCheckBackend): 205 | def run_check(self): 206 | pass 207 | 208 | plugin_dir.reset() 209 | plugin_dir.register(JSONSuccessBackend) 210 | response = client.get( 211 | self.url, 212 | HTTP_ACCEPT="text/html; q=0.1, application/xhtml+xml; q=0.1, application/json", 213 | ) 214 | assert response["content-type"] == "application/json" 215 | assert response.status_code == 200 216 | 217 | def test_format_override(self, client): 218 | class JSONSuccessBackend(BaseHealthCheckBackend): 219 | def run_check(self): 220 | pass 221 | 222 | plugin_dir.reset() 223 | plugin_dir.register(JSONSuccessBackend) 224 | response = client.get(self.url + "?format=json", HTTP_ACCEPT="text/html") 225 | assert response["content-type"] == "application/json" 226 | assert response.status_code == 200 227 | 228 | def test_format_no_accept_header(self, client): 229 | class JSONSuccessBackend(BaseHealthCheckBackend): 230 | def run_check(self): 231 | pass 232 | 233 | plugin_dir.reset() 234 | plugin_dir.register(JSONSuccessBackend) 235 | response = client.get(self.url) 236 | assert response.status_code == 200, response.content.decode("utf-8") 237 | assert response["content-type"] == "text/html; charset=utf-8" 238 | 239 | def test_error_accept_json(self, client): 240 | class JSONErrorBackend(BaseHealthCheckBackend): 241 | def run_check(self): 242 | self.add_error("JSON Error") 243 | 244 | plugin_dir.reset() 245 | plugin_dir.register(JSONErrorBackend) 246 | response = client.get(self.url, HTTP_ACCEPT="application/json") 247 | assert response.status_code == 500, response.content.decode("utf-8") 248 | assert response["content-type"] == "application/json" 249 | assert "JSON Error" in json.loads(response.content.decode("utf-8"))[JSONErrorBackend().identifier()] 250 | 251 | def test_success_param_json(self, client): 252 | class JSONSuccessBackend(BaseHealthCheckBackend): 253 | def run_check(self): 254 | pass 255 | 256 | plugin_dir.reset() 257 | plugin_dir.register(JSONSuccessBackend) 258 | response = client.get(self.url, {"format": "json"}) 259 | assert response.status_code == 200, response.content.decode("utf-8") 260 | assert response["content-type"] == "application/json" 261 | assert json.loads(response.content.decode("utf-8")) == { 262 | JSONSuccessBackend().identifier(): JSONSuccessBackend().pretty_status() 263 | } 264 | 265 | def test_success_subset_define(self, client): 266 | class SuccessOneBackend(BaseHealthCheckBackend): 267 | def run_check(self): 268 | pass 269 | 270 | class SuccessTwoBackend(BaseHealthCheckBackend): 271 | def run_check(self): 272 | pass 273 | 274 | plugin_dir.reset() 275 | plugin_dir.register(SuccessOneBackend) 276 | plugin_dir.register(SuccessTwoBackend) 277 | 278 | HEALTH_CHECK["SUBSETS"] = { 279 | "startup-probe": ["SuccessOneBackend", "SuccessTwoBackend"], 280 | "liveness-probe": ["SuccessTwoBackend"], 281 | } 282 | 283 | response_startup_probe = client.get(self.url + "startup-probe/", {"format": "json"}) 284 | assert response_startup_probe.status_code == 200, response_startup_probe.content.decode("utf-8") 285 | assert response_startup_probe["content-type"] == "application/json" 286 | assert json.loads(response_startup_probe.content.decode("utf-8")) == { 287 | SuccessOneBackend().identifier(): SuccessOneBackend().pretty_status(), 288 | SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), 289 | } 290 | 291 | response_liveness_probe = client.get(self.url + "liveness-probe/", {"format": "json"}) 292 | assert response_liveness_probe.status_code == 200, response_liveness_probe.content.decode("utf-8") 293 | assert response_liveness_probe["content-type"] == "application/json" 294 | assert json.loads(response_liveness_probe.content.decode("utf-8")) == { 295 | SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), 296 | } 297 | 298 | def test_error_subset_not_found(self, client): 299 | plugin_dir.reset() 300 | response = client.get(self.url + "liveness-probe/", {"format": "json"}) 301 | print(f"content: {response.content}") 302 | print(f"code: {response.status_code}") 303 | assert response.status_code == 404, response.content.decode("utf-8") 304 | 305 | def test_error_param_json(self, client): 306 | class JSONErrorBackend(BaseHealthCheckBackend): 307 | def run_check(self): 308 | self.add_error("JSON Error") 309 | 310 | plugin_dir.reset() 311 | plugin_dir.register(JSONErrorBackend) 312 | response = client.get(self.url, {"format": "json"}) 313 | assert response.status_code == 500, response.content.decode("utf-8") 314 | assert response["content-type"] == "application/json" 315 | assert "JSON Error" in json.loads(response.content.decode("utf-8"))[JSONErrorBackend().identifier()] 316 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app 2 | 3 | __all__ = ["app"] 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "tests", 35 | ) 36 | 37 | MIDDLEWARE_CLASSES = ( 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.contrib.auth.middleware.AuthenticationMiddleware", 40 | "django.contrib.messages.middleware.MessageMiddleware", 41 | ) 42 | 43 | STATIC_URL = "/static/" 44 | 45 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 46 | 47 | SITE_ID = 1 48 | ROOT_URLCONF = "tests.testapp.urls" 49 | 50 | TEMPLATES = [ 51 | { 52 | "BACKEND": "django.template.backends.django.DjangoTemplates", 53 | "APP_DIRS": True, 54 | "OPTIONS": { 55 | "debug": True, 56 | }, 57 | }, 58 | ] 59 | 60 | SECRET_KEY = uuid.uuid4().hex 61 | 62 | USE_TZ = True 63 | 64 | CELERY_QUEUES = [ 65 | Queue("default"), 66 | Queue("queue2"), 67 | ] 68 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | 3 | urlpatterns = [ 4 | re_path(r"^ht/", include("health_check.urls")), 5 | ] 6 | --------------------------------------------------------------------------------