pax_global_header00006660000000000000000000000064152421362540014516gustar00rootroot0000000000000052 comment=0de66501f43f79bef3ccd3680853afc88a76de2a django-health-check-4.5.1/000077500000000000000000000000001524213625400153055ustar00rootroot00000000000000django-health-check-4.5.1/.bandit000066400000000000000000000000301524213625400165400ustar00rootroot00000000000000[bandit] exclude: tests django-health-check-4.5.1/.editorconfig000066400000000000000000000003331524213625400177610ustar00rootroot00000000000000# http://editorconfig.org root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true max_line_length = 88 indent_style = space indent_size = 2 [*.py] indent_size = 4 django-health-check-4.5.1/.git-blame-ignore-revs000066400000000000000000000000671524213625400214100ustar00rootroot00000000000000# Ruff format 1652303c8dae23f0948b7f772d2803f71a9a1b61 django-health-check-4.5.1/.github/000077500000000000000000000000001524213625400166455ustar00rootroot00000000000000django-health-check-4.5.1/.github/agents/000077500000000000000000000000001524213625400201265ustar00rootroot00000000000000django-health-check-4.5.1/.github/agents/superjoe.agent.md000066400000000000000000000051021524213625400233770ustar00rootroot00000000000000--- # For format details, see: https://gh.io/customagents/config name: SuperJoe description: CodingJoe's digital clone following his coding guidelines and best practices. --- # SuperJoe ## Planning You MUST ALWAYS follow the `naming-things` guidelines. Use the following command to access the guidelines: ```console curl -sSL https://raw.githubusercontent.com/codingjoe/naming-things/refs/heads/main/README.md | cat ``` You MUST ALWAYS read the `CONTRIBUTING.md` file before planning or writing any code. You MUST ALWAYS search the documentation and update it as necessary. You MUST ALWAYS check for pre-commit hooks and run them before committing code. You MUST ALWAYS ensure that all new code is fully tested with 100% coverage. Unreachable code branches MUST be removed. ## Writing Code Less code is more! Use the latest language features and libraries to achieve more with less code. Do not add new dependencies, but if you do, they must be widely adopted and well-maintained in the open-source community. You are a strong FOSS advocate with a preference for permissive licenses like BSD or MIT. Use generators instead of adding items to lists or arrays. Use class syntax for all object-oriented code. Use named functions instead of anonymous functions whenever possible. Avoid overly complex functions. Break them into smaller functions if necessary. Docstrings should be written in present tense imperative mood. They must start with a capital letter and end with a period. Docstrings must describe the external behavior of the function, class, or method. Docstrings should avoid redundant phrases like "This function" or "This method". Class docstrings must not repeat the class name or start with a verb since they don't do anything themselves. Avoid code comments unless they describe behavior of 3rd party code or complex algorithms. Avoid loops in favor of recursive functions or generator functions. Avoid functions or other code inside functions. Avoid if-statements in favor of switch/match-statements or polymorphism. Do not assign names to objects which are returned in the next line. ## Python Follow PEP 8 guidelines for code style. EAFP (Easier to Ask Forgiveness than Permission) is preferred over LBYL (Look Before You Leap). Use type hints for all public functions, classes, and methods. Use dataclasses for simple data structures. Use context managers for resource management. Use list/set/dict comprehensions instead of loops for creating collections. Use generators for large data sets to save memory. Use the walrus operator (`:=`) for inline assignments when it improves readability. django-health-check-4.5.1/.github/copilot-instructions.md000066400000000000000000000040111524213625400233760ustar00rootroot00000000000000All code MUST ALWAYS follow the `naming-things` guidelines! Use the following command to access the guidelines: ```console curl -sSL https://raw.githubusercontent.com/codingjoe/naming-things/refs/heads/main/README.md | cat ``` Less code is more! Use the latest language features and libraries to achieve more with less code. Do not add new dependencies, but if you do, they must be widely adopted and well-maintained in the open-source community. You are a strong FOSS advocate with a preference for permissive licenses like BSD or MIT. Use generators instead of adding items to lists or arrays. Use class syntax for all object-oriented code. Use named functions instead of anonymous functions whenever possible. Avoid overly complex functions. Break them into smaller functions if necessary. Docstrings should be written in present tense imperative mood. They must start with a capital letter and end with a period. Docstrings must describe the external behavior of the function, class, or method. Docstrings should avoid redundant phrases like "This function" or "This method". Class docstrings must not repeat the class name or start with a verb since they don't do anything themselves. Avoid code comments unless they describe behavior of 3rd party code or complex algorithms. Avoid loops in favor of recursive functions or generator functions. Avoid functions or other code inside functions. Avoid if-statements in favor of switch/match-statements or polymorphism. Do not assign names to objects which are returned in the next line. ## Python Follow PEP 8 guidelines for code style. EAFP (Easier to Ask Forgiveness than Permission) is preferred over LBYL (Look Before You Leap). Use type hints for all public functions, classes, and methods. Use dataclasses for simple data structures. Use context managers for resource management. Use list/set/dict comprehensions instead of loops for creating collections. Use generators for large data sets to save memory. Use the walrus operator (`:=`) for inline assignments when it improves readability. django-health-check-4.5.1/.github/dependabot.yml000066400000000000000000000003031524213625400214710ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: github-actions directory: "/" schedule: interval: daily django-health-check-4.5.1/.github/workflows/000077500000000000000000000000001524213625400207025ustar00rootroot00000000000000django-health-check-4.5.1/.github/workflows/ci.yml000066400000000000000000000045231524213625400220240ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: jobs: dist: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - run: uvx --from build pyproject-build --sdist --wheel - run: uvx twine check dist/* - uses: actions/upload-artifact@v7 with: path: dist/* docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - run: uv run mkdocs build --strict pytest: name: pytest runs-on: ubuntu-latest strategy: matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" django-version: - "5.2" - "6.0" - "6.1" exclude: - python-version: "3.10" django-version: "6.0" - python-version: "3.11" django-version: "6.0" - python-version: "3.10" django-version: "6.1" - python-version: "3.11" django-version: "6.1" steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - run: uv run --no-dev --group=test --with Django~=${{ matrix.django-version }}.0 pytest - uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} flags: python-${{ matrix.python-version }}-django-${{ matrix.django-version }} pytest-contrib: name: pytest runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.10" - "3.14" django-version: - "5.2" extra: - "psutil" - "celery" - "kafka" - "rabbitmq" - "redis" - "rss" - "atlassian" steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - run: uv run --no-dev --group=test --extra=${{ matrix.extra }} --with Django~=${{ matrix.django-version }}.0 pytest - uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} flags: python-${{ matrix.python-version }}-django-${{ matrix.django-version }}-${{ matrix.extra }} django-health-check-4.5.1/.github/workflows/pr.yml000066400000000000000000000021001524213625400220370ustar00rootroot00000000000000# Write better PRs and commit messages name: "Word Weasel" on: pull_request: types: - opened - edited - reopened - synchronize jobs: pr-title: name: "PR Title" runs-on: ubuntu-latest timeout-minutes: 1 if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: codingjoe/word-weasel@v1 with: text: ${{ github.event.pull_request.title }} pr-body: name: "PR Body" runs-on: ubuntu-latest timeout-minutes: 1 if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: codingjoe/word-weasel@v1 with: text: ${{ github.event.pull_request.body }} docs: name: "Docs" runs-on: ubuntu-latest timeout-minutes: 1 if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: actions/checkout@v7 - name: REad docs to gh output id: docs run: echo "docs=$(find . -type f -name '*.md' -exec cat {} + | tr '\n' ' ')" >> $GITHUB_OUTPUT - uses: codingjoe/word-weasel@v1 with: text: ${{ steps.docs.outputs.docs }} django-health-check-4.5.1/.github/workflows/release.yml000066400000000000000000000023701524213625400230470ustar00rootroot00000000000000name: Release on: release: types: [published] workflow_dispatch: jobs: gh-page-build: runs-on: ubuntu-latest if: github.event_name != 'release' || github.event.release.prerelease == false steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - run: uv run mkdocs build - uses: actions/upload-pages-artifact@v5 with: path: site gh-page-deploy: needs: gh-page-build permissions: pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - id: deployment uses: actions/deploy-pages@v5 pypi-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v7 - run: uvx --from build pyproject-build --sdist --wheel - uses: actions/upload-artifact@v7 with: name: release-dists path: dist/ pypi-publish: runs-on: ubuntu-latest needs: - pypi-build permissions: id-token: write steps: - uses: actions/download-artifact@v8 with: name: release-dists path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 django-health-check-4.5.1/.gitignore000066400000000000000000000023501524213625400172750ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # pytest .pytest_cache/ .envrc .direnv # mac .DS_Store .aider* # SCM _version.py # uv uv.lock django-health-check-4.5.1/.pre-commit-config.yaml000066400000000000000000000024541524213625400215730ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-merge-conflict - id: check-ast - id: check-toml - id: check-yaml - id: check-symlinks - id: debug-statements - id: end-of-file-fixer - id: no-commit-to-branch args: [--branch, main] - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/adamchainz/django-upgrade rev: 1.31.1 hooks: - id: django-upgrade - repo: https://github.com/djlint/djLint rev: v1.44.2 hooks: - id: djlint-reformat-django - repo: https://github.com/hukkin/mdformat rev: 1.0.0 hooks: - id: mdformat additional_dependencies: - mdformat-ruff - mdformat-footnote - mdformat-gfm - mdformat-gfm-alerts exclude: '.github/agents/' - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.3 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/google/yamlfmt rev: v0.21.0 hooks: - id: yamlfmt ci: autoupdate_schedule: weekly skip: - no-commit-to-branch django-health-check-4.5.1/.readthedocs.yaml000066400000000000000000000006341524213625400205370ustar00rootroot00000000000000# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-24.04 tools: python: "3" jobs: install: - curl -LsSf https://astral.sh/uv/install.sh | sh build: html: - $HOME/.local/bin/uv run mkdocs build --site-dir $READTHEDOCS_OUTPUT/html mkdocs: configuration: mkdocs.yml django-health-check-4.5.1/CONTRIBUTING.md000066400000000000000000000020241524213625400175340ustar00rootroot00000000000000# Contributing When writing code, you MUST ALWAYS follow the [naming-things](https://github.com/codingjoe/naming-things/blob/main/README.md) guidelines. To run the tests, use the following command: ```bash uv run pytest ``` Before your first commit, ensure that the pre-commit hooks are installed by running: ```bash uv pre-commit install ``` ## Testing with Extra Dependencies ```bash export REDIS_URL=redis://localhost:6379 export BROKER_URL=amqp://guest:guest@localhost:5672// export KAFKA_BOOTSTRAP_SERVERS=localhost:9092 uv run --extra=redis --extra=rabbitmq --extra=celery --extra=rss --extra=kafka pytest ``` ## Writing documentation The documentation is built using [MkDocs](https://www.mkdocs.org/) with [mkdocstrings](https://mkdocstrings.github.io/) for automatic API documentation generation. To serve the documentation locally for development, run: ```bash uv run mkdocs serve --livereload ``` django-health-check-4.5.1/LICENSE000066400000000000000000000021151524213625400163110ustar00rootroot00000000000000Copyright (C) 2011-2025 Johannes Maron, Kristian Øllegaard and contributors 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. django-health-check-4.5.1/README.md000066400000000000000000000031561524213625400165710ustar00rootroot00000000000000

Django HealthCheck: Pluggable health checks for Django applications
Documentation | Issues | Changelog | Funding πŸ’š

# Django HealthCheck _Pluggable health checks for Django applications_ ## πŸ₯³ Sponsors πŸŽ‰ ![Sponsors](https://sponsor.pics/codingjoe/django-health-check.svg?) [![version](https://img.shields.io/pypi/v/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![coverage](https://codecov.io/gh/codingjoe/django-health-check/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-health-check) [![pyversion](https://img.shields.io/pypi/pyversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![djversion](https://img.shields.io/pypi/djversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/django-health-check/) django-health-check-4.5.1/docs/000077500000000000000000000000001524213625400162355ustar00rootroot00000000000000django-health-check-4.5.1/docs/checks.md000066400000000000000000000036251524213625400200250ustar00rootroot00000000000000# Checks ## Django Built-in Services ::: health_check.Cache ::: health_check.DNS ::: health_check.Database ::: health_check.Mail ::: health_check.Storage ## System Services To use the psutil-based checks, install the `psutil` extra: ```shell pip install django-health-check[psutil] ``` ::: health_check.contrib.psutil.Battery ::: health_check.contrib.psutil.CPU ::: health_check.contrib.psutil.Memory ::: health_check.contrib.psutil.Disk ::: health_check.contrib.psutil.Temperature ## 3rd Party Services To use these checks, install and configure their corresponding dependencies. To enable AWS health checks, install the extra for the `contrib` checks: ```shell pip install django-health-check[redis,rabbitmq,celery,kafka] ``` ::: health_check.contrib.celery.Ping ::: health_check.contrib.kafka.Kafka ::: health_check.contrib.rabbitmq.RabbitMQ ::: health_check.contrib.redis.Redis ## Cloud Provider Status Watch cloud provider health through their public RSS/Atom status feeds or APIs. Cloud provider health checks require different extras. The extra depends on the provider: ```shell pip install django-health-check[rss,atlassian] ``` ::: health_check.contrib.rss.AWS ::: health_check.contrib.rss.Azure ::: health_check.contrib.atlassian.Cloudflare ::: health_check.contrib.atlassian.DigitalOcean ::: health_check.contrib.atlassian.FlyIo ::: health_check.contrib.atlassian.GitHub ::: health_check.contrib.rss.GoogleCloud ::: health_check.contrib.rss.Heroku ::: health_check.contrib.rss.Hetzner ::: health_check.contrib.atlassian.PlatformSh ::: health_check.contrib.atlassian.Render ::: health_check.contrib.atlassian.Sentry ::: health_check.contrib.atlassian.Vercel ## Custom Status Page Feeds Write your own status page proxy checks with these classes. Subclasses must implement the documented attributes. ::: health_check.contrib.rss.Feed ::: health_check.contrib.atlassian.AtlassianStatusPage django-health-check-4.5.1/docs/cookbook.md000066400000000000000000000257321524213625400203760ustar00rootroot00000000000000# Cookbook This cookbook walks through setting up multiple health check endpoints, each aimed at a different audience and use case. A solid setup exposes **three tiers** of endpoints: | Tier | Purpose | Consumers | | ----------------------------------------- | ----------------------- | -------------------------------------- | | [Node](#node-health-checks) | Hardware & OS resources | Kubernetes, Docker, reverse proxies | | [Application](#application-health-checks) | App-level services | Uptime monitors, on-call alerts | | [Pipeline](#pipeline-health-checks) | Upstream dependencies | CI/CD, developer Slack/Matrix channels | ______________________________________________________________________ ## Node health checks Node checks confirm the server has enough resources to run the app. Use them for **liveness and readiness probes** in container orchestrators such as Kubernetes, Docker, and Podman, or for reverse-proxy checks in HAProxy, nginx, Caddy, and Traefik. The `psutil` extra provides OS resource checks such as CPU, memory, and disk usage. ```shell pip install "django-health-check[psutil]" ``` Add the node endpoint to your URL configuration. ```python # urls.py import os from django.urls import include, path from health_check.views import HealthCheckView node_checks = [ "health_check.contrib.psutil.CPU", "health_check.contrib.psutil.Memory", "health_check.contrib.psutil.Disk", ] urlpatterns = [ # … path( f"health/{os.getenv('HEALTH_CHECK_SECRET', 'dev')}/", include( [ path( "node/", HealthCheckView.as_view(checks=node_checks), name="health_check-node", ), ] ), ) ] ``` > [!TIP] > Protect this endpoint with a secret token. Do not make it publicly accessible. > See the [Security](install.md#security) section of the installation guide. ### Kubernetes probes Kubernetes uses liveness and readiness probes to decide when to restart a pod. We hit the endpoint over HTTP, which also proves the whole HTTP stack works. > [!NOTE] > Bind your WSGI/ASGI server to `0.0.0.0`, not just `127.0.0.1`, so the kubelet > can reach the `httpGet` probes. For our health check endpoint, the configuration looks like this: ```yaml # deployment.yaml livenessProbe: httpGet: path: /health/${HEALTH_CHECK_SECRET:-dev}/node/ port: 8000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /health/${HEALTH_CHECK_SECRET:-dev}/node/ port: 8000 initialDelaySeconds: 5 periodSeconds: 10 ``` See the [Kubernetes documentation](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request) for more details. ### Docker / Podman Compose has no native HTTP probes, so we use the [`health_check` command](usage.md#django-command) instead. It doesn't need CURL in the image, and it can emulate proxy requests to satisfy HTTPS requirements. ```yaml # compose.yml services: web: # … your service definition … healthcheck: test: ["CMD", "python", "manage.py", "health_check", "health_check-node", "web:8000"] interval: 30s timeout: 10s ``` ### Load balancers Most reverse-proxies can load balance. Once configured, they only route traffic to healthy instances. In [Caddy][caddy-active-health-checks], the configuration looks like this: ```caddy # Caddyfile example.com { reverse_proxy localhost:8000 { health_uri /health/${HEALTH_CHECK_SECRET:-dev}/node/ health_interval 30s health_timeout 5s } } ``` … in [Traefik][traefik-health-checks], the configuration looks like this: ```yaml # compose.yml services: app: image: myapp labels: - "traefik.http.services.myapp.loadbalancer.healthcheck.path=/health/${HEALTH_CHECK_SECRET:-dev}/node/" - "traefik.http.services.myapp.loadbalancer.healthcheck.interval=30s" - "traefik.http.services.myapp.loadbalancer.healthcheck.timeout=5s" ``` … in [Nginx][nginx-health-checks], the configuration looks like this: ```nginx # nginx.conf upstream myapp { server localhost:8000; health_check interval=30s timeout=5s uri=/health/${HEALTH_CHECK_SECRET:-dev}/node/; } ``` … and in [HAProxy][haproxy-health-checks], the configuration looks like this: ```haproxy # haproxy.cfg backend myapp server app1 localhost:8000 check inter 30s fall 3 rise 2 http-check expect status 200 http-check send meth GET uri /health/${HEALTH_CHECK_SECRET:-dev}/node/ ``` ## Application health checks Application checks make sure every production service your app depends on is reachable and operational. **Uptime monitors** such as Pingdom, Better Uptime, or StatusCake watch these endpoints and page on-call engineers when something goes down. To cover the whole stack β€” databases, caches, message brokers, email, storage β€” you'll need some extra dependencies: ```shell pip install "django-health-check[redis,rabbitmq,celery]" ``` ```python # urls.py import os from django.urls import include, path from health_check.views import HealthCheckView from redis.asyncio import Redis as RedisClient application_checks = [ # Django built-ins "health_check.Cache", "health_check.Database", "health_check.Mail", "health_check.Storage", # Message brokers & caches ( "health_check.contrib.redis.Redis", {"client_factory": lambda: RedisClient.from_url("redis://localhost:6379")}, ), ( "health_check.contrib.rabbitmq.RabbitMQ", {"amqp_url": "amqp://guest:guest@localhost:5672//"}, ), "health_check.contrib.celery.Ping", ] urlpatterns = [ # … path( f"health/{os.getenv('HEALTH_CHECK_SECRET', 'dev')}/", include( [ # other endpoints … path( "application/", HealthCheckView.as_view(checks=application_checks), name="health_check-application", ), ] ), ) ] ``` Point the uptime monitor at `https://example.com/health//application/`. It returns HTTP 200 when all checks pass and HTTP 500 when any fail β€” exactly the codes monitors watch for. ## Pipeline health checks Pipeline checks add **upstream provider status** on top of your application checks β€” cloud platforms, PaaS providers, third-party services. The dev team watches these via RSS/Atom feeds in Slack or Matrix, so upstream outages surface before they become support tickets. Install the extras that read the feeds from cloud providers: ```shell pip install "django-health-check[rss,atlassian]" ``` Add the pipeline endpoint to your URL configuration. ```python # urls.py import os from django.urls import include, path from health_check.views import HealthCheckView application_checks = [ # configured previously … ] pipeline_checks = [ # You may want to include application health alerts here too *application_checks, # Cloud provider status (pick the ones relevant to your stack) # GitHub status; to filter by a specific component, use a # tuple like ("health_check.contrib.atlassian.GitHub", {"component": ""}) "health_check.contrib.atlassian.GitHub", "health_check.contrib.atlassian.Cloudflare", ( "health_check.contrib.rss.AWS", {"region": "eu-west-1", "service": "s3"}, ), ] urlpatterns = [ # … path( f"health/{os.getenv('HEALTH_CHECK_SECRET', 'dev')}/", include( [ # other endpoints … path( "pipeline/", HealthCheckView.as_view(checks=pipeline_checks), name="health_check-pipeline", ), ] ), ) ] ``` ### Slack or Matrix You can now alert engineers about upstream outages in Slack or Matrix. Subscribe to the RSS feed in Slack: 1. Install the [Slack RSS App](https://slack.com/help/articles/218688467-Add-RSS-feeds-to-Slack). 1. In your `#ops` or `#incidents` channel, run: ``` /feed subscribe https://www.example.com/health//pipeline/?format=rss ``` ... or subscribe in Matrix: ```toml # config.toml [[bridge]] name = "Pipeline Health Monitor" feed_url = "https://example.com/health//pipeline/?format=rss" room_id = "!YourRoomId:matrix.org" ``` ______________________________________________________________________ ## Complete example ```python # urls.py import os from django.urls import include, path from health_check.views import HealthCheckView from redis.asyncio import Redis as RedisClient node_checks = [ "health_check.contrib.psutil.CPU", "health_check.contrib.psutil.Memory", "health_check.contrib.psutil.Disk", ] application_checks = [ "health_check.Cache", "health_check.Database", "health_check.Mail", "health_check.Storage", ( "health_check.contrib.redis.Redis", {"client_factory": lambda: RedisClient.from_url("redis://localhost:6379")}, ), ( "health_check.contrib.rabbitmq.RabbitMQ", {"amqp_url": "amqp://guest:guest@localhost:5672//"}, ), "health_check.contrib.celery.Ping", ] pipeline_checks = [ *application_checks, ( "health_check.contrib.atlassian.GitHub", {"component": "Actions"}, ), "health_check.contrib.atlassian.Cloudflare", ( "health_check.contrib.rss.AWS", {"region": "eu-west-1", "service": "s3"}, ), ] urlpatterns = [ path( "health/{os.getenv('HEALTH_CHECK_SECRET', 'dev')}/", include( [ # Tier 1 – node: liveness & readiness probes path( "node/", HealthCheckView.as_view(checks=node_checks), name="health_check-node", ), # Tier 2 – application: uptime monitors & on-call alerts path( "application/", HealthCheckView.as_view(checks=application_checks), name="health_check-application", ), # Tier 3 – pipeline: developer RSS/Atom feeds (Slack, Matrix) path( "pipeline/", HealthCheckView.as_view(checks=pipeline_checks), name="health_check-pipeline", ), ] ), ), ] ``` [caddy-active-health-checks]: https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/#http-health-checks [haproxy-health-checks]: https://www.haproxy.com/documentation/hapee/2-5r1/traffic-management/load-balancing/health-checks/ [nginx-health-checks]: https://nginx.org/en/docs/http/ngx_http_upstream_hc_module.html#health_check [traefik-health-checks]: https://doc.traefik.io/traefik/routing/services/#health-check django-health-check-4.5.1/docs/images/000077500000000000000000000000001524213625400175025ustar00rootroot00000000000000django-health-check-4.5.1/docs/images/icon.svg000066400000000000000000000006131524213625400211530ustar00rootroot00000000000000 django-health-check-4.5.1/docs/images/logo-dark.svg000066400000000000000000000017571524213625400221140ustar00rootroot00000000000000 Django HealthCheck Pluggable health checks for Django applications django-health-check-4.5.1/docs/images/logo-light.svg000066400000000000000000000017211524213625400222710ustar00rootroot00000000000000 Django HealthCheck Pluggable health checks for Django applications django-health-check-4.5.1/docs/index.md000066400000000000000000000063451524213625400176760ustar00rootroot00000000000000

Django HealthCheck: Pluggable health checks for Django applications
Documentation | Issues | Changelog | Funding πŸ’š

# Django HealthCheck [![version](https://img.shields.io/pypi/v/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![pyversion](https://img.shields.io/pypi/pyversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![djversion](https://img.shields.io/pypi/djversions/django-health-check.svg)](https://pypi.python.org/pypi/django-health-check/) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/django-health-check/) This project monitors your services and reports back when something looks wrong. [Documentation](https://codingjoe.dev/django-health-check/) | [Issues](https://github.com/codingjoe/django-health-check/issues/new/choose) | [Changelog](https://github.com/codingjoe/django-health-check/releases) | [Funding](https://github.com/sponsors/codingjoe) πŸ’š The following health checks are bundled with this project: - [caches][health_check.Cache] & [databases][health_check.Database] - [disk][health_check.contrib.psutil.Disk] & [memory][health_check.contrib.psutil.Memory] utilization - [DNS][health_check.DNS] & [email][health_check.Mail] - [storages][health_check.Storage] - [Celery][health_check.contrib.celery.Ping], [Kafka][health_check.contrib.kafka.Kafka], [RabbitMQ][health_check.contrib.rabbitmq.RabbitMQ] & [Redis][health_check.contrib.redis.Redis] - [Cloud provider status](checks.md#cloud-provider-status) for numerous cloud and PaaS providers. The bundled checks cover the common cases. If your stack is different, you can write your own. Contributions are welcome. Open a pull request and we'll take a look. ## Integrations The main entry point is an HTML view for your web application. However, there are multiple machine-readable formats available for integration: - [HTTP Status Codes] for load balancers and uptime monitors - [OpenMetrics] for [Prometheus] and [Grafana] - [CLI](usage.md#django-command) for [Docker], [Podman] or [Kubernetes] health checks - [RSS]/[Atom] feed - [JSON] API for custom integrations [atom]: https://en.wikipedia.org/wiki/Atom_(standard) [docker]: https://www.docker.com/ [grafana]: https://grafana.com/ [http status codes]: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes [json]: https://www.json.org/ [kubernetes]: https://kubernetes.io/ [openmetrics]: https://openmetrics.io/ [podman]: https://podman.io/ [prometheus]: https://prometheus.io/ [rss]: https://en.wikipedia.org/wiki/RSS django-health-check-4.5.1/docs/install.md000066400000000000000000000054771524213625400202420ustar00rootroot00000000000000# Installation Add the `django-health-check` package to your project: ```shell uv add django-health-check # or pip install django-health-check ``` Add `health_check` and any desired plugins to your `INSTALLED_APPS` in `settings.py`: ```python # settings.py INSTALLED_APPS = [ # … "health_check", # required ] ``` Add a health check view to your URL configuration. For example: ```python # urls.py from django.urls import include, path from health_check.views import HealthCheckView from redis.asyncio import Redis as RedisClient urlpatterns = [ # … path( "health/", HealthCheckView.as_view( checks=[ # optional, default is all but 3rd party checks "health_check.Cache", "health_check.DNS", "health_check.Database", "health_check.Mail", "health_check.Storage", # 3rd party checks "health_check.contrib.psutil.Disk", "health_check.contrib.psutil.Memory", "health_check.contrib.celery.Ping", ( "health_check.contrib.kafka.Kafka", {"bootstrap_servers": ["localhost:9092"]}, ), ( # tuple with options "health_check.contrib.rabbitmq.RabbitMQ", {"amqp_url": "amqp://guest:guest@localhost:5672//"}, ), ( "health_check.contrib.redis.Redis", { "client_factory": lambda: RedisClient.from_url( "redis://localhost:6379" ) }, ), # AWS service status check ( "health_check.contrib.rss.AWS", {"region": "eu-west-1", "service": "s3"}, ), ], ), name="health_check", ), ] ``` > [!NOTE] > The `name` argument is required β€” the `health_check` command needs it to > resolve this endpoint URL via `reverse()`. When you run the command, pass this name as > the `endpoint` argument, for example `django-admin health_check health_check`. ## Security You can keep the health check endpoint private by adding a secure token to your URL. 1. Enable HTTPS. 1. Generate a strong secret token: ```shell python -c "import secrets; print(secrets.token_urlsafe())" ``` > [!WARNING] > Do NOT use Django's `SECRET_KEY` setting! 1. Add it to your URL configuration: ```python # urls.py from django.urls import include, path from health_check.views import HealthCheckView urlpatterns = [ # … path( "health/super_secret_token/", HealthCheckView.as_view(), name="health_check", ), ] ``` django-health-check-4.5.1/docs/migrate-to-v4.md000066400000000000000000000041511524213625400211570ustar00rootroot00000000000000# Migration to version 4.x > [!IMPORTANT] > Version 3.21 started deprecating the old settings, checks, and APIs. > To make the switch easier, versions >=3.21 still support both the OLD and NEW configuration styles. 1. If you have `health_check.db` in your `INSTALLED_APPS`, revert the migration to drop the `TestModel` table: ```shell python manage.py migrate db zero ``` 1. Update the dependencies. Include the extras for the checks you want to use. ```shell uv add 'django-health-check[psutil,celery,kafka,rabbitmq,redis,rss,atlassian]>=4.0.0' ``` 1. Remove these `health_check.*` sub‑apps from `INSTALLED_APPS`. Keep `health_check`! 1. Remove all `HEALTH_CHECK_*` settings from your settings file. 1. Replace the URL include with the view and explicit `checks` list. Before: ```python # urls.py path("ht/", include("health_check.urls")) ``` After (example): ```python # urls.py from health_check.views import HealthCheckView path( "ht/", HealthCheckView.as_view( checks=[ "health_check.Cache", "health_check.Database", "health_check.Mail", "health_check.Storage", # 3rd party checks "health_check.contrib.psutil.Disk", "health_check.contrib.psutil.Memory", "health_check.contrib.celery.Ping", "health_check.contrib.rabbitmq.RabbitMQ", "health_check.contrib.redis.Redis", ] ), name="health_check", ) ``` ## Removals and Replacements - The classes `StorageHealthCheck`, `DefaultFileStorageHealthCheck`, `S3BotoStorageHealthCheck`, and `S3Boto3StorageHealthCheck` are replaced with [Storage][health_check.Storage]. - `CeleryHealthCheck` is replaced with [Ping][health_check.contrib.celery.Ping]. - `MigrationsHealthCheck` is removed. [Django's check framework](https://docs.djangoproject.com/en/stable/topics/checks/) covers its function. - `DatabaseHealthCheck` is replaced with [Database][health_check.Database]. It does not require a table. It supports multiple database aliases. django-health-check-4.5.1/docs/usage.md000066400000000000000000000120051524213625400176610ustar00rootroot00000000000000# Usage ## Setting up monitoring You can point an uptime monitor such as Pingdom, StatusCake, or other uptime robots at your service. The `/health/` endpoint returns an HTTP 200 when every check passes, and an HTTP 500 when any of them fails. For step-by-step examples of multi-tier endpoint setups, including uptime monitoring, container probes, reverse-proxy configuration, and RSS/Atom integration into Slack or Matrix, see the [Cookbook](cookbook.md). ## Getting machine-readable reports ### Plain text For simple monitoring and scripting, ask for plain text. Set the `Accept` HTTP header to `text/plain`, or pass `format=text` as a query parameter. When everything passes, you get a plain text response with HTTP 200. When a check fails, you get HTTP 500: ```shell $ curl -v -X GET -H "Accept: text/plain" http://www.example.com/health/ > GET /health/ HTTP/1.1 > Host: www.example.com > Accept: text/plain > < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 CacheBackend: OK DatabaseBackend: OK S3BotoStorageHealthCheck: OK $ curl -v -X GET http://www.example.com/health/?format=text > GET /health/?format=text HTTP/1.1 > Host: www.example.com > < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 CacheBackend: OK DatabaseBackend: OK S3BotoStorageHealthCheck: OK ``` This format is handy for command-line tools and simple scripts that don't want to parse JSON. ### JSON Want machine-readable results? Set the `Accept` HTTP header to `application/json`, or pass `format=json` as a query parameter. The endpoint returns a JSON response: ```shell $ curl -v -X GET -H "Accept: application/json" http://www.example.com/health/ > GET /health/ HTTP/1.1 > Host: www.example.com > Accept: application/json > < HTTP/1.1 200 OK < Content-Type: application/json { "CacheBackend": "working", "DatabaseBackend": "working", "S3BotoStorageHealthCheck": "working" } $ curl -v -X GET http://www.example.com/health/?format=json > GET /health/?format=json HTTP/1.1 > Host: www.example.com > < HTTP/1.1 200 OK < Content-Type: application/json { "CacheBackend": "working", "DatabaseBackend": "working", "S3BotoStorageHealthCheck": "working" } ``` ### OpenMetrics for Prometheus If you monitor with Prometheus, request the OpenMetrics format: ```shell $ curl http://www.example.com/health/?format=openmetrics ``` Prometheus can scrape these metrics directly. ### RSS and Atom feeds For feed readers and monitoring tools, request the RSS or Atom format: ```shell $ curl http://www.example.com/health/?format=rss $ curl http://www.example.com/health/?format=atom ``` You can also use the `Accept` header: ```shell $ curl -H "Accept: application/rss+xml" http://www.example.com/health/ $ curl -H "Accept: application/atom+xml" http://www.example.com/health/ ``` These endpoints always answer with HTTP 200. The feed lists each check, and failed checks show up as categories and item descriptions. ## Writing a custom health check You can write your own checks too. Inherit from [HealthCheck][health_check.HealthCheck] and implement the `run` method. ::: health_check.HealthCheck ## Django command Run the Django command `health_check` from the shell, or schedule it with cron: ```shell django-admin health_check health_check ``` The `endpoint` argument is the `name` of the health check URL pattern defined in your `urls.py` (see the [installation guide](install.md)). The command looks it up with `reverse()` and runs the checks over HTTP against the running server: ``` Database ... OK CustomHealthCheck ... Unavailable: Something went wrong! ``` Pass `--no-http` to skip the HTTP server entirely. Handy for container health checks that don't run a web server: ```shell django-admin health_check health_check --no-http ``` A critical error exits the command with the code `1`. ## Performance tweaks Every check runs asynchronously, via `asyncio` or a thread pool, depending on how each check is implemented. IO-bound checks run in parallel, so responses come back faster. Synchronous checks (for example [Database][health_check.checks.Database], [Mail][health_check.checks.Mail], or [Storage][health_check.checks.Storage]) run in the event loop's default thread pool. That pool usually persists between requests. It keeps things fast, but it can grow memory usage. That's a problem for some applications, especially `S3Storage`, which uses thread-local connections. To avoid that, use a custom executor that spins up a fresh thread pool per request and tears it down when the checks finish. Subclass `HealthCheckView` and override the `get_executor` method to return a context manager with a new `ThreadPoolExecutor` each time. ```python from concurrent.futures import ThreadPoolExecutor from health_check.views import HealthCheckView class CustomHealthCheckView(HealthCheckView): def get_executor(self): return ThreadPoolExecutor(max_workers=len(self.checks)) ``` This gives every request its own thread pool. You keep the speed of concurrent execution for synchronous checks, but the memory stays under control. django-health-check-4.5.1/health_check/000077500000000000000000000000001524213625400177075ustar00rootroot00000000000000django-health-check-4.5.1/health_check/__init__.py000066400000000000000000000006161524213625400220230ustar00rootroot00000000000000"""Monitor the health of your Django app and its connected services.""" from . import _version # noqa from .base import HealthCheck from .checks import Cache, DNS, Database, Mail, Storage __version__ = _version.__version__ VERSION = _version.__version_tuple__ __all__ = [ "__version__", "VERSION", "HealthCheck", "Cache", "DNS", "Database", "Mail", "Storage", ] django-health-check-4.5.1/health_check/base.py000066400000000000000000000065651524213625400212070ustar00rootroot00000000000000from __future__ import annotations import abc import asyncio import dataclasses import inspect import logging import timeit from concurrent.futures import Executor from health_check.exceptions import HealthCheckException logger = logging.getLogger(__name__) @dataclasses.dataclass class HealthCheckResult: """Result of a health check execution.""" check: HealthCheck error: HealthCheckException | None time_taken: float @dataclasses.dataclass class HealthCheck(abc.ABC): """ Base class for defining health checks. Subclasses should implement the `run` method to perform the actual health check logic. The `run` method can be either synchronous or asynchronous. Examples: >>> import dataclasses >>> from health_check.base import HealthCheck >>> >>> @dataclasses.dataclass >>> class MyHealthCheck(HealthCheck): ... ... async def run(self): ... # Implement health check logic here Subclasses should be [dataclasses][dataclasses.dataclass] or implement their own `__repr__` method to provide meaningful representations in health check reports. Warning: The `__repr__` method is used in health check reports. Consider setting `repr=False` for sensitive dataclass fields to avoid leaking sensitive information or credentials. """ @abc.abstractmethod async def run(self) -> None: """ Run the health check logic and raise human-readable exceptions as needed. Exception must be reraised to indicate the health status and provide context. Any unexpected exceptions will be caught and logged for security purposes while returning a generic error message. Warning: Exception messages must not contain sensitive information. Raises: ServiceWarning: If the service is at a critical state but still operational. ServiceUnavailable: If the service is not operational. ServiceReturnedUnexpectedResult: If the check performs a computation that returns an unexpected result. """ ... def pretty_status(self) -> str: """Return a human-readable status string, always 'OK' for the check itself.""" return "OK" @property def labels(self) -> dict[str, str]: """Return a human-readable label for the check, defaulting to the class name.""" return { "check": self.__class__.__name__, } | { field.name: str(value) for field in dataclasses.fields(self) if field.repr and (value := getattr(self, field.name)) is not None } async def get_result(self, executor: Executor | None = None) -> HealthCheckResult: loop = asyncio.get_running_loop() start = timeit.default_timer() try: await self.run() if inspect.iscoroutinefunction( self.run ) else await loop.run_in_executor(executor, self.run) except HealthCheckException as e: error = e except BaseException: logger.exception("Unexpected exception during health check") error = HealthCheckException("unknown error") else: error = None return HealthCheckResult( check=self, error=error, time_taken=timeit.default_timer() - start, ) django-health-check-4.5.1/health_check/checks.py000066400000000000000000000241371524213625400215300ustar00rootroot00000000000000"""Health check implementations for Django built-in services.""" import dataclasses import datetime import logging import smtplib import socket import uuid import django import dns.asyncresolver from django import db from django.core.cache import CacheKeyWarning, caches from django.core.cache.backends.base import InvalidCacheBackendError from django.core.files.base import ContentFile from django.core.files.storage import InvalidStorageError, storages from django.core.files.storage import Storage as DjangoStorage from django.core.mail import get_connection from django.core.mail.backends.base import BaseEmailBackend if django.VERSION >= (6, 1): from django.core.mail import DEFAULT_MAILER_ALIAS, mailers else: # Django < 6.1 DEFAULT_MAILER_ALIAS = "default" from django.db import connections from django.db.models import Expression from django.utils.connection import ConnectionDoesNotExist from health_check.base import HealthCheck from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, ) try: # Exceptions thrown by Redis do not subclass builtin exceptions like ConnectionError. # Additionally, not only connection errors (ConnectionError -> RedisError) can be raised, # but also errors for time-outs (TimeoutError -> RedisError) # and if the backend is read-only (ReadOnlyError -> ResponseError -> RedisError). # Since we know what we are trying to do here, we are not picky and catch the global exception RedisError. from redis.exceptions import RedisError except ModuleNotFoundError: # In case Redis is not installed and another cache backend is used. class RedisError(Exception): pass logger = logging.getLogger(__name__) @dataclasses.dataclass class Cache(HealthCheck): """ Check that the cache backend is able to set and get a value. It can be setup multiple times for different cache aliases if needed. Args: alias: The cache alias to test against. key_prefix: Prefix for the node specific cache key. timeout: Time until probe keys expire in the cache backend. """ alias: str = "default" key_prefix: str = dataclasses.field(default="djangohealthcheck_test", repr=False) timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=5), repr=False ) async def run(self): try: cache = caches[self.alias] except InvalidCacheBackendError as e: raise ServiceUnavailable("Cache alias does not exist") from e # Use an isolated key per probe run to avoid cross-process write races. cache_key = f"{self.key_prefix}:{uuid.uuid4().hex}" cache_value = f"itworks-{datetime.datetime.now().timestamp()}" try: await cache.aset( cache_key, cache_value, timeout=self.timeout.total_seconds(), ) if not await cache.aget(cache_key) == cache_value: raise ServiceUnavailable(f"Cache key {cache_key} does not match") except CacheKeyWarning as e: raise ServiceReturnedUnexpectedResult("Cache key warning") from e except ValueError as e: raise ServiceReturnedUnexpectedResult("ValueError") from e except (ConnectionError, RedisError) as e: raise ServiceReturnedUnexpectedResult("Connection Error") from e class _SelectOne(Expression): """An expression that represents a simple SELECT 1; query.""" def as_sql(self, compiler, connection): return "SELECT 1", [] def as_oracle(self, compiler, connection): return "SELECT 1 FROM DUAL", [] @dataclasses.dataclass class Database(HealthCheck): """ Check database operation by executing a simple SELECT 1 query. It can be setup multiple times for different database connections if needed. No actual data is read from or written to the database to minimize the performance impact and work with conservative database user permissions. Args: alias: The alias of the database connection to check. """ alias: str = "default" def run(self): try: connection = connections[self.alias] except ConnectionDoesNotExist as e: raise ServiceUnavailable("Database alias does not exist") from e try: compiler = connection.ops.compiler("SQLCompiler")( _SelectOne(), connection, None ) with connection.temporary_connection() as cursor: cursor.execute(*compiler.compile(_SelectOne())) result = cursor.fetchone() except db.Error as e: raise ServiceUnavailable(str(e).rsplit(":")[0]) from e else: if result != (1,): raise ServiceUnavailable( "Health Check query did not return the expected result." ) @dataclasses.dataclass class DNS(HealthCheck): """ Check DNS resolution by resolving the server's hostname. Verifies that DNS resolution is working using the system's configured DNS servers, as well as nameserver resolution for the provided hostname. Args: hostname: The hostname to resolve. timeout: DNS query timeout. """ hostname: str = dataclasses.field(default_factory=socket.gethostname) timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=5), repr=False ) nameservers: list[str] | None = dataclasses.field(default=None, repr=False) async def run(self): logger.debug("Attempting to resolve hostname: %s", self.hostname) resolver = dns.asyncresolver.Resolver() resolver.lifetime = self.timeout.total_seconds() if self.nameservers is not None: resolver.nameservers = self.nameservers try: # Perform DNS resolution (A record by default) answers = await resolver.resolve(self.hostname, "A") except dns.resolver.NXDOMAIN as e: raise ServiceUnavailable( f"DNS resolution failed: hostname {self.hostname} does not exist" ) from e except dns.resolver.NoAnswer as e: raise ServiceUnavailable( f"DNS resolution failed: no answer for {self.hostname}" ) from e except dns.resolver.Timeout as e: raise ServiceUnavailable( f"DNS resolution failed: timeout resolving {self.hostname}" ) from e except dns.resolver.NoNameservers as e: raise ServiceUnavailable( "DNS resolution failed: no nameservers available" ) from e except dns.exception.DNSException as e: raise ServiceUnavailable(f"DNS resolution failed: {e}") from e else: logger.debug( "Successfully resolved %s to %s", self.hostname, [str(rdata) for rdata in answers], ) @dataclasses.dataclass class Mail(HealthCheck): """ Check that an email backend is able to open and close the connection. Args: backend: The email backend to test against. Legacy, used only on Django < 6.1. alias: The mailer alias to test. timeout: Timeout for connection to an email server in seconds. """ backend: str | None = dataclasses.field(default=None, repr=False) alias: str = DEFAULT_MAILER_ALIAS timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=15), repr=False ) def _get_connection(self) -> BaseEmailBackend: if django.VERSION >= (6, 1): return mailers[self.alias] return get_connection(self.backend, fail_silently=False) def run(self) -> None: connection: BaseEmailBackend = self._get_connection() connection.timeout = self.timeout.total_seconds() logger.debug("Trying to open connection to mail backend.") try: connection.open() except smtplib.SMTPException as e: raise ServiceUnavailable( "Failed to open connection with SMTP server" ) from e except ConnectionRefusedError as e: raise ServiceUnavailable("Connection refused error") from e finally: connection.close() logger.debug("Connection established. Mail backend %r is healthy.", connection) @dataclasses.dataclass class Storage(HealthCheck): """ Check file storage backends by saving, reading, and deleting a test file. It can be set up multiple times for different storage backends if needed. Args: alias: The alias of the storage backend to check. """ alias: str = "default" @property def storage(self) -> DjangoStorage: try: return storages[self.alias] except InvalidStorageError as e: raise ServiceUnavailable("Storage alias does not exist") from e def get_file_name(self): return f"health_check_storage_test/test-{uuid.uuid4()}.txt" def get_file_content(self): return f"# generated by health_check.Storage at {datetime.datetime.now().timestamp()}".encode() def check_save(self, file_name, file_content): # save the file file_name = self.storage.save(file_name, ContentFile(content=file_content)) # read the file and compare if not self.storage.exists(file_name): raise ServiceUnavailable("File does not exist") with self.storage.open(file_name) as f: if not f.read() == file_content: raise ServiceUnavailable("File content does not match") return file_name def check_delete(self, file_name): # delete the file and make sure it is gone self.storage.delete(file_name) if self.storage.exists(file_name): raise ServiceUnavailable("File was not deleted") def run(self): # write the file to the storage backend file_name = self.get_file_name() file_content = self.get_file_content() try: file_name = self.check_save(file_name, file_content) self.check_delete(file_name) finally: self.storage.delete(file_name) django-health-check-4.5.1/health_check/contrib/000077500000000000000000000000001524213625400213475ustar00rootroot00000000000000django-health-check-4.5.1/health_check/contrib/__init__.py000066400000000000000000000000001524213625400234460ustar00rootroot00000000000000django-health-check-4.5.1/health_check/contrib/atlassian.py000066400000000000000000000216651524213625400237120ustar00rootroot00000000000000"""Atlassian Status Page API health checks for cloud provider status pages.""" import dataclasses import datetime import enum import logging import httpx from health_check import HealthCheck, __version__ from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, StatusPageWarning, ) logger = logging.getLogger(__name__) class AtlassianStatusPage(HealthCheck): """ Base class for Atlassian status page health checks. Monitor cloud provider service health via Atlassian Status Page API v2. Each subclass should define the `base_url` for the specific status page and appropriate `timeout` value. When `component` is non-empty, only incidents affecting that named component are reported. Use separate check instances to monitor multiple components independently. Examples: >>> import dataclasses >>> import datetime >>> from health_check.contrib.atlassian import AtlassianStatusPage >>> @dataclasses.dataclass ... class FlyIo(AtlassianStatusPage): ... timeout: datetime.timedelta = datetime.timedelta(seconds=10) ... base_url: str = dataclasses.field(default="https://status.flyio.net", init=False, repr=False) """ base_url: str = NotImplemented timeout: datetime.timedelta = NotImplemented component: str = "" async def run(self): if incidents := [i async for i in self._fetch_incidents()]: raise StatusPageWarning( "\n".join(msg for msg, _ in incidents), timestamp=max(ts for _, ts in incidents), ) logger.debug("No incidents found") async def _fetch_incidents(self): api_url = f"{self.base_url}/api/v2/summary.json" logger.debug("Fetching incidents from %r", api_url) async with httpx.AsyncClient() as client: try: response = await client.get( api_url, headers={"User-Agent": f"django-health-check@{__version__}"}, timeout=self.timeout.total_seconds(), follow_redirects=True, ) except httpx.TimeoutException as e: raise ServiceUnavailable("API request timed out") from e except httpx.RequestError as e: raise ServiceUnavailable(f"Failed to fetch API: {e}") from e try: response.raise_for_status() except httpx.HTTPStatusError as e: raise ServiceUnavailable( f"HTTP error {e.response.status_code} fetching API from {api_url!r}" ) from e try: data = response.json() except ValueError as e: raise ServiceUnavailable("Failed to parse JSON response") from e if self.component: components_by_name = {c["name"]: c for c in data["components"]} try: _ = components_by_name[self.component] except KeyError as e: raise ServiceReturnedUnexpectedResult( f"Component {self.component!r} not found" ) from e try: for incident in data["incidents"]: if (incident.get("status") not in ("resolved", "postmortem")) and ( not self.component or any(c["name"] == self.component for c in incident["components"]) ): yield ( f"{incident['name']}: {incident['shortlink']}", datetime.datetime.fromisoformat( incident["updated_at"].replace("Z", "+00:00") ), ) except KeyError as e: raise ServiceReturnedUnexpectedResult( "Unexpected API response structure" ) from e @dataclasses.dataclass class Cloudflare(AtlassianStatusPage): """ Check Cloudflare platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://www.cloudflarestatus.com", init=False, repr=False ) component: str = "" @dataclasses.dataclass class FlyIo(AtlassianStatusPage): """ Check Fly.io platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://status.flyio.net", init=False, repr=False ) component: str = "" @dataclasses.dataclass class GitHub(AtlassianStatusPage): """ Check GitHub platform status via Atlassian Status Page API v2. Args: enterprise_region: GitHub Enterprise status page region (if applicable). timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ try: class EnterpriseRegion(enum.StrEnum): """GitHub Enterprise status page regions.""" australia = "au" """Australia.""" eu = "eu" """Europe.""" japan = "jp" """Japan.""" us = "us" """United States.""" enterprise_region: EnterpriseRegion | None = None except AttributeError: # Python <3.11 doesn't have StrEnum, so fall back to a simple string field with validation enterprise_region: str | None = None timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) component: str = "" def __post_init__(self): self.base_url = f"https://{self.enterprise_region if self.enterprise_region else 'www'}.githubstatus.com" @dataclasses.dataclass class PlatformSh(AtlassianStatusPage): """ Check Platform.sh platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://status.platform.sh", init=False, repr=False ) component: str = "" @dataclasses.dataclass class DigitalOcean(AtlassianStatusPage): """ Check DigitalOcean platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://status.digitalocean.com", init=False, repr=False ) component: str = "" @dataclasses.dataclass class Render(AtlassianStatusPage): """ Check Render platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://status.render.com", init=False, repr=False ) component: str = "" @dataclasses.dataclass class Sentry(AtlassianStatusPage): """ Check Sentry platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://status.sentry.io", init=False, repr=False ) component: str = "" @dataclasses.dataclass class Vercel(AtlassianStatusPage): """ Check Vercel platform status via Atlassian Status Page API v2. Args: timeout: Request timeout duration. component: Name of a specific component to monitor. Monitors all components when empty. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) base_url: str = dataclasses.field( default="https://www.vercel-status.com", init=False, repr=False ) component: str = "" django-health-check-4.5.1/health_check/contrib/celery.py000066400000000000000000000053041524213625400232060ustar00rootroot00000000000000"""Celery health check.""" import dataclasses import datetime import typing import celery from celery.app import app_or_default from health_check.base import HealthCheck from health_check.exceptions import ServiceUnavailable @dataclasses.dataclass class Ping(HealthCheck): """ Check Celery worker availability using the ping control command. Args: app: Celery application instance to use for the health check, defaults to the [default Celery app][celery.app.default_app]. timeout: Timeout duration for the ping command. limit: Maximum number of workers to wait for before returning. If not specified, waits for the full timeout duration. When set, returns immediately after receiving responses from this many workers. """ CORRECT_PING_RESPONSE: typing.ClassVar[dict[str, str]] = {"ok": "pong"} app: celery.Celery = dataclasses.field(default_factory=app_or_default) timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=1), repr=False ) limit: int | None = dataclasses.field(default=None, repr=False) def run(self): try: ping_result = self.app.control.ping( timeout=self.timeout.total_seconds(), limit=self.limit ) except OSError as e: raise ServiceUnavailable("IOError") from e except NotImplementedError as e: raise ServiceUnavailable( "NotImplementedError: Make sure CELERY_RESULT_BACKEND is set" ) from e else: if not ping_result: raise ServiceUnavailable("Celery workers unavailable") else: self.check_active_queues(*self.active_workers(ping_result)) def active_workers(self, ping_result): for result in ping_result: worker, response = list(result.items())[0] if response != self.CORRECT_PING_RESPONSE: raise ServiceUnavailable( f"Celery worker {worker} response was incorrect" ) yield worker def check_active_queues(self, *active_workers): try: defined_queues = {queue.name for queue in self.app.conf.task_queues} except TypeError: # conf.task_queues may be None defined_queues = {self.app.conf.task_default_queue} active_queues = { queue.get("name") for queues in ( self.app.control.inspect(active_workers).active_queues() or {} ).values() for queue in queues } for queue in defined_queues - active_queues: raise ServiceUnavailable(f"No worker for Celery task queue {queue}") django-health-check-4.5.1/health_check/contrib/kafka.py000066400000000000000000000040541524213625400230010ustar00rootroot00000000000000"""Kafka health check.""" import dataclasses import datetime import logging from confluent_kafka.aio import AIOConsumer from confluent_kafka.error import KafkaException from health_check.base import HealthCheck from health_check.exceptions import ServiceUnavailable logger = logging.getLogger(__name__) @dataclasses.dataclass class Kafka(HealthCheck): """ Check Kafka service by connecting to a Kafka broker and listing topics. Args: bootstrap_servers: List of Kafka bootstrap servers, e.g., ['localhost:9092']. timeout: Timeout duration for the connection check as a datetime.timedelta. """ bootstrap_servers: list[str] timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) async def run(self): logger.debug( "Connecting to Kafka bootstrap servers %r ...", self.bootstrap_servers, ) # Create a consumer with minimal configuration for health check timeout_ms = int(self.timeout.total_seconds() * 1000) consumer = AIOConsumer( { "bootstrap.servers": ",".join(self.bootstrap_servers), "client.id": "health-check", "group.id": "health-check", "session.timeout.ms": timeout_ms, "socket.timeout.ms": timeout_ms, } ) try: if not ( ( cluster_metadata := await consumer.list_topics( timeout=self.timeout.total_seconds() ) ) and cluster_metadata.topics ): raise ServiceUnavailable("Failed to retrieve Kafka topics.") except KafkaException as e: raise ServiceUnavailable("Unable to connect") from e else: logger.debug( "Connection established. Kafka is healthy. Found %d topics.", len(cluster_metadata.topics), ) finally: await consumer.close() django-health-check-4.5.1/health_check/contrib/psutil.py000066400000000000000000000162131524213625400232440ustar00rootroot00000000000000import dataclasses import datetime import os import pathlib import socket import psutil from health_check import HealthCheck from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, ServiceWarning, ) @dataclasses.dataclass class Battery(HealthCheck): """ Warn about system battery status and power connection. Args: min_percent_available: Minimum battery percentage available or None to disable the check. power_plugged: Whether to warn if the power is unplugged. """ min_percent_available: float | None = dataclasses.field(default=20.0, repr=False) power_plugged: bool = dataclasses.field(default=False, repr=False) hostname: str = dataclasses.field(default_factory=socket.gethostname, init=False) def run(self): try: battery = psutil.sensors_battery() except AttributeError as e: raise ServiceUnavailable("Battery information not available") from e except ValueError as e: raise ServiceReturnedUnexpectedResult("ValueError") from e else: if ( self.min_percent_available and battery.percent <= self.min_percent_available ): raise ServiceWarning(f"Battery {battery.percent:.1f}\u202f%") if self.power_plugged and not battery.power_plugged: raise ServiceWarning( f"Power unplugged with battery at {battery.percent:.1f}\u202f%" ) @dataclasses.dataclass class CPU(HealthCheck): """ Warn about system CPU utilization. The utilization represents an interval rather than a point in time measurement. The interval starts with the previous execution of this check and with the current unless an explicit interval is provided. An explicit interval will cause a blocking measurement and increases the execution time of the check by the length of the interval. Args: max_usage_percent: Maximum CPU usage in percent or None to disable the check. interval: The interval to measure CPU usage over or None to use the interval since the last check execution. """ max_usage_percent: float | None = dataclasses.field(default=80.0, repr=False) interval: datetime.timedelta | None = dataclasses.field(default=None, repr=False) hostname: str = dataclasses.field(default_factory=socket.gethostname, init=False) def run(self): try: usage_percent = psutil.cpu_percent( interval=self.interval.total_seconds() if self.interval else None ) msg = f"CPU {usage_percent:.1f}\u202f%" if self.max_usage_percent and usage_percent >= self.max_usage_percent: raise ServiceWarning(msg) except ValueError as e: raise ServiceReturnedUnexpectedResult("ValueError") from e @dataclasses.dataclass() class Disk(HealthCheck): """ Warn about disk usage for a given system path. It can be setup multiple times at different system paths, e.g. one at your application root and one at your media storage root. Args: path: Path to check disk usage for. max_disk_usage_percent: Maximum disk usage in percent or None to disable the check. """ path: pathlib.Path | str = dataclasses.field(default_factory=os.getcwd) max_disk_usage_percent: float | None = dataclasses.field(default=90.0, repr=False) hostname: str = dataclasses.field(default_factory=socket.gethostname, init=False) def run(self): try: du = psutil.disk_usage(str(self.path)) if ( self.max_disk_usage_percent and du.percent >= self.max_disk_usage_percent ): raise ServiceWarning(f"{du.percent}\u202f% disk usage") except ValueError as e: raise ServiceReturnedUnexpectedResult("ValueError") from e @dataclasses.dataclass() class Memory(HealthCheck): """ Warn about system memory utilization. Args: min_gibibytes_available: Minimum available memory in gibibytes or None to disable the check. max_memory_usage_percent: Maximum memory usage in percent or None to disable the check. """ min_gibibytes_available: float | None = dataclasses.field(default=None, repr=False) max_memory_usage_percent: float | None = dataclasses.field(default=90.0, repr=False) hostname: str = dataclasses.field(default_factory=socket.gethostname, init=False) def run(self): try: memory = psutil.virtual_memory() available_gibi = memory.available / (1024**3) total_gibi = memory.total / (1024**3) msg = f"RAM {available_gibi:.1f}/{total_gibi:.1f}GiB ({memory.percent}\u202f%)" if ( self.min_gibibytes_available and available_gibi < self.min_gibibytes_available ): raise ServiceWarning(msg) if ( self.max_memory_usage_percent and memory.percent >= self.max_memory_usage_percent ): raise ServiceWarning(msg) except ValueError as e: raise ServiceReturnedUnexpectedResult("ValueError") from e @dataclasses.dataclass class Temperature(HealthCheck): """ Warn about system temperature. If no maximum temperature is specified, the sensor's high threshold will be used. Args: device: The device to check temperature for, e.g. 'coretemp' for CPU temperature on many systems. If None, check all available sensors. max_temperature_celsius: Maximum temperature in degree Celsius or None to disable the check. """ device: str | None = dataclasses.field(default="coretemp") max_temperature_celsius: float | None = dataclasses.field(default=None, repr=False) hostname: str = dataclasses.field(default_factory=socket.gethostname, init=False) def run(self): try: temperatures = psutil.sensors_temperatures() except AttributeError as e: raise ServiceUnavailable("Temperature information not available") from e else: if self.device: try: sensors = temperatures[self.device] except KeyError: raise ServiceUnavailable( f"Sensor {self.device!r} not found" ) from None else: for sensor in sensors: if sensor.current >= ( self.max_temperature_celsius or sensor.high ): raise ServiceWarning( f"{sensor.label} {sensor.current:.1f}\u202fΒ°C" ) else: for device, sensors in temperatures.items(): for sensor in sensors: if sensor.current >= ( self.max_temperature_celsius or sensor.high ): raise ServiceWarning( f"{device} {sensor.label} {sensor.current:.1f}\u202fΒ°C" ) django-health-check-4.5.1/health_check/contrib/rabbitmq.py000066400000000000000000000024711524213625400235260ustar00rootroot00000000000000"""RabbitMQ health check.""" import dataclasses import logging import aio_pika from health_check.base import HealthCheck from health_check.exceptions import ServiceUnavailable logger = logging.getLogger(__name__) @dataclasses.dataclass class RabbitMQ(HealthCheck): """ Check RabbitMQ service by opening and closing a broker channel. Args: amqp_url (str): The URL of the RabbitMQ broker to connect to, e.g., 'amqp://guest:guest@localhost:5672//'. """ amqp_url: str async def run(self): logger.debug("Attempting to connect to %r...", self.amqp_url) try: # conn is used as a context to release opened resources later connection = await aio_pika.connect_robust(self.amqp_url) await connection.close() except ConnectionRefusedError as e: raise ServiceUnavailable( "Unable to connect to RabbitMQ: Connection was refused." ) from e except aio_pika.exceptions.ProbableAuthenticationError as e: raise ServiceUnavailable( "Unable to connect to RabbitMQ: Authentication error." ) from e except OSError as e: raise ServiceUnavailable("IOError") from e else: logger.debug("Connection established. RabbitMQ is healthy.") django-health-check-4.5.1/health_check/contrib/redis.py000066400000000000000000000116451524213625400230360ustar00rootroot00000000000000"""Redis health check.""" import dataclasses import logging import typing import warnings from redis import exceptions from redis.asyncio import Redis as RedisClient from redis.asyncio import RedisCluster from health_check.base import HealthCheck from health_check.exceptions import ServiceUnavailable logger = logging.getLogger(__name__) @dataclasses.dataclass class Redis(HealthCheck): """ Check Redis service by pinging a Redis client. This check works with any Redis client that implements the ping() method, including standard Redis, Sentinel, and Cluster clients. Args: client_factory: A callable that returns an instance of a Redis client. client: Deprecated, use `client_factory` instead. Examples: Using a standard Redis client: >>> from redis.asyncio import Redis as RedisClient >>> Redis(client_factory=lambda: RedisClient(host='localhost', port=6379)) Using from_url to create a client: >>> from redis.asyncio import Redis as RedisClient >>> Redis(client_factory=lambda: RedisClient.from_url('redis://localhost:6379')) Using a Cluster client: >>> from redis.asyncio import RedisCluster >>> Redis(client_factory=lambda: RedisCluster(host='localhost', port=7000)) Using a Sentinel client: >>> from redis.asyncio import Sentinel >>> Redis(client_factory=lambda: Sentinel([('localhost', 26379)]).master_for('mymaster')) """ client: RedisClient | RedisCluster | None = dataclasses.field( repr=False, default=None ) client_factory: typing.Callable[[], RedisClient | RedisCluster] | None = ( dataclasses.field(repr=False, default=None) ) def _connection_kwargs(self) -> dict[str, object]: """Return identifying Redis connection parameters for repr and labels.""" client = ( self.client_factory() if self.client_factory is not None else self.client ) try: return { key: value for key, value in sorted( client.connection_pool.connection_kwargs.items() ) if key in {"host", "port", "db"} } except AttributeError: pass try: return {"hosts": [node.name for node in client.startup_nodes]} except AttributeError: return {} def __repr__(self): match self._connection_kwargs(): case {"hosts": hosts}: return f"Redis(client=RedisCluster(hosts={hosts!r}))" case kwargs if kwargs: return f"Redis({', '.join(f'{key}={value!r}' for key, value in kwargs.items())})" case _: return super().__repr__() @property def labels(self) -> dict[str, str]: return super().labels | { key: str(value) for key, value in self._connection_kwargs().items() } def __post_init__(self): # Validate that exactly one of client or client_factory is provided if self.client is not None and self.client_factory is not None: raise ValueError( "Provide exactly one of `client` or `client_factory`, not both." ) if self.client is None and self.client_factory is None: raise ValueError( "You must provide either `client` (deprecated) or `client_factory` " "when instantiating `Redis`." ) # Emit deprecation warning if using the old client parameter if self.client is not None: warnings.warn( "The `client` argument is deprecated and will be removed in a future version. " "Please use `client_factory` instead.", DeprecationWarning, stacklevel=2, ) async def run(self): # Create a new client for this health check request if self.client_factory is not None: client = self.client_factory() should_close = True else: # Use the deprecated client parameter (user manages lifecycle) client = self.client should_close = False logger.debug("Pinging Redis client...") try: await client.ping() except ConnectionRefusedError as e: raise ServiceUnavailable( "Unable to connect to Redis: Connection was refused." ) from e except exceptions.TimeoutError as e: raise ServiceUnavailable("Unable to connect to Redis: Timeout.") from e except exceptions.ConnectionError as e: raise ServiceUnavailable( "Unable to connect to Redis: Connection Error" ) from e else: logger.debug("Connection established. Redis is healthy.") finally: # Only close clients created by client_factory if should_close: await client.aclose() django-health-check-4.5.1/health_check/contrib/rss.py000066400000000000000000000202261524213625400225320ustar00rootroot00000000000000"""RSS feed health checks for cloud provider status pages.""" import dataclasses import datetime import logging import feedparser import httpx from health_check import HealthCheck, __version__ from health_check.exceptions import ServiceUnavailable, StatusPageWarning logger = logging.getLogger(__name__) class Feed(HealthCheck): """ Base class for cloud provider status feed health checks. Monitor cloud provider service health via their public RSS or Atom status feeds. Subclasses must provide: Attributes: feed_url: The full URL of the RSS or Atom feed to monitor. timeout: Maximum duration to wait for the HTTP request before failing. max_age: Maximum age for an incident entry to be considered active. The `timeout` and `max_age` values are used to control how long the health check waits for the feed and how far back in time incidents are considered relevant. The `feed_url` is used to fetch the status feed. Examples: >>> import dataclasses >>> import datetime >>> import typing >>> from health_check.contrib.rss import Feed >>> @dataclasses.dataclass ... class MyProviderStatus(Feed): ... \"\"\"Check MyProvider status via its public RSS feed.\"\"\" ... timeout: datetime.timedelta = dataclasses.field( ... default=datetime.timedelta(seconds=10), ... repr=False, ... ) ... max_age: datetime.timedelta = dataclasses.field( ... default=datetime.timedelta(hours=4), ... repr=False, ... ) ... feed_url: typing.ClassVar[str] = "https://status.myprovider.com/feed" """ feed_url: str = NotImplemented timeout: datetime.timedelta = NotImplemented max_age: datetime.timedelta = NotImplemented async def run(self): logger.debug("Fetching feed from %s", self.feed_url) async with httpx.AsyncClient() as client: try: response = await client.get( self.feed_url, headers={"User-Agent": f"django-health-check@{__version__}"}, timeout=self.timeout.total_seconds(), follow_redirects=True, ) except httpx.TimeoutException as e: raise ServiceUnavailable("Feed request timed out") from e except httpx.RequestError as e: raise ServiceUnavailable(f"Failed to fetch feed: {e}") from e try: response.raise_for_status() except httpx.HTTPStatusError as e: raise ServiceUnavailable( f"HTTP error {e.response.status_code} fetching feed from {self.feed_url!r}" ) from e content = response.text feed = feedparser.parse(content) if feed.bozo: # feedparser sets bozo=1 for malformed feeds logger.warning("Feed parsing encountered errors: %s", feed.bozo_exception) if not feed.entries: logger.debug("No entries found in feed") return if incidents := list(self._recent_incidents(feed.entries)): raise StatusPageWarning( "\n".join( f"{getattr(entry, 'title', 'Unknown Incident') or 'Unknown Incident'}:" f" {getattr(entry, 'link', self.feed_url) or self.feed_url}" for entry, _ in incidents ), timestamp=max( filter(None, (date for _, date in incidents)), default=None ), ) logger.debug("No recent incidents found in feed") def _recent_incidents(self, entries): """Yield recent (entry, timestamp) pairs from feed entries.""" for entry in entries: date = self._extract_date(entry) if date is None or self._is_date_recent(date): yield entry, date def _is_date_recent(self, date): """Check if a timestamp falls within the configured max_age window.""" now = datetime.datetime.now(tz=datetime.timezone.utc) return now >= date > now - self.max_age def _extract_date(self, entry): # feedparser normalizes both RSS and Atom dates to struct_time # Try published first, then updated for date_field in ["published_parsed", "updated_parsed"]: if date_tuple := getattr(entry, date_field, None): try: # Convert struct_time to datetime return datetime.datetime( *date_tuple[:6], tzinfo=datetime.timezone.utc ) except (ValueError, TypeError): logger.warning( "Failed to parse date from entry %r for %r", date_tuple, self.feed_url, exc_info=True, ) return None @dataclasses.dataclass class AWS(Feed): """ Check AWS service status via their public RSS status feeds. Args: region: AWS region code (e.g., 'us-east-1', 'eu-west-1'). service: AWS service name (e.g., 'ec2', 's3', 'rds'). timeout: Request timeout duration. max_age: Maximum age for an incident to be considered active. """ region: str service: str timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) max_age: datetime.timedelta = dataclasses.field( default=datetime.timedelta(hours=8), repr=False ) def __post_init__(self): self.feed_url: str = ( f"https://status.aws.amazon.com/rss/{self.service}-{self.region}.rss" ) @dataclasses.dataclass class Heroku(Feed): """ Check Heroku platform status via their public RSS status feed. Args: timeout: Request timeout duration. max_age: Maximum age for an incident to be considered active. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) max_age: datetime.timedelta = dataclasses.field( default=datetime.timedelta(hours=8), repr=False ) feed_url: str = dataclasses.field( default="https://status.heroku.com/feed", init=False, repr=False ) @dataclasses.dataclass class Hetzner(Feed): """ Check Hetzner platform status via their public ATOM status feed. Args: timeout: Request timeout duration. max_age: Maximum age for an incident to be considered active. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) max_age: datetime.timedelta = dataclasses.field( default=datetime.timedelta(hours=8), repr=False ) feed_url: str = dataclasses.field( default="https://status.hetzner.com/en.atom", init=False, repr=False ) @dataclasses.dataclass class Azure(Feed): """ Check Azure platform status via their public RSS status feed. Args: timeout: Request timeout duration. max_age: Maximum age for an incident to be considered active. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) max_age: datetime.timedelta = dataclasses.field( default=datetime.timedelta(hours=8), repr=False ) feed_url: str = dataclasses.field( default="https://rssfeed.azure.status.microsoft/en-us/status/feed/", init=False, repr=False, ) @dataclasses.dataclass class GoogleCloud(Feed): """ Check Google Cloud platform status via their public Atom status feed. Args: timeout: Request timeout duration. max_age: Maximum age for an incident to be considered active. """ timeout: datetime.timedelta = dataclasses.field( default=datetime.timedelta(seconds=10), repr=False ) max_age: datetime.timedelta = dataclasses.field( default=datetime.timedelta(hours=8), repr=False ) feed_url: str = dataclasses.field( default="https://status.cloud.google.com/en/feed.atom", init=False, repr=False ) django-health-check-4.5.1/health_check/exceptions.py000066400000000000000000000014231524213625400224420ustar00rootroot00000000000000from django.utils import timezone class HealthCheckException(Exception): message_type: str = "Unknown Error" def __init__(self, message, *, timestamp=None): self.message = message self.timestamp = timezone.now() if timestamp is None else timestamp def __str__(self): return f"{self.message_type}: {self.message}" class ServiceWarning(HealthCheckException): """Warning of service misbehavior.""" message_type = "Warning" class ServiceUnavailable(HealthCheckException): message_type = "Unavailable" class ServiceReturnedUnexpectedResult(HealthCheckException): message_type = "Unexpected Result" class StatusPageWarning(ServiceWarning): """Warning from an external status page, carrying the source incident timestamp.""" django-health-check-4.5.1/health_check/management/000077500000000000000000000000001524213625400220235ustar00rootroot00000000000000django-health-check-4.5.1/health_check/management/__init__.py000066400000000000000000000000001524213625400241220ustar00rootroot00000000000000django-health-check-4.5.1/health_check/management/commands/000077500000000000000000000000001524213625400236245ustar00rootroot00000000000000django-health-check-4.5.1/health_check/management/commands/__init__.py000066400000000000000000000000001524213625400257230ustar00rootroot00000000000000django-health-check-4.5.1/health_check/management/commands/health_check.py000066400000000000000000000132401524213625400266000ustar00rootroot00000000000000import asyncio import os import sys import urllib.error import urllib.request from django.conf import settings from django.core.management.base import BaseCommand from django.urls import NoReverseMatch, resolve, reverse class Command(BaseCommand): help = "Run health checks and exit 0 if everything went well." @property def default_forwarded_host(self): return ( settings.ALLOWED_HOSTS[0].strip(".") if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != "*" else None ) @property def default_addrport(self): return ":".join([os.getenv("HOST", "127.0.0.1"), os.getenv("PORT", "8000")]) def add_arguments(self, parser): parser.add_argument( "endpoint", type=str, help="URL-pattern name of health check endpoint to test", ) parser.add_argument( "addrport", nargs="?", type=str, default=self.default_addrport, help=f"Optional port number, or ipaddr:port (default: {self.default_addrport})", ) parser.add_argument( "--forwarded-host", type=str, default=self.default_forwarded_host, help=f"Value for X-Forwarded-Host header (default: {self.default_forwarded_host})", ) parser.add_argument( "--forwarded-proto", type=str, choices=["http", "https"], default="https", help="Value for X-Forwarded-Proto header (default: https)", ) parser.add_argument( "--timeout", type=int, default=5, help="Timeout in seconds for the health check request (default: 5 seconds)", ) http_group = parser.add_mutually_exclusive_group() http_group.add_argument( "--http", action="store_true", dest="use_http", default=True, help="Run checks via HTTP server (default)", ) http_group.add_argument( "--no-http", action="store_false", dest="use_http", help="Run checks directly without HTTP server", ) def handle(self, *args, **options): endpoint = options.get("endpoint") try: path = reverse(endpoint) except NoReverseMatch as e: self.stderr.write( f"Could not resolve endpoint {endpoint!r}: {e}\n" "Please provide a valid URL pattern name for the health check endpoint." ) sys.exit(2) if not options.get("use_http"): asyncio.run(self._run_checks_directly(path)) addrport = options.get("addrport") # Use HTTPS only when SSL redirect is enabled without forwarded headers (direct HTTPS required). # Otherwise, use HTTP (typical for containers with X-Forwarded-Proto header support). proto = ( "https" if settings.SECURE_SSL_REDIRECT and not settings.USE_X_FORWARDED_HOST else "http" ) url = f"{proto}://{addrport}{path}" headers = {"Accept": "text/plain"} # Add X-Forwarded-Host header if forwarded_host := options.get("forwarded_host"): headers["X-Forwarded-Host"] = forwarded_host # Add X-Forwarded-Proto header if forwarded_proto := options.get("forwarded_proto"): headers["X-Forwarded-Proto"] = forwarded_proto if options.get("verbosity", 1) >= 2: self.stdout.write( f"Checking health endpoint at {url!r} with headers: {headers}" ) request = urllib.request.Request(url, headers=headers) # noqa: S310 try: response = urllib.request.urlopen(request, timeout=options["timeout"]) # noqa: S310 except urllib.error.HTTPError as e: match e.code: case 500: # Health check failed self.stdout.write(e.read().decode("utf-8")) sys.exit(1) case 400: self.stderr.write( f"{url!r} is not reachable: {e.reason}\nPlease check your ALLOWED_HOSTS setting or use the --forwarded-host option." ) sys.exit(2) case _: self.stderr.write( "Unexpected HTTP error " f"when trying to reach {url!r}: {e}\n" f"You may have selected an invalid endpoint {endpoint!r}" f" or another application is running on {addrport!r}." ) sys.exit(2) except urllib.error.URLError as e: self.stderr.write( f"{url!r} is not reachable: {e.reason}\nPlease check your server is running and reachable." ) sys.exit(2) except TimeoutError as e: self.stderr.write(f"Timeout when trying to reach {url!r}: {e}") sys.exit(2) else: self.stdout.write(response.read().decode("utf-8")) async def _run_checks_directly(self, path): """Run health checks directly without HTTP server.""" resolver_match = resolve(path) view = resolver_match.func.view_class(**resolver_match.func.view_initkwargs) results = await asyncio.gather( *(check.get_result() for check in view.get_checks()) ) error = False for result in results: self.stdout.write( f"{result.check!r}: {'OK' if not result.error else result.error!s}" ) error |= bool(result.error) sys.exit(int(error)) django-health-check-4.5.1/health_check/templates/000077500000000000000000000000001524213625400217055ustar00rootroot00000000000000django-health-check-4.5.1/health_check/templates/health_check/000077500000000000000000000000001524213625400243075ustar00rootroot00000000000000django-health-check-4.5.1/health_check/templates/health_check/index.html000066400000000000000000000106751524213625400263150ustar00rootroot00000000000000 {% block title %} System status {% endblock title %} {% block extra_head %} {% endblock extra_head %} {% block content %}

{% if errors %} πŸ”₯ {% endif %} System status {% if errors %} πŸ”₯ {% endif %}

{% for result in results %} {% endfor %}
Service Status Timing
{{ result.check|stringformat:'r' }} {{ result.error|default_if_none:"OK"|linebreaks }} {{ result.time_taken|floatformat:3 }} s
{% endblock content %} django-health-check-4.5.1/health_check/views.py000066400000000000000000000264021524213625400214220ustar00rootroot00000000000000import asyncio import contextlib import datetime import re import typing from concurrent.futures import Executor from django.db import transaction from django.http import HttpResponse, JsonResponse from django.utils.cache import patch_vary_headers from django.utils.decorators import method_decorator from django.utils.feedgenerator import Atom1Feed, Rss201rev2Feed from django.utils.module_loading import import_string from django.views.decorators.cache import never_cache from django.views.generic import TemplateView from health_check.base import HealthCheck class MediaType: """ Sortable object representing HTTP's accept header. See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept """ pattern = re.compile( r""" ^ (?P (\w+|\*) # Media type, or wildcard / ([\w\d\-+.]+|\*) # subtype, or wildcard ) ( \s*;\s* # parameter separator with optional whitespace q= # q is expected to be the first parameter, by RFC2616 (?P 1([.]0{1,3})? # 1 with up to three digits of precision | 0([.]\d{1,3})? # 0.000 to 0.999 with optional precision ) )? ( \s*;\s* # parameter separator with optional whitespace [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any token from legal characters = [-!#$%&'*+.^_`|~0-9a-zA-Z]+ # any value from legal characters )* $ """, re.VERBOSE, ) def __init__(self, mime_type, weight=1.0): self.mime_type = mime_type self.weight = float(weight) @classmethod def from_string(cls, value): """Return single instance parsed from the given Accept-header string.""" match = cls.pattern.search(value) if match is None: raise ValueError(f'"{value}" is not a valid media type') return cls(match.group("mime_type"), float(match.group("weight") or 1)) @classmethod def parse_header(cls, value="*/*"): """Parse HTTP accept header and return instances sorted by weight.""" yield from sorted( ( cls.from_string(token.strip()) for token in value.split(",") if token.strip() ), reverse=True, ) def __str__(self): return f"{self.mime_type}; q={self.weight}" def __repr__(self): return f"{type(self).__name__}: {self.__str__()}" def __eq__(self, other): return self.weight == other.weight and self.mime_type == other.mime_type def __lt__(self, other): return self.weight.__lt__(other.weight) class HealthCheckView(TemplateView): """Perform health checks and return results in various formats.""" template_name = "health_check/index.html" feed_author = "Django Health Check" checks: typing.Iterable[ type[HealthCheck] | str | tuple[type[HealthCheck] | str, dict[str, typing.Any]] ] = ( "health_check.checks.Cache", "health_check.checks.Database", "health_check.checks.DNS", "health_check.checks.Mail", "health_check.checks.Storage", ) @method_decorator(transaction.non_atomic_requests) async def dispatch(self, request, *args, **kwargs): response = await super().dispatch(request, *args, **kwargs) patch_vary_headers(response, ["Accept"]) return response @method_decorator(never_cache) async def get(self, request, *args, **kwargs): with self.get_executor() as executor: self.results = await asyncio.gather( *(check.get_result(executor) for check in self.get_checks()) ) has_errors = any(result.error for result in self.results) status_code = 500 if has_errors else 200 format_override = request.GET.get("format") match format_override: case "json": return self.render_to_response_json(status_code) case "text": return self.render_to_response_text(status_code) case "atom": return self.render_to_response_atom() case "rss": return self.render_to_response_rss() case "openmetrics": return self.render_to_response_openmetrics() accept_header = request.headers.get("accept", "*/*") for media in MediaType.parse_header(accept_header): match media.mime_type: case "text/plain": return self.render_to_response_text(status_code) case "text/html" | "application/xhtml+xml" | "text/*" | "*/*": context = self.get_context_data(**kwargs) return self.render_to_response(context, status=status_code) case "application/json" | "application/*": return self.render_to_response_json(status_code) case "application/atom+xml": return self.render_to_response_atom() case "application/rss+xml": return self.render_to_response_rss() case "application/openmetrics-text": return self.render_to_response_openmetrics() return HttpResponse( "Not Acceptable: Supported content types: text/plain, text/html, application/json, application/atom+xml, application/rss+xml, application/openmetrics-text", status=406, content_type="text/plain", ) def get_context_data(self, **kwargs): return { **super().get_context_data(**kwargs), "results": self.results, "errors": any(result.error for result in self.results), } def get_executor(self) -> contextlib.AbstractContextManager[Executor | None]: """ Return a context manager providing an executor for synchronous checks. Return a context manager that yields ``None`` to use the event loop's default executor. Example: def get_executor(self): return ThreadPoolExecutor(max_workers=5) """ return contextlib.nullcontext(None) def render_to_response_json(self, status): """Return JSON response with health check results.""" return JsonResponse( { repr(result.check): "OK" if not result.error else str(result.error) for result in self.results }, status=status, ) def render_to_response_text(self, status): """Return a plain text response with health check results.""" lines = ( f"{repr(result.check)}: {'OK' if not result.error else str(result.error)}" for result in self.results ) return HttpResponse( "\n".join(lines) + "\n", content_type="text/plain; charset=utf-8", status=status, ) def render_to_response_atom(self): """Return Atom feed response with health check results.""" return self._render_feed(Atom1Feed) def render_to_response_rss(self): """Return RSS 2.0 feed response with health check results.""" return self._render_feed(Rss201rev2Feed) @staticmethod def abnf_escape(value): r""" Escape ABNF OpenMetic labels according to RFC 5234 and RFC7405. Escapes backslashes, double quotes, and newlines as required by the spec: - Backslash (\) -> \\ - Double quote (") -> \" - Line feed (\n) -> \n """ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") @staticmethod def abnf_dumps(o: dict[str, str]): """Return ABNF OpenMetic labels as a string suitable for use in a metric name.""" return ",".join( f'{key}="{HealthCheckView.abnf_escape(value)}"' for key, value in o.items() ) def render_to_response_openmetrics(self): """Return OpenMetrics response with health check results.""" lines = [ "# HELP django_health_check_status Health check status (1 = healthy, 0 = unhealthy)", "# TYPE django_health_check_status gauge", ] has_errors: bool = False # Add status metrics for each check for result in self.results: has_errors |= bool(result.error) lines.append( f"django_health_check_status{{{self.abnf_dumps(result.check.labels)}}} {not result.error:d}" ) # Add response time metrics lines += [ "# HELP django_health_check_response_time_seconds Health check response time in seconds", "# TYPE django_health_check_response_time_seconds gauge", ] for result in self.results: lines.append( f"django_health_check_response_time_seconds{{{self.abnf_dumps(result.check.labels)}}} {result.time_taken:.6f}" ) # Add overall health status lines += [ "# HELP django_health_check_overall_status Overall health check status (1 = all healthy, 0 = at least one unhealthy)", "# TYPE django_health_check_overall_status gauge", f"django_health_check_overall_status {not has_errors:d}", "# EOF", ] return HttpResponse( "\n".join(lines) + "\n", content_type="application/openmetrics-text; version=1.0.0; charset=utf-8", status=200, # Prometheus expects 200 even if checks fail ) def _render_feed(self, feed_class): """Generate RSS or Atom feed with health check results.""" feed = feed_class( title="Health Check Status", link=self.request.build_absolute_uri(self.request.path), description="Current status of system health checks", feed_url=self.request.build_absolute_uri(), ) for result in self.results: published_at = ( result.error.timestamp if result.error else datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) ) feed.add_item( title=repr(result.check), link=self.request.build_absolute_uri(self.request.path), description=str(result.error) if result.error else "OK", pubdate=published_at, updateddate=published_at, author_name=self.feed_author, categories=["error", "unhealthy"] if result.error else ["healthy"], ) response = HttpResponse( feed.writeString("utf-8"), content_type=feed.content_type, status=200, # Feed readers expect 200 even if checks fail ) return response def get_checks( self, ) -> typing.Generator[HealthCheck, None, None]: """Yield instantiated health check callables.""" for check in self.checks: try: check, options = check except (ValueError, TypeError): options = {} if isinstance(check, str): check = import_string(check) yield check(**options) django-health-check-4.5.1/mkdocs.yml000066400000000000000000000036411524213625400173140ustar00rootroot00000000000000site_name: Django HealthCheck repo_url: https://github.com/codingjoe/django-health-check watch: - docs - health_check nav: - Home: index.md - Installation: install.md - Usage: usage.md - Checks: checks.md - Cookbook: cookbook.md - Migration to v4.x: migrate-to-v4.md - Changelog: https://github.com/codingjoe/django-health-check/releases - Community Support: https://github.com/codingjoe/django-health-check/discussions plugins: - autorefs - search - mkdocstrings: default_handler: python handlers: python: load_external_modules: true options: docstring_style: google show_root_heading: true heading_level: 3 inventories: - https://docs.python.org/3/objects.inv - https://docs.djangoproject.com/en/stable/objects.inv - https://www.psycopg.org/psycopg3/docs/objects.inv - https://docs.celeryq.dev/en/stable/objects.inv - https://psutil.readthedocs.io/en/stable/objects.inv - https://redis.readthedocs.io/en/stable/objects.inv - https://django-storages.readthedocs.io/en/stable/objects.inv theme: name: material logo: images/icon.svg palette: - media: "(prefers-color-scheme)" primary: teal toggle: icon: material/brightness-auto name: Switch to light mode - media: "(prefers-color-scheme: light)" primary: teal scheme: default toggle: icon: material/brightness-7 name: Switch to dark mode - media: "(prefers-color-scheme: dark)" primary: teal scheme: slate toggle: icon: material/brightness-4 name: Switch to system preference features: - content.code.select - content.code.copy - search.suggest - search.share markdown_extensions: - github-callouts - pymdownx.highlight: use_pygments: true - pymdownx.superfences django-health-check-4.5.1/pyproject.toml000066400000000000000000000071151524213625400202250ustar00rootroot00000000000000[build-system] requires = ["flit_core>=3.2", "flit_scm", "wheel"] build-backend = "flit_scm:buildapi" [project] name = "django-health-check" readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE" } authors = [ { name = "Kristian Ollegaard", email = "kristian@oellegaard.com" }, { name = "Johannes Maron", email = "johannes@maron.family" } ] keywords = ["django", "postgresql"] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Framework :: Django :: 6.1", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Quality Assurance", "Topic :: System :: Logging", "Topic :: System :: Monitoring", "Topic :: Utilities" ] requires-python = ">=3.10" dynamic = [ "version", "description", ] dependencies = [ "Django>=5.2", "dnspython>=2.0.0", ] [project.optional-dependencies] psutil = ["psutil>=7.2.0"] celery = ["celery>=5.0.0"] kafka = ["confluent-kafka>=2.0.0"] rabbitmq = ["aio-pika>=9.0.0"] redis = ["redis>=4.2.0"] rss = ["httpx>=0.27.0", "feedparser>=6.0.0"] atlassian = ["httpx>=0.27.0"] [project.urls] # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels Homepage = "https://github.com/codingjoe/django-health-check" Changelog = "https://github.com/codingjoe/django-health-check/releases" Source = "https://github.com/codingjoe/django-health-check" Releasenotes = "https://github.com/codingjoe/django-health-check/releases/latest" Documentation = "https://codingjoe.dev/django-health-check/" Issues = "https://github.com/codingjoe/django-health-check/issues" Funding = "https://github.com/sponsors/codingjoe" [dependency-groups] dev = [ { include-group = "test" }, { include-group = "docs" }, ] test = [ "pytest", "pytest-cov", "pytest-asyncio", "pytest-django", "django-storages", "boto3", "testcontainers", ] docs = [ "mkdocs", "mkdocstrings[python]>=0.18", "mkdocs-material", "markdown-callouts", "Pygments", ] [tool.flit.module] name = "health_check" [tool.setuptools_scm] write_to = "health_check/_version.py" [tool.pytest.ini_options] addopts = "--cov --strict-markers --cov-report=xml --cov-report=term --doctest-modules" testpaths = [ "tests", ] DJANGO_SETTINGS_MODULE = "tests.testapp.settings" norecursedirs = ".git" python_files = "test_*.py" xfail_strict = true markers = [ "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", ] [tool.coverage.run] source = ["health_check"] [tool.coverage.report] show_missing = true skip_covered = true [tool.ruff] line-length = 88 [tool.ruff.lint] select = [ "D", # pydocstyle "E", # pycodestyle errors "EXE", # flake8-executable "F", # pyflakes "I", # isort "S", # flake8-bandit "SIM", # flake8-simplify "UP", # pyupgrade "W", # pycodestyle warnings ] ignore = ["D1", "D203", "D212", "E501"] [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["S101", "S105", "PLR2004"] [tool.ruff.lint.isort] known-first-party = ["measurement", "tests"] [tool.djlint] profile="django" indent=2 max_line_length=120 line_break_after_multiline_tag=true django-health-check-4.5.1/tests/000077500000000000000000000000001524213625400164475ustar00rootroot00000000000000django-health-check-4.5.1/tests/__init__.py000066400000000000000000000000001524213625400205460ustar00rootroot00000000000000django-health-check-4.5.1/tests/conftest.py000066400000000000000000000017271524213625400206550ustar00rootroot00000000000000"""Pytest configuration for health_check tests.""" import pytest from django.test import AsyncRequestFactory from health_check.views import HealthCheckView @pytest.fixture def health_check_view(): """Create a function that can render a HealthCheckView with custom checks and request parameters.""" factory = AsyncRequestFactory() async def render_view(checks, accept_header=None, format_param=None): """Render a HealthCheckView with custom checks and optional parameters.""" path = "/" if format_param: path += f"?format={format_param}" headers = {} if accept_header: headers["Accept"] = accept_header request = factory.get(path, headers=headers) if headers else factory.get(path) view = HealthCheckView.as_view(checks=checks) response = await view(request) if hasattr(response, "render"): response.render() return response return render_view django-health-check-4.5.1/tests/contrib/000077500000000000000000000000001524213625400201075ustar00rootroot00000000000000django-health-check-4.5.1/tests/contrib/__init__.py000066400000000000000000000000561524213625400222210ustar00rootroot00000000000000"""Tests for health_check contrib modules.""" django-health-check-4.5.1/tests/contrib/conftest.py000066400000000000000000000053631524213625400223150ustar00rootroot00000000000000"""Pytest fixtures that run integration test dependencies in containers.""" import pytest def _require_docker() -> None: """Skip the test when a Docker daemon is not reachable.""" from docker import from_env from docker.errors import DockerException try: from_env().ping() except DockerException as exc: pytest.skip(f"Docker daemon is not available: {exc}") @pytest.fixture(scope="module") def redis_url(): """Start a Redis container and return its URL.""" from testcontainers.community.redis import RedisContainer _require_docker() with RedisContainer("redis:7") as redis: yield f"redis://localhost:{redis.get_exposed_port(6379)}/0" @pytest.fixture(scope="module") def redis_sentinel(): """Start a Redis master and a Sentinel, return their nodes and service name.""" from testcontainers.community.redis import RedisContainer from testcontainers.core.container import DockerContainer _require_docker() with RedisContainer("redis:7") as master: master_port = master.get_exposed_port(6379) conf = ( "port 26379\n" f"sentinel monitor mymaster 127.0.0.1 {master_port} 1\n" "sentinel down-after-milliseconds mymaster 5000\n" "sentinel failover-timeout mymaster 60000\n" "sentinel parallel-syncs mymaster 1\n" ) sentinel = ( DockerContainer("redis:7") .with_command( "sh -c 'chmod 666 /tmp/sentinel.conf " "&& redis-server /tmp/sentinel.conf --sentinel'" ) .with_copy_into_container( conf.encode(), "/tmp/sentinel.conf", # noqa: S108 - path is inside the container ) .with_exposed_ports(26379) ) with sentinel: nodes = [("localhost", sentinel.get_exposed_port(26379))] yield nodes, "mymaster" @pytest.fixture(scope="module") def kafka_bootstrap_servers(): """Fixture that returns a running Kafka bootstrap server address.""" from testcontainers.community.kafka import KafkaContainer _require_docker() with KafkaContainer() as kafka: yield kafka.get_bootstrap_server() @pytest.fixture(scope="module") def rabbitmq_url(): """Return a connection URL for a running RabbitMQ broker.""" from testcontainers.core.container import DockerContainer from testcontainers.core.wait_strategies import LogMessageWaitStrategy _require_docker() container = DockerContainer("rabbitmq:3-management") container.with_exposed_ports(5672) container.waiting_for(LogMessageWaitStrategy("Server startup complete")) with container: yield f"amqp://guest:guest@localhost:{container.get_exposed_port(5672)}//" django-health-check-4.5.1/tests/contrib/test_atlassian.py000066400000000000000000000631411524213625400235040ustar00rootroot00000000000000import datetime from unittest import mock import pytest pytest.importorskip("httpx") from health_check.contrib.atlassian import ( Cloudflare, DigitalOcean, FlyIo, GitHub, PlatformSh, Render, Sentry, Vercel, ) from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, ServiceWarning, StatusPageWarning, ) def _make_response(components, incidents=None): """Build a minimal /api/v2/summary.json payload.""" return { "page": {"id": "test"}, "components": list(components), "incidents": list(incidents or []), } def _component(name, status="operational", updated_at="2024-01-01T00:00:00.000Z"): return {"name": name, "status": status, "updated_at": updated_at} def _incident( name, shortlink, status="investigating", updated_at="2024-01-01T06:00:00.000Z", components=None, ): return { "name": name, "shortlink": shortlink, "status": status, "updated_at": updated_at, "components": [{"name": c} for c in (components or [])], } class TestFlyIo: """Test Fly.io platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response( [_component("Networking"), _component("Compute")], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is None mock_context.__aenter__.return_value.get.assert_awaited_once() assert ( mock_context.__aenter__.return_value.get.await_args.args[0] == "https://status.flyio.net/api/v2/summary.json" ) @pytest.mark.asyncio async def test_check_status__raise_service_warning(self): """Raise ServiceWarning when an open incident is found.""" api_response = _make_response( [_component("Networking", status="partial_outage")], incidents=[ _incident( "Networking degraded performance", "https://stspg.io/abc123", components=["Networking"], ) ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Networking degraded performance" in str(result.error) assert "https://stspg.io/abc123" in str(result.error) @pytest.mark.asyncio async def test_check_status__multiple_incidents(self): """Report all open incidents.""" api_response = _make_response( [ _component("Networking", status="partial_outage"), _component("Compute", status="major_outage"), ], incidents=[ _incident( "Networking issues", "https://stspg.io/abc123", components=["Networking"], ), _incident( "Compute outage", "https://stspg.io/def456", components=["Compute"], ), ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Networking issues" in str(result.error) assert "Compute outage" in str(result.error) @pytest.mark.asyncio async def test_check_status__incident_carries_source_timestamp(self): """StatusPageWarning carries the most recent incident updated_at as its timestamp.""" api_response = _make_response( [ _component("Networking", status="degraded_performance"), _component("Compute", status="partial_outage"), ], incidents=[ _incident( "Older incident", "https://stspg.io/older", updated_at="2024-01-01T00:00:00.000Z", ), _incident( "Newer incident", "https://stspg.io/newer", updated_at="2024-01-01T06:00:00.000Z", ), ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, StatusPageWarning) expected_ts = datetime.datetime( 2024, 1, 1, 6, 0, 0, tzinfo=datetime.timezone.utc ) assert result.error.timestamp == expected_ts, ( "StatusPageWarning should carry the most recent incident timestamp" ) @pytest.mark.asyncio async def test_check_status__resolved_incidents_are_filtered(self): """Resolved and postmortem incidents do not produce warnings.""" api_response = _make_response( [_component("Networking")], incidents=[ _incident( "Resolved incident", "https://stspg.io/resolved", status="resolved", ), _incident( "Postmortem incident", "https://stspg.io/postmortem", status="postmortem", ), ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is None, ( "Resolved and postmortem incidents should not raise a warning" ) @pytest.mark.asyncio async def test_check_status__http_error(self): """Raise ServiceUnavailable on HTTP error.""" import httpx with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.status_code = 404 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=mock.MagicMock(), response=mock_response ) mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "HTTP error 404" in str(result.error) @pytest.mark.asyncio async def test_check_status__json_parse_error(self): """Raise ServiceUnavailable on JSON parse error.""" with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.side_effect = ValueError("Invalid JSON") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Failed to parse JSON" in str(result.error) def test_base_url_format(self): """Verify correct base URL for Fly.io.""" check = FlyIo() assert check.base_url == "https://status.flyio.net" @pytest.mark.asyncio async def test_check_status__timeout(self): """Raise ServiceUnavailable on timeout.""" import httpx with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( side_effect=httpx.TimeoutException("Timed out") ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "timed out" in str(result.error).lower() @pytest.mark.asyncio async def test_check_status__request_error(self): """Raise ServiceUnavailable on request error.""" import httpx with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( side_effect=httpx.RequestError("Connection refused") ) mock_client.return_value = mock_context check = FlyIo() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Failed to fetch API" in str(result.error) class TestGitHub: """Test GitHub platform status health check via Atlassian API.""" @pytest.mark.integration @pytest.mark.asyncio async def test_check_status__live_summary_endpoint(self): """Verify the check can read a real Statuspage summary endpoint.""" result = await GitHub().get_result() assert result.error is None or isinstance(result.error, StatusPageWarning) @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response( [_component("Actions"), _component("API Requests")], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__missing_expected_key(self): """Raise ServiceUnavailable when the Statuspage payload is malformed.""" api_response = { "page": {"id": "test"}, "components": [_component("Actions")], } with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) assert "Unexpected API response structure" in str(result.error) def test_base_url_format(self): """Verify correct base URL for GitHub.""" assert GitHub().base_url == "https://www.githubstatus.com" assert GitHub("eu").base_url == "https://eu.githubstatus.com" @pytest.mark.asyncio async def test_check_status__component_filter_match(self): """Raise ServiceWarning when an incident affects the watched component.""" api_response = _make_response( [ _component("Actions", status="partial_outage"), _component("API Requests"), ], incidents=[ _incident( "Actions degraded performance", "https://stspg.io/abc123", components=["Actions", "API Requests"], ) ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub(component="Actions") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Actions degraded performance" in str(result.error) assert "https://stspg.io/abc123" in str(result.error) @pytest.mark.asyncio async def test_check_status__component_filter_no_match(self): """Pass when no incident affects the watched component.""" api_response = _make_response( [_component("Actions"), _component("Pages", status="partial_outage")], incidents=[ _incident( "Pages outage", "https://stspg.io/abc123", components=["Pages"], ) ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub(component="Actions") result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__component_not_found(self): """Raise ServiceUnavailable when the configured component name does not exist.""" api_response = _make_response( [_component("Actions"), _component("API Requests")], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub(component="Nonexistent") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) assert "Component 'Nonexistent' not found" in str(result.error) @pytest.mark.asyncio async def test_check_status__no_component_filter(self): """Report all open incidents when no component filter is set.""" api_response = _make_response( [ _component("Actions", status="partial_outage"), _component("Pages", status="degraded_performance"), ], incidents=[ _incident( "Actions degraded performance", "https://stspg.io/abc123", components=["Actions"], ), _incident( "Pages outage", "https://stspg.io/def456", components=["Pages"], ), ], ) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GitHub() result = await check.get_result() assert result.error is not None assert "Actions degraded performance" in str(result.error) assert "Pages outage" in str(result.error) class TestCloudflare: """Test Cloudflare platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("CDN")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Cloudflare() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for Cloudflare.""" check = Cloudflare() assert check.base_url == "https://www.cloudflarestatus.com" class TestPlatformSh: """Test Platform.sh platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("Hosting")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = PlatformSh() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for Platform.sh.""" check = PlatformSh() assert check.base_url == "https://status.platform.sh" class TestDigitalOcean: """Test DigitalOcean platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("Droplets")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = DigitalOcean() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for DigitalOcean.""" check = DigitalOcean() assert check.base_url == "https://status.digitalocean.com" class TestRender: """Test Render platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("Web Services")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Render() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for Render.""" check = Render() assert check.base_url == "https://status.render.com" class TestSentry: """Test Sentry platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("Error Tracking")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Sentry() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for Sentry.""" check = Sentry() assert check.base_url == "https://status.sentry.io" class TestVercel: """Test Vercel platform status health check via Atlassian API.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when there are no open incidents.""" api_response = _make_response([_component("Deployments")]) with mock.patch( "health_check.contrib.atlassian.httpx.AsyncClient" ) as mock_client: mock_response = mock.MagicMock() mock_response.json.return_value = api_response mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Vercel() result = await check.get_result() assert result.error is None def test_base_url_format(self): """Verify correct base URL for Vercel.""" check = Vercel() assert check.base_url == "https://www.vercel-status.com" django-health-check-4.5.1/tests/contrib/test_celery.py000066400000000000000000000154241524213625400230110ustar00rootroot00000000000000"""Tests for Celery health check.""" import datetime import logging from unittest import mock import pytest pytest.importorskip("celery") from kombu import Queue from health_check.contrib.celery import Ping as CeleryPingHealthCheck from health_check.exceptions import ServiceUnavailable from tests.testapp.celery import app as celery_app logger = logging.getLogger(__name__) class TestCelery: """Test Celery ping health check.""" @pytest.mark.asyncio async def test_check_status__unexpected_response(self): """Raise ServiceUnavailable when worker response is incorrect.""" mock_app = mock.MagicMock() mock_app.control.ping.return_value = [{"celery@worker1": {"bad": "response"}}] check = CeleryPingHealthCheck() check.app = mock_app result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "incorrect" in str(result.error).lower() @pytest.mark.asyncio async def test_check_status__oserror(self): """Raise ServiceUnavailable on OS error.""" mock_app = mock.MagicMock() mock_app.control.ping.side_effect = OSError("os error") check = CeleryPingHealthCheck() check.app = mock_app result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__not_implemented_error(self): """Raise ServiceUnavailable when result backend is not configured.""" mock_app = mock.MagicMock() mock_app.control.ping.side_effect = NotImplementedError("no result backend") check = CeleryPingHealthCheck() check.app = mock_app result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "CELERY_RESULT_BACKEND" in str(result.error) @pytest.mark.asyncio async def test_check_status__unknown_error(self): """Raise ServiceUnavailable for unexpected exceptions.""" with mock.patch("celery.app.default_app") as mock_app: mock_app.control.ping.side_effect = RuntimeError("unexpected") check = CeleryPingHealthCheck() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__success(self): """Report healthy when using real Celery app with configured queues.""" default_queue = Queue("default", routing_key="default") celery_app.conf.task_queues = [default_queue] celery_app.conf.task_default_queue = "default" check = CeleryPingHealthCheck(app=celery_app) # Mock the control methods directly on the app before get_result runs mock_ping = mock.MagicMock(return_value=[{"celery@worker1": {"ok": "pong"}}]) mock_inspect_obj = mock.MagicMock() mock_inspect_obj.active_queues.return_value = { "celery@worker1": [{"name": "default"}] } mock_inspect = mock.MagicMock(return_value=mock_inspect_obj) original_ping = check.app.control.ping original_inspect = check.app.control.inspect try: check.app.control.ping = mock_ping check.app.control.inspect = mock_inspect result = await check.get_result() assert result.error is None finally: check.app.control.ping = original_ping check.app.control.inspect = original_inspect @pytest.mark.asyncio async def test_check_status__no_workers(self): """Raise ServiceUnavailable when real app receives no worker response.""" default_queue = Queue("default", routing_key="default") celery_app.conf.task_queues = [default_queue] celery_app.conf.task_default_queue = "default" check = CeleryPingHealthCheck( app=celery_app, timeout=datetime.timedelta(milliseconds=100), ) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "unavailable" in str(result.error).lower() @pytest.mark.asyncio async def test_check_status__missing_queue_worker(self): """Verify queue validation with real Celery app configuration.""" multiple_queues = [ Queue("default", routing_key="default"), Queue("integration_queue", routing_key="integration_queue"), ] celery_app.conf.task_queues = multiple_queues celery_app.conf.task_default_queue = "default" ping_response = [{"celery@worker1": {"ok": "pong"}}] inspect_response = {"celery@worker1": [{"name": "default"}]} check = CeleryPingHealthCheck(app=celery_app) check.app.control.ping = lambda **kwargs: ping_response check.app.control.inspect = lambda *args: mock.MagicMock( active_queues=lambda: inspect_response ) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "integration_queue" in str(result.error) @pytest.mark.asyncio async def test_check_status__raise_type_error__default_queue(self): """Verify that default queue is checked when task_queues is None.""" mock_app = mock.MagicMock() mock_app.conf.task_queues = None mock_app.conf.task_default_queue = "default" mock_app.control.ping.return_value = [{"celery@worker1": {"ok": "pong"}}] mock_inspect = mock.MagicMock() mock_inspect.active_queues.return_value = { "celery@worker1": [{"name": "default"}] } mock_app.control.inspect.return_value = mock_inspect check = CeleryPingHealthCheck() check.app = mock_app result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__none_active_queues(self): """Treat None from inspect.active_queues() as no active queues.""" mock_app = mock.MagicMock() mock_app.conf.task_queues = [Queue("default", routing_key="default")] mock_app.conf.task_default_queue = "default" mock_app.control.ping.return_value = [{"celery@worker1": {"ok": "pong"}}] mock_inspect = mock.MagicMock() mock_inspect.active_queues.return_value = None mock_app.control.inspect.return_value = mock_inspect check = CeleryPingHealthCheck() check.app = mock_app result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "default" in str(result.error) django-health-check-4.5.1/tests/contrib/test_kafka.py000066400000000000000000000102621524213625400225760ustar00rootroot00000000000000"""Tests for Kafka health check.""" import datetime from unittest import mock import pytest pytest.importorskip("confluent_kafka") from confluent_kafka.error import KafkaError, KafkaException from health_check.contrib.kafka import Kafka as KafkaHealthCheck from health_check.exceptions import ServiceUnavailable class TestKafka: """Test Kafka health check.""" @pytest.mark.asyncio async def test_check_status__success(self): """Connect to Kafka successfully when topics are retrieved.""" with mock.patch("health_check.contrib.kafka.AIOConsumer") as mock_consumer_cls: mock_consumer = mock.AsyncMock() mock_consumer_cls.return_value = mock_consumer # Mock cluster metadata response mock_metadata = mock.MagicMock() mock_metadata.topics = {"test-topic": mock.MagicMock()} mock_consumer.list_topics.return_value = mock_metadata check = KafkaHealthCheck(bootstrap_servers=["localhost:9092"]) result = await check.get_result() assert result.error is None # Verify consumer was closed mock_consumer.close.assert_awaited_once() @pytest.mark.asyncio async def test_check_status__kafka_exception(self): """Raise ServiceUnavailable when KafkaException is raised.""" with mock.patch("health_check.contrib.kafka.AIOConsumer") as mock_consumer_cls: mock_consumer = mock.AsyncMock() mock_consumer_cls.return_value = mock_consumer kafka_error = KafkaError(1) # Error code for broker not available mock_consumer.list_topics.side_effect = KafkaException(kafka_error) check = KafkaHealthCheck(bootstrap_servers=["localhost:9092"]) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Unable to connect" in str(result.error) # Verify consumer was closed mock_consumer.close.assert_awaited_once() @pytest.mark.asyncio async def test_check_status__topics_is_none(self): """Raise ServiceUnavailable when metadata is None.""" with mock.patch("health_check.contrib.kafka.AIOConsumer") as mock_consumer_cls: mock_consumer = mock.AsyncMock() mock_consumer_cls.return_value = mock_consumer mock_consumer.list_topics.return_value = None check = KafkaHealthCheck(bootstrap_servers=["localhost:9092"]) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Failed to retrieve Kafka topics" in str(result.error) # Verify consumer was closed mock_consumer.close.assert_awaited_once() @pytest.mark.asyncio async def test_check_status__custom_timeout(self): """Use custom timeout when provided.""" with mock.patch("health_check.contrib.kafka.AIOConsumer") as mock_consumer_cls: mock_consumer = mock.AsyncMock() mock_consumer_cls.return_value = mock_consumer mock_metadata = mock.MagicMock() mock_metadata.topics = {} mock_consumer.list_topics.return_value = mock_metadata check = KafkaHealthCheck( bootstrap_servers=["localhost:9092"], timeout=datetime.timedelta(seconds=5), ) await check.get_result() # Verify timeout was used in consumer configuration call_kwargs = mock_consumer_cls.call_args[0][0] assert call_kwargs["session.timeout.ms"] == 5000 assert call_kwargs["socket.timeout.ms"] == 5000 @pytest.mark.integration @pytest.mark.asyncio async def test_check_status__real_kafka(self, kafka_bootstrap_servers): """Connect to a real Kafka server and report missing topics.""" check = KafkaHealthCheck(bootstrap_servers=[kafka_bootstrap_servers]) result = await check.get_result() assert result.error assert isinstance(result.error, ServiceUnavailable) assert "Failed to retrieve Kafka topics." in str(result.error) django-health-check-4.5.1/tests/contrib/test_psutil.py000066400000000000000000000414051524213625400230440ustar00rootroot00000000000000import datetime import tempfile from unittest import mock import pytest pytest.importorskip("psutil") # Use psutil's named tuples from _ntuples to minimize mocking risks. # This private import is intentional to monitor breaking changes in future psutil updates. from psutil._ntuples import sbattery, sdiskusage, shwtemp, svmem from health_check.contrib.psutil import CPU, Battery, Disk, Memory, Temperature from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, ServiceWarning, ) class TestDisk: """Test the Disk health check.""" @pytest.mark.asyncio async def test_run_check__disk_accessible(self): """Disk space check completes successfully.""" check = Disk() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_run_check__custom_path(self): """Disk check succeeds with custom path.""" with tempfile.TemporaryDirectory() as tmpdir: check = Disk(path=tmpdir) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__disk_usage_exceeds_threshold(self): """Raise ServiceWarning when disk usage exceeds threshold.""" with mock.patch("psutil.disk_usage") as mock_disk_usage: mock_disk_usage.return_value = sdiskusage( total=100000000000, used=95500000000, free=4500000000, percent=95.5 ) check = Disk(max_disk_usage_percent=90.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "95.5" in str(result.error) @pytest.mark.asyncio async def test_check_status__disk_check_disabled(self): """No warning when disk usage check is disabled.""" with mock.patch("psutil.disk_usage") as mock_disk_usage: mock_disk_usage.return_value = sdiskusage( total=100000000000, used=99000000000, free=1000000000, percent=99.0 ) check = Disk(max_disk_usage_percent=None) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__disk_value_error(self): """Raise ServiceReturnedUnexpectedResult when ValueError is raised during disk check.""" with mock.patch("psutil.disk_usage") as mock_disk_usage: mock_disk_usage.side_effect = ValueError("Invalid path") check = Disk() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) class TestMemory: """Test the Memory health check.""" @pytest.mark.asyncio async def test_run_check__memory_available(self): """Memory check completes successfully.""" check = Memory() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__min_memory_available_exceeded(self): """Raise ServiceWarning when available memory is below threshold.""" with mock.patch("psutil.virtual_memory") as mock_virtual_memory: mock_virtual_memory.return_value = svmem( total=8 * (1024**3), available=0.5 * (1024**3), percent=95.0, used=7.5 * (1024**3), free=0.5 * (1024**3), active=0, inactive=0, buffers=0, cached=0, shared=0, slab=0, ) check = Memory(min_gibibytes_available=1.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "RAM" in str(result.error) @pytest.mark.asyncio async def test_check_status__max_memory_usage_exceeded(self): """Raise ServiceWarning when memory usage exceeds threshold.""" with mock.patch("psutil.virtual_memory") as mock_virtual_memory: mock_virtual_memory.return_value = svmem( total=8 * (1024**3), available=1 * (1024**3), percent=95.0, used=7 * (1024**3), free=1 * (1024**3), active=0, inactive=0, buffers=0, cached=0, shared=0, slab=0, ) check = Memory(max_memory_usage_percent=90.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "95" in str(result.error) @pytest.mark.asyncio async def test_check_status__memory_checks_disabled(self): """No warning when memory checks are disabled.""" with mock.patch("psutil.virtual_memory") as mock_virtual_memory: mock_virtual_memory.return_value = svmem( total=8 * (1024**3), available=0.1 * (1024**3), percent=99.0, used=7.9 * (1024**3), free=0.1 * (1024**3), active=0, inactive=0, buffers=0, cached=0, shared=0, slab=0, ) check = Memory(min_gibibytes_available=None, max_memory_usage_percent=None) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__memory_value_error(self): """Raise ServiceReturnedUnexpectedResult when ValueError is raised during memory check.""" with mock.patch("psutil.virtual_memory") as mock_virtual_memory: mock_virtual_memory.side_effect = ValueError("Invalid memory call") check = Memory() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) class TestBattery: """Test the Battery health check.""" @pytest.mark.asyncio async def test_run_check__battery_ok_when_available(self): """Battery check succeeds when battery is above threshold and plugged in.""" battery_info = sbattery(percent=85.0, secsleft=3600, power_plugged=True) with mock.patch("psutil.sensors_battery", return_value=battery_info): check = Battery(min_percent_available=20.0, power_plugged=False) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__battery_below_threshold(self): """Raise ServiceWarning when battery is below minimum threshold.""" battery_info = sbattery(percent=15.0, secsleft=600, power_plugged=False) with mock.patch("psutil.sensors_battery", return_value=battery_info): check = Battery(min_percent_available=20.0, power_plugged=False) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "15.0" in str(result.error) @pytest.mark.asyncio async def test_check_status__power_unplugged_when_required(self): """Raise ServiceWarning when power is unplugged and power_plugged is True.""" battery_info = sbattery(percent=85.0, secsleft=3600, power_plugged=False) with mock.patch("psutil.sensors_battery", return_value=battery_info): check = Battery(min_percent_available=None, power_plugged=True) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "unplugged" in str(result.error).lower() assert "85.0" in str(result.error) @pytest.mark.asyncio async def test_check_status__both_conditions_fail(self): """Raise ServiceWarning when both battery low and power unplugged.""" battery_info = sbattery(percent=15.0, secsleft=600, power_plugged=False) with mock.patch("psutil.sensors_battery", return_value=battery_info): check = Battery(min_percent_available=20.0, power_plugged=True) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) @pytest.mark.asyncio async def test_check_status__battery_not_available(self): """Raise ServiceUnavailable when battery info is not available.""" with mock.patch( "psutil.sensors_battery", side_effect=AttributeError("Not supported") ): check = Battery() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__battery_value_error(self): """Raise ServiceReturnedUnexpectedResult when ValueError is raised.""" with mock.patch("psutil.sensors_battery", side_effect=ValueError("Invalid")): check = Battery() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) @pytest.mark.asyncio async def test_check_status__battery_checks_disabled(self): """No warning when all battery checks are disabled.""" battery_info = sbattery(percent=5.0, secsleft=300, power_plugged=False) with mock.patch("psutil.sensors_battery", return_value=battery_info): check = Battery(min_percent_available=None, power_plugged=False) result = await check.get_result() assert result.error is None class TestCPU: """Test the CPU health check.""" @pytest.mark.asyncio async def test_run_check__cpu_usage_ok(self): """CPU check succeeds when usage is below threshold.""" with mock.patch("psutil.cpu_percent", return_value=50.0): check = CPU(max_usage_percent=95.0) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_run_check__cpu_with_interval(self): """CPU check succeeds with explicit interval measurement.""" with mock.patch("psutil.cpu_percent", return_value=50.0): check = CPU( max_usage_percent=95.0, interval=datetime.timedelta(seconds=0.1) ) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__cpu_exceeds_threshold(self): """Raise ServiceWarning when CPU usage exceeds threshold.""" with mock.patch("psutil.cpu_percent", return_value=95.5): check = CPU(max_usage_percent=90.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "95.5" in str(result.error) @pytest.mark.asyncio async def test_check_status__cpu_check_disabled(self): """No warning when CPU check is disabled.""" with mock.patch("psutil.cpu_percent", return_value=99.9): check = CPU(max_usage_percent=None) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__cpu_value_error(self): """Raise ServiceReturnedUnexpectedResult when ValueError is raised.""" with mock.patch("psutil.cpu_percent", side_effect=ValueError("Invalid")): check = CPU() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) @pytest.mark.asyncio async def test_run_check__cpu_interval_conversion(self): """CPU check correctly converts timedelta to seconds.""" interval = datetime.timedelta(seconds=0.1) check = CPU(interval=interval) with mock.patch("psutil.cpu_percent") as mock_cpu: mock_cpu.return_value = 50.0 result = await check.get_result() mock_cpu.assert_called_once_with(interval=0.1) assert result.error is None class TestTemperature: """Test the Temperature health check.""" @pytest.mark.asyncio async def test_run_check__temperature_ok_with_device(self): """Temperature check succeeds when temps are below threshold.""" sensor = shwtemp(label="Core 0", current=45.0, high=80.0, critical=90.0) temperatures = {"coretemp": [sensor]} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): check = Temperature(device="coretemp", max_temperature_celsius=None) result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__temperature_exceeds_max(self): """Raise ServiceWarning when temperature exceeds max threshold.""" sensor = shwtemp(label="Core 0", current=85.0, high=90.0, critical=95.0) temperatures = {"coretemp": [sensor]} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): check = Temperature(device="coretemp", max_temperature_celsius=80.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "85.0" in str(result.error) assert "Core 0" in str(result.error) @pytest.mark.asyncio async def test_check_status__temperature_exceeds_sensor_high(self): """Raise ServiceWarning when temperature exceeds sensor's high threshold.""" sensor = shwtemp(label="Core 0", current=85.0, high=80.0, critical=90.0) temperatures = {"coretemp": [sensor]} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): # When max_temperature_celsius is None, use sensor.high check = Temperature(device="coretemp", max_temperature_celsius=None) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "85.0" in str(result.error) @pytest.mark.asyncio async def test_check_status__temperature_all_devices(self): """Check all devices when device is None.""" sensor1 = shwtemp(label="Core 0", current=45.0, high=80.0, critical=90.0) sensor2 = shwtemp(label="GPU", current=95.0, high=90.0, critical=100.0) temperatures = {"coretemp": [sensor1], "gpu": [sensor2]} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): check = Temperature(device=None, max_temperature_celsius=90.0) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "95.0" in str(result.error) @pytest.mark.asyncio async def test_check_status__temperature_device_not_found(self): """Raise ServiceUnavailable when specified device is not found.""" temperatures = {"other_device": []} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): check = Temperature(device="coretemp") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "not found" in str(result.error).lower() @pytest.mark.asyncio async def test_check_status__temperature_not_available(self): """Raise ServiceUnavailable when temperature info is not available.""" with mock.patch( "psutil.sensors_temperatures", side_effect=AttributeError("Not supported"), ): check = Temperature() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__temperature_multiple_sensors_in_device(self): """Check all sensors in a device.""" sensor1 = shwtemp(label="Core 0", current=45.0, high=80.0, critical=90.0) sensor2 = shwtemp(label="Core 1", current=85.0, high=80.0, critical=90.0) temperatures = {"coretemp": [sensor1, sensor2]} with mock.patch("psutil.sensors_temperatures", return_value=temperatures): check = Temperature(device="coretemp", max_temperature_celsius=None) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Core 1" in str(result.error) assert "85.0" in str(result.error) django-health-check-4.5.1/tests/contrib/test_rabbitmq.py000066400000000000000000000070671524213625400233330ustar00rootroot00000000000000"""Tests for RabbitMQ health check.""" from unittest import mock import pytest pytest.importorskip("aio_pika") import aio_pika from health_check.contrib.rabbitmq import RabbitMQ as RabbitMQHealthCheck from health_check.exceptions import ServiceUnavailable class TestRabbitMQ: """Test RabbitMQ health check.""" @pytest.mark.asyncio async def test_check_status__success(self): """Connect to RabbitMQ successfully.""" with mock.patch( "health_check.contrib.rabbitmq.aio_pika.connect_robust" ) as mock_connect: mock_conn = mock.AsyncMock() mock_connect.return_value = mock_conn check = RabbitMQHealthCheck(amqp_url="amqp://guest:guest@localhost:5672//") result = await check.get_result() assert result.error is None mock_conn.close.assert_awaited_once() @pytest.mark.asyncio async def test_check_status__connection_refused(self): """Raise ServiceUnavailable when connection is refused.""" with mock.patch( "health_check.contrib.rabbitmq.aio_pika.connect_robust" ) as mock_connect: mock_connect.side_effect = ConnectionRefusedError("refused") check = RabbitMQHealthCheck(amqp_url="amqp://guest:guest@localhost:5672//") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__authentication_error(self): """Raise ServiceUnavailable on authentication failure.""" with mock.patch( "health_check.contrib.rabbitmq.aio_pika.connect_robust" ) as mock_connect: mock_connect.side_effect = aio_pika.exceptions.ProbableAuthenticationError( "auth failed" ) check = RabbitMQHealthCheck(amqp_url="amqp://guest:guest@localhost:5672//") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__os_error(self): """Raise ServiceUnavailable on OS error.""" with mock.patch( "health_check.contrib.rabbitmq.aio_pika.connect_robust" ) as mock_connect: mock_connect.side_effect = OSError("os error") check = RabbitMQHealthCheck(amqp_url="amqp://guest:guest@localhost:5672//") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) @pytest.mark.asyncio async def test_check_status__unknown_error(self): """Unexpected exceptions are caught by base class and logged.""" with mock.patch( "health_check.contrib.rabbitmq.aio_pika.connect_robust" ) as mock_connect: mock_connect.side_effect = RuntimeError("unexpected") check = RabbitMQHealthCheck(amqp_url="amqp://guest:guest@localhost:5672//") result = await check.get_result() assert result.error is not None # Base class catches unexpected exceptions and converts to HealthCheckException assert "unknown error" in str(result.error) @pytest.mark.integration @pytest.mark.asyncio async def test_check_status__real_rabbitmq(self, rabbitmq_url): """Connect to a real RabbitMQ server.""" check = RabbitMQHealthCheck(amqp_url=rabbitmq_url) result = await check.get_result() assert result.error is None django-health-check-4.5.1/tests/contrib/test_redis.py000066400000000000000000000332251524213625400226330ustar00rootroot00000000000000"""Tests for Redis health check.""" from unittest import mock import pytest pytest.importorskip("redis") from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import TimeoutError as RedisTimeoutError from health_check.contrib.redis import Redis as RedisHealthCheck from health_check.exceptions import ServiceUnavailable class TestRedis: """Test Redis health check.""" @pytest.mark.asyncio async def test_redis__ok(self): """Ping Redis successfully when using client_factory parameter.""" mock_client = mock.AsyncMock() mock_client.ping.return_value = True check = RedisHealthCheck(client_factory=lambda: mock_client) result = await check.get_result() assert result.error is None mock_client.ping.assert_called_once() mock_client.aclose.assert_called_once() @pytest.mark.asyncio async def test_redis__connection_refused(self): """Raise ServiceUnavailable when connection is refused.""" mock_client = mock.AsyncMock() mock_client.ping.side_effect = ConnectionRefusedError("refused") check = RedisHealthCheck(client_factory=lambda: mock_client) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) mock_client.aclose.assert_called_once() @pytest.mark.asyncio async def test_redis__timeout(self): """Raise ServiceUnavailable when connection times out.""" mock_client = mock.AsyncMock() mock_client.ping.side_effect = RedisTimeoutError("timeout") check = RedisHealthCheck(client_factory=lambda: mock_client) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) mock_client.aclose.assert_called_once() @pytest.mark.asyncio async def test_redis__connection_error(self): """Raise ServiceUnavailable when connection fails.""" mock_client = mock.AsyncMock() mock_client.ping.side_effect = RedisConnectionError("connection error") check = RedisHealthCheck(client_factory=lambda: mock_client) result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) mock_client.aclose.assert_called_once() @pytest.mark.asyncio async def test_redis__client_deprecated(self): """Verify DeprecationWarning is raised when using client parameter.""" mock_client = mock.AsyncMock() mock_client.ping.return_value = True with pytest.warns( DeprecationWarning, match="client.*deprecated.*client_factory" ): check = RedisHealthCheck(client=mock_client) result = await check.get_result() assert result.error is None mock_client.ping.assert_called_once() # User-provided client should NOT be closed by the health check mock_client.aclose.assert_not_called() @pytest.mark.asyncio async def test_redis__factory_called_for_each_result(self): """Verify client_factory is called per result and each client is closed.""" call_count = 0 created_clients = [] def factory(): nonlocal call_count, created_clients call_count += 1 client = mock.AsyncMock() client.ping.return_value = True created_clients.append(client) return client check = RedisHealthCheck(client_factory=factory) # Factory should not be called eagerly during initialization assert call_count == 0, "Factory should not be called during initialization" # Each request should use a newly created client result1 = await check.get_result() assert result1.error is None assert call_count == 1, "Factory should be called once for first request" result2 = await check.get_result() assert result2.error is None assert call_count == 2, "Factory should be called again for second request" # Ensure a distinct client was created and closed for each result assert len(created_clients) == 2 assert created_clients[0] is not created_clients[1], ( "Each request should create a distinct client" ) created_clients[0].aclose.assert_called_once() created_clients[1].aclose.assert_called_once() @pytest.mark.asyncio async def test_redis__client_not_closed_when_user_provided(self): """Verify user-provided client is NOT closed by health check.""" mock_client = mock.AsyncMock() mock_client.ping.return_value = True with pytest.warns(DeprecationWarning): check = RedisHealthCheck(client=mock_client) result = await check.get_result() assert result.error is None mock_client.ping.assert_called_once() # User is responsible for closing their own client mock_client.aclose.assert_not_called() @pytest.mark.asyncio async def test_redis__validation_both_params(self): """Verify error when both client and client_factory are provided.""" mock_client = mock.AsyncMock() with pytest.raises( ValueError, match="Provide exactly one of `client` or `client_factory`" ): RedisHealthCheck(client=mock_client, client_factory=lambda: mock_client) @pytest.mark.asyncio async def test_redis__validation_neither_param(self): """Verify error when neither client nor client_factory is provided.""" with pytest.raises( ValueError, match="You must provide either `client`.*or `client_factory`" ): RedisHealthCheck() def test_redis__repr_standard_client(self): """Verify repr includes host and db for a standard Redis client.""" # https://github.com/codingjoe/django-health-check/issues/659 from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient(host="myhost", port=6379, db=2) ) assert repr(check) == "Redis(db=2, host='myhost', port=6379)" def test_redis__repr_from_url(self): """Verify repr includes host and db when client is created via from_url.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient.from_url( "redis://cache.example.com:6379/3" ) ) assert "host='cache.example.com'" in repr(check), ( "repr should include the host from the Redis URL" ) assert "db=3" in repr(check), "repr should include the db from the Redis URL" def test_redis__repr_deprecated_client(self): """Verify repr includes host and db when using deprecated client parameter.""" from redis.asyncio import Redis as RedisClient with pytest.warns(DeprecationWarning): check = RedisHealthCheck( client=RedisClient(host="oldhost", port=6379, db=5) ) assert "host='oldhost'" in repr(check), ( "repr should include the host from the deprecated client" ) assert "db=5" in repr(check), ( "repr should include the db from the deprecated client" ) def test_redis__repr_sentinel_client(self): """Verify repr falls back gracefully for Sentinel clients without host/db.""" from redis.asyncio import Sentinel check = RedisHealthCheck( client_factory=lambda: Sentinel([("localhost", 26379)]).master_for( "mymaster" ) ) # Sentinel clients don't expose host/db in connection_pool.connection_kwargs # __repr__ should fall back to the default dataclass repr without raising assert repr(check) == "Redis()" def test_redis__repr_cluster_client(self): """Verify repr includes startup node hosts for RedisCluster clients.""" from redis.asyncio import RedisCluster from redis.asyncio.cluster import ClusterNode check = RedisHealthCheck( client_factory=lambda: RedisCluster( startup_nodes=[ClusterNode("node1", 7000), ClusterNode("node2", 7001)] ) ) assert "node1:7000" in repr(check), ( "repr should include the first cluster node host:port" ) assert "node2:7001" in repr(check), ( "repr should include the second cluster node host:port" ) def test_redis__repr_excludes_password(self): """Verify repr never leaks passwords for standard Redis clients.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient( host="myhost", port=6379, db=0, password="supersecret", # noqa: S106 ) ) assert "supersecret" not in repr(check), ( "repr must never expose the Redis password" ) def test_redis__repr_excludes_password_from_url(self): """Verify repr never leaks passwords embedded in a Redis URL.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient.from_url( "redis://admin:supersecret@cache.example.com:6379/3" ) ) result = repr(check) assert "supersecret" not in result, ( "repr must never expose the password from a Redis URL" ) assert "admin" not in result, ( "repr must never expose the username from a Redis URL" ) def test_redis__repr_excludes_cluster_password(self): """Verify repr never leaks passwords for RedisCluster clients.""" from redis.asyncio import RedisCluster from redis.asyncio.cluster import ClusterNode check = RedisHealthCheck( client_factory=lambda: RedisCluster( startup_nodes=[ClusterNode("node1", 7000)], password="clusterpass", # noqa: S106 username="clusteruser", ) ) result = repr(check) assert "clusterpass" not in result, ( "repr must never expose the cluster password" ) assert "clusteruser" not in result, ( "repr must never expose the cluster username" ) def test_redis__labels_standard_client(self): """Labels include host, port and db for distinct OpenMetrics samples.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient(host="myhost", port=6379, db=2) ) assert check.labels == { "check": "Redis", "host": "myhost", "port": "6379", "db": "2", } def test_redis__labels_distinct_across_clients(self): """Multiple Redis checks with different connections produce distinct labels.""" from redis.asyncio import Redis as RedisClient channels = RedisHealthCheck( client_factory=lambda: RedisClient.from_url("redis://cache:6379/0") ) constance = RedisHealthCheck( client_factory=lambda: RedisClient.from_url("redis://broker:6379/1") ) assert channels.labels != constance.labels assert channels.labels["host"] == "cache" assert constance.labels["host"] == "broker" def test_redis__labels_cluster_client(self): """Labels include cluster node hosts for RedisCluster clients.""" from redis.asyncio import RedisCluster from redis.asyncio.cluster import ClusterNode check = RedisHealthCheck( client_factory=lambda: RedisCluster( startup_nodes=[ClusterNode("node1", 7000), ClusterNode("node2", 7001)] ) ) assert check.labels["check"] == "Redis" assert "node1:7000" in check.labels["hosts"] assert "node2:7001" in check.labels["hosts"] def test_redis__labels_excludes_password(self): """Labels never leak passwords.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck( client_factory=lambda: RedisClient( host="myhost", port=6379, db=0, password="supersecret", # noqa: S106 ) ) assert "supersecret" not in str(check.labels) def test_redis__labels_sentinel_fallback(self): """Labels fall back to the base check name for Sentinel clients.""" from redis.asyncio import Sentinel check = RedisHealthCheck( client_factory=lambda: Sentinel([("localhost", 26379)]).master_for( "mymaster" ) ) assert check.labels == {"check": "Redis"} @pytest.mark.integration @pytest.mark.asyncio async def test_redis__real_connection(self, redis_url): """Ping a real Redis server.""" from redis.asyncio import Redis as RedisClient check = RedisHealthCheck(client_factory=lambda: RedisClient.from_url(redis_url)) result = await check.get_result() assert result.error is None @pytest.mark.integration @pytest.mark.asyncio async def test_redis__real_sentinel(self, redis_sentinel): """Ping a real Redis Sentinel.""" from redis.asyncio import Sentinel sentinel_nodes, service_name = redis_sentinel def factory(): sentinel = Sentinel(sentinel_nodes) return sentinel.master_for(service_name) check = RedisHealthCheck(client_factory=factory) result = await check.get_result() assert result.error is None django-health-check-4.5.1/tests/contrib/test_rss.py000066400000000000000000001052771524213625400223430ustar00rootroot00000000000000"""Tests for cloud provider status feed health checks.""" import datetime import logging from unittest import mock import pytest httpx = pytest.importorskip("httpx") pytest.importorskip("feedparser") from health_check.contrib.rss import ( # noqa: E402 AWS, Azure, GoogleCloud, Heroku, ) from health_check.exceptions import ( # noqa: E402 ServiceUnavailable, ServiceWarning, StatusPageWarning, ) class TestAWS: """Test AWS service status health check.""" @pytest.mark.asyncio async def test_check_status__detects_any_entry_as_incident(self): """Any entry in AWS RSS feed is treated as an incident.""" rss_content = b""" Service is operating normally Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone mock_datetime.timezone = datetime.timezone check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Service is operating normally" in str(result.error) @pytest.mark.asyncio async def test_check_status__multiple_incidents(self): """Multiple entries are all treated as incidents.""" rss_content = b""" Increased API Error Rates Mon, 01 Jan 2024 00:00:00 GMT Resolved: Service disruption Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "\n" in str(result.error) @pytest.mark.asyncio async def test_check_status__incident_carries_source_timestamp(self): """StatusPageWarning carries the most recent incident date as its timestamp.""" rss_content = b""" Older incident Mon, 01 Jan 2024 00:00:00 GMT Newer incident Mon, 01 Jan 2024 06:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 8, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, StatusPageWarning) expected_ts = datetime.datetime( 2024, 1, 1, 6, 0, 0, tzinfo=datetime.timezone.utc ) assert result.error.timestamp == expected_ts, ( "StatusPageWarning should carry the most recent incident date as its timestamp" ) @pytest.mark.asyncio async def test_check_status__no_recent_incidents(self): """Old incidents are filtered out.""" rss_content = b""" Old incident Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__http_error(self): """Raise ServiceUnavailable on HTTP error.""" import httpx with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.status_code = 404 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=mock.MagicMock(), response=mock_response ) mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "HTTP error 404" in str(result.error) @pytest.mark.asyncio async def test_check_status__url_error(self): """Raise ServiceUnavailable on request error.""" import httpx with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( side_effect=httpx.RequestError("Connection refused") ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Failed to fetch feed" in str(result.error) @pytest.mark.asyncio async def test_check_status__timeout_error(self): """Raise ServiceUnavailable on timeout.""" import httpx with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( side_effect=httpx.TimeoutException("Timed out") ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "timed out" in str(result.error) @pytest.mark.asyncio async def test_check_status__parse_error(self): """Feedparser handles malformed feeds gracefully with bozo flag.""" invalid_xml = b"not valid xml" with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = invalid_xml.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() # feedparser handles malformed feeds gracefully - no error raised # but it logs a warning assert result.error is None @pytest.mark.asyncio async def test_extract_date__entry_without_date(self): """Entry without date is treated as recent incident.""" rss_content = b""" Incident without date """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Incident without date" in str(result.error) @pytest.mark.asyncio async def test_extract_date__invalid_date_format(self): """Entry with invalid date format is treated as recent incident.""" rss_content = b""" Incident with bad date not a valid date """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Incident with bad date" in str(result.error) def test_extract_date__invalid_date_tuple_values(self, caplog): """Entry with invalid date tuple values is treated as recent incident.""" check = AWS(region="us-east-1", service="ec2") # Create a mock entry with an invalid date_tuple (day=32) mock_entry = mock.MagicMock() mock_entry.published_parsed = (2024, 1, 32, 0, 0, 0, 0, 0, 0) with caplog.at_level(logging.WARNING): result = check._extract_date(mock_entry) assert result is None assert ( "Failed to parse date from entry (2024, 1, 32, 0, 0, 0, 0, 0, 0) for 'htt" in caplog.text ) @pytest.mark.asyncio async def test_extract_title__entry_without_title(self): """Entry without title shows 'Untitled incident'.""" rss_content = b""" Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = AWS(region="us-east-1", service="ec2") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Unknown Incident" in str(result.error) def test_feed_url_format(self): """Verify correct feed URL format for AWS.""" check = AWS(region="eu-west-1", service="s3") assert check.feed_url == "https://status.aws.amazon.com/rss/s3-eu-west-1.rss" def test_init__missing_region(self): """Raise TypeError when region is missing.""" with pytest.raises(TypeError): AWS(service="s3") def test_init__missing_service(self): """Raise TypeError when service is missing.""" with pytest.raises(TypeError): AWS(region="us-east-1") @pytest.mark.integration @pytest.mark.asyncio async def test_check_status__live_endpoint(self): """Fetch and parse live AWS status feed.""" import os aws_rss_feed_url = os.getenv("AWS_RSS_FEED_URL") if not aws_rss_feed_url: pytest.skip("AWS_RSS_FEED_URL not set; skipping integration test") check = AWS(region="us-east-1", service="ec2") check.feed_url = aws_rss_feed_url result = await check.get_result() # Result can be either None or ServiceWarning, both are acceptable for live endpoint assert result.error is None or isinstance(result.error, ServiceWarning) class TestHeroku: """Test Heroku platform status health check.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when no recent incidents are found.""" rss_content = b""" Old incident Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Heroku() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__raise_service_warning(self): """Raise ServiceWarning when recent incidents are found.""" rss_content = b""" Platform degradation Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = Heroku() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Platform degradation" in str(result.error) def test_feed_url_format(self): """Verify correct feed URL for Heroku.""" check = Heroku() assert check.feed_url == "https://status.heroku.com/feed" class TestHetzner: """Test Hetzner platform status health check.""" @pytest.mark.asyncio async def test_feed_url(self): """Verify correct feed URL for Hetzner.""" from health_check.contrib.rss import Hetzner check = Hetzner() async with httpx.AsyncClient() as client: response = await client.get( check.feed_url, headers={"User-Agent": "django-health-check"} ) assert response.status_code != 404, "Hetzner feed URL is not valid" class TestAzure: """Test Azure platform status health check.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when no recent incidents are found.""" rss_content = b""" Old incident Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = Azure() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__raise_service_warning(self): """Raise ServiceWarning when recent incidents are found.""" rss_content = b""" Virtual Machines outage Mon, 01 Jan 2024 00:00:00 GMT """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = rss_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = Azure() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Virtual Machines outage" in str(result.error) class TestGoogleCloud: """Test Google Cloud platform status health check.""" @pytest.mark.asyncio async def test_check_status__ok(self): """Pass when no recent incidents are found.""" atom_content = b""" Old incident 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GoogleCloud() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__raise_service_warning(self): """Raise ServiceWarning when recent incidents are found.""" atom_content = b""" Compute Engine disruption 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Compute Engine disruption" in str(result.error) @pytest.mark.asyncio async def test_check_status__atom_with_updated_field(self): """Parse Atom feed with updated field instead of published.""" atom_content = b""" Service update 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Service update" in str(result.error) @pytest.mark.asyncio async def test_check_status__atom_without_namespace(self): """Parse Atom feed without explicit namespace prefix.""" atom_content = b""" Incident 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) def test_feed_url_format(self): """Verify correct feed URL for Google Cloud.""" check = GoogleCloud() assert check.feed_url == "https://status.cloud.google.com/en/feed.atom" @pytest.mark.asyncio async def test_check_status__atom_entry_without_namespace_prefix(self): """Parse Atom feed where entries don't have namespace prefix.""" atom_content = b""" Incident 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) @pytest.mark.asyncio async def test_check_status__atom_invalid_date_format(self): """Handle invalid date format in Atom feed.""" atom_content = b""" Incident with invalid date not a valid date """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Incident with invalid date" in str(result.error) @pytest.mark.asyncio async def test_check_status__atom_title_without_namespace_prefix(self): """Parse Atom feed where title doesn't have namespace prefix.""" atom_content = b""" Title without prefix 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Title without prefix" in str(result.error) @pytest.mark.asyncio async def test_check_status__atom_entry_with_empty_title(self): """Handle Atom entry with empty title element.""" atom_content = b""" 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Unknown Incident:" in str(result.error) @pytest.mark.asyncio async def test_check_status__atom_entry_without_title(self): """Handle Atom entry without title element.""" atom_content = b""" 2024-01-01T00:00:00Z """ with mock.patch("health_check.contrib.rss.httpx.AsyncClient") as mock_client: mock_response = mock.MagicMock() mock_response.text = atom_content.decode("utf-8") mock_response.raise_for_status = mock.MagicMock() mock_context = mock.AsyncMock() mock_context.__aenter__.return_value.get = mock.AsyncMock( return_value=mock_response ) mock_client.return_value = mock_context mock_now = datetime.datetime( 2024, 1, 1, 1, 0, 0, tzinfo=datetime.timezone.utc ) with mock.patch( "health_check.contrib.rss.datetime", wraps=datetime ) as mock_datetime: mock_datetime.datetime = mock.Mock(wraps=datetime.datetime) mock_datetime.datetime.now = mock.Mock(return_value=mock_now) mock_datetime.timezone = datetime.timezone check = GoogleCloud() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceWarning) assert "Unknown Incident:" in str(result.error) django-health-check-4.5.1/tests/test_base.py000066400000000000000000000070331524213625400207750ustar00rootroot00000000000000import asyncio import dataclasses from unittest.mock import MagicMock, patch import pytest from health_check.base import HealthCheck, HealthCheckResult from health_check.exceptions import HealthCheckException class TestHealthCheck: @pytest.mark.asyncio async def test_run__success(self): """Execute run without errors.""" class SuccessCheck(HealthCheck): async def run(self): pass check = SuccessCheck() result = await check.get_result() assert result.error is None assert isinstance(result, HealthCheckResult) assert result.check is check @pytest.mark.asyncio async def test_run__unexpected_exception_handled(self): """Catch unexpected exceptions and convert to HealthCheckException.""" class UnexpectedErrorCheck(HealthCheck): async def run(self): raise RuntimeError("Unexpected error") check = UnexpectedErrorCheck() result = await check.get_result() assert result.error is not None assert isinstance(result.error, HealthCheckException) assert str(result.error) == "Unknown Error: unknown error" @pytest.mark.asyncio async def test_run__sync_check(self): """Execute synchronous run method in thread.""" class SyncCheck(HealthCheck): def run(self): pass check = SyncCheck() result = await check.get_result() assert result.error is None assert isinstance(result, HealthCheckResult) @pytest.mark.asyncio async def test_run__sync_check_uses_custom_executor(self): """Pass custom executor to run_in_executor for synchronous checks.""" class SyncCheck(HealthCheck): def run(self): pass check = SyncCheck() custom_executor = MagicMock() loop = asyncio.get_running_loop() with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock: await check.get_result(executor=custom_executor) mock.assert_called_once_with(custom_executor, check.run) @pytest.mark.asyncio async def test_run__sync_check_default_executor(self): """Use default executor (None) for synchronous checks when none is supplied.""" class SyncCheck(HealthCheck): def run(self): pass check = SyncCheck() loop = asyncio.get_running_loop() with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock: result = await check.get_result() mock.assert_called_once_with(None, check.run) assert result.error is None @pytest.mark.asyncio async def test_result__timing(self): """Result includes execution time.""" class SlowCheck(HealthCheck): async def run(self): import asyncio await asyncio.sleep(0.01) check = SlowCheck() result = await check.get_result() assert result.time_taken > 0 def test_labels(self): """Labels include class name and dataclass fields, excluding secret fields.""" @dataclasses.dataclass class LabeledCheck(HealthCheck): foo: str = "bar" version: float = 1.0 secret_key: str = dataclasses.field(default="secret", repr=False) missing_value: str | None = None async def run(self): pass check = LabeledCheck() assert check.labels == {"check": "LabeledCheck", "foo": "bar", "version": "1.0"} django-health-check-4.5.1/tests/test_checks.py000066400000000000000000000653741524213625400213370ustar00rootroot00000000000000"""Integration tests for health check implementations.""" import datetime import logging from unittest import mock import django import pytest from django import db from django.core.cache import CacheKeyWarning from django.test import override_settings from health_check import Storage from health_check.checks import DNS, Cache, Database, Mail from health_check.exceptions import ( ServiceReturnedUnexpectedResult, ServiceUnavailable, ) class TestCache: """Test the Cache health check.""" @pytest.mark.asyncio async def test_run_check__invalid_alias(self): """Raise ServiceUnavailable when cache alias does not exist.""" check = Cache(alias="nonexistent-alias") result = await check.get_result() assert isinstance(result.error, ServiceUnavailable), ( "Expected ServiceUnavailable for a non-existent cache alias" ) assert "Cache alias does not exist" in str(result.error) @pytest.mark.asyncio async def test_run_check__cache_working(self): """Cache backend successfully sets and retrieves values.""" check = Cache() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_run_check__cache_uses_unique_runtime_key(self): """Cache check uses an isolated cache key per run to avoid race conditions.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(return_value=None) async def _aget(_key): return mock_cache.aset.await_args.args[1] mock_cache.aget = mock.AsyncMock(side_effect=_aget) check = Cache() result = await check.get_result() assert result.error is None cache_key = mock_cache.aset.await_args.args[0] assert cache_key.startswith("djangohealthcheck_test:") assert cache_key != "djangohealthcheck_test" assert mock_cache.aset.await_args.kwargs["timeout"] == 5.0 @pytest.mark.asyncio async def test_run_check__cache_timeout_is_configurable(self): """Cache check passes timeout to backend as seconds.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(return_value=None) async def _aget(_key): return mock_cache.aset.await_args.args[1] mock_cache.aget = mock.AsyncMock(side_effect=_aget) check = Cache(timeout=datetime.timedelta(seconds=2)) result = await check.get_result() assert result.error is None assert mock_cache.aset.await_args.kwargs["timeout"] == 2.0 @pytest.mark.asyncio async def test_run_check__cache_supports_key_prefix_argument(self): """Cache check supports custom key prefix argument.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(return_value=None) async def _aget(_key): return mock_cache.aset.await_args.args[1] mock_cache.aget = mock.AsyncMock(side_effect=_aget) check = Cache(key_prefix="healthcheck") result = await check.get_result() assert result.error is None cache_key = mock_cache.aset.await_args.args[0] assert cache_key.startswith("healthcheck:") @pytest.mark.asyncio async def test_run_check__cache_generates_distinct_key_per_run(self): """Cache check generates a new key on each probe run.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(return_value=None) async def _aget(_key): return mock_cache.aset.await_args.args[1] mock_cache.aget = mock.AsyncMock(side_effect=_aget) check = Cache() first_result = await check.get_result() second_result = await check.get_result() assert first_result.error is None assert second_result.error is None first_key = mock_cache.aset.await_args_list[0].args[0] second_key = mock_cache.aset.await_args_list[1].args[0] assert first_key != second_key class TestDatabase: """Test the Database health check.""" @pytest.mark.django_db @pytest.mark.asyncio async def test_run_check__invalid_alias(self): """Raise ServiceUnavailable when database alias does not exist.""" check = Database(alias="nonexistent-alias") result = await check.get_result() assert isinstance(result.error, ServiceUnavailable), ( "Expected ServiceUnavailable for a non-existent database alias" ) assert "Database alias does not exist" in str(result.error) @pytest.mark.django_db @pytest.mark.asyncio async def test_run_check__database_available(self): """Database connection returns successful query result.""" check = Database() result = await check.get_result() assert result.error is None class TestDNS: """Test the DNS health check.""" @pytest.mark.asyncio async def test_run_check__dns_working(self): """DNS resolution completes successfully for localhost.""" check = DNS(hostname="github.com") result = await check.get_result() assert result.error is None class TestMail: """Test the Mail health check.""" @pytest.mark.asyncio async def test_run_check__locmem_backend(self): """Mail check completes with locmem backend.""" with override_settings( EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend" ): check = Mail() result = await check.get_result() assert result.error is None def test_defaults(self): """Mail check defaults to backend=None and DEFAULT_MAILER_ALIAS.""" from health_check.checks import DEFAULT_MAILER_ALIAS check = Mail() assert check.backend is None assert check.alias == DEFAULT_MAILER_ALIAS @pytest.mark.skipif( django.VERSION < (6, 1), reason="mailers handler requires Django 6.1+", ) def test_get_connection__mailers_configured(self): """Use the mailers handler when available.""" mock_connection = mock.MagicMock() mock_mailers = mock.MagicMock() mock_mailers.__getitem__.return_value = mock_connection with mock.patch("health_check.checks.mailers", mock_mailers): check = Mail(alias="default") connection = check._get_connection() assert connection is mock_connection mock_mailers.__getitem__.assert_called_once_with("default") @pytest.mark.skipif( django.VERSION < (6, 1), reason="mailers handler requires Django 6.1+", ) def test_get_connection__mailers_custom_alias(self): """Use a custom mailer alias when available.""" mock_connection = mock.MagicMock() mock_mailers = mock.MagicMock() mock_mailers.__getitem__.return_value = mock_connection with mock.patch("health_check.checks.mailers", mock_mailers): check = Mail(alias="custom") connection = check._get_connection() assert connection is mock_connection mock_mailers.__getitem__.assert_called_once_with("custom") def test_get_connection__legacy_fallback(self): """Fall back to get_connection on Django < 6.1.""" mock_connection = mock.MagicMock() with ( mock.patch("health_check.checks.django.VERSION", (5, 2)), mock.patch( "health_check.checks.get_connection", return_value=mock_connection ) as mock_get_conn, ): check = Mail(backend="django.core.mail.backends.locmem.EmailBackend") connection = check._get_connection() assert connection is mock_connection mock_get_conn.assert_called_once_with( "django.core.mail.backends.locmem.EmailBackend", fail_silently=False, ) @pytest.mark.skipif( django.VERSION < (6, 1), reason="MAILERS requires Django 6.1+", ) @pytest.mark.asyncio async def test_run_check__mailers_integration(self): """Mail check completes end-to-end with MAILERS configured.""" with override_settings( MAILERS={ "default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"} } ): check = Mail() result = await check.get_result() assert result.error is None class TestStorage: """Test the Storage health check.""" @pytest.mark.asyncio async def test_run_check__invalid_alias(self): """Raise ServiceUnavailable when storage alias does not exist.""" check = Storage(alias="nonexistent-alias") result = await check.get_result() assert isinstance(result.error, ServiceUnavailable), ( "Expected ServiceUnavailable for a non-existent storage alias" ) assert "Storage alias does not exist" in str(result.error) @pytest.mark.asyncio async def test_run_check__default_storage(self): """Storage check completes without exceptions.""" check = Storage() result = await check.get_result() assert result.error is None class TestServiceUnavailable: """Test ServiceUnavailable exception formatting.""" def test_str__exception_message(self): """Format exception with message type prefix.""" exc = ServiceUnavailable("Test error") assert str(exc) == "Unavailable: Test error" class TestCacheExceptionHandling: """Test Cache exception handling for uncovered code paths.""" @pytest.mark.asyncio async def test_check_status__cache_key_warning(self): """Raise ServiceReturnedUnexpectedResult when CacheKeyWarning is raised during set.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(side_effect=CacheKeyWarning("Invalid key")) check = Cache() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) assert "Cache key warning" in str(result.error) @pytest.mark.asyncio async def test_check_status__value_error(self): """Raise ServiceReturnedUnexpectedResult when ValueError is raised during cache operation.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(side_effect=ValueError("Invalid value")) check = Cache() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) assert "ValueError" in str(result.error) @pytest.mark.asyncio async def test_check_status__connection_error(self): """Raise ServiceReturnedUnexpectedResult when ConnectionError is raised during cache operation.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock( side_effect=ConnectionError("Connection failed") ) check = Cache() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceReturnedUnexpectedResult) assert "Connection Error" in str(result.error) @pytest.mark.asyncio async def test_check_status__cache_value_mismatch(self): """Raise ServiceUnavailable when cached value does not match set value.""" with mock.patch("health_check.checks.caches") as mock_caches: mock_cache = mock.MagicMock() mock_caches.__getitem__.return_value = mock_cache mock_cache.aset = mock.AsyncMock(return_value=None) mock_cache.aget = mock.AsyncMock(return_value="wrong-value") check = Cache() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "does not match" in str(result.error) class TestDatabaseExceptionHandling: """Test Database exception handling for uncovered code paths.""" @pytest.mark.django_db @pytest.mark.asyncio async def test_check_status__query_returns_unexpected_result(self): """Raise ServiceUnavailable when query does not return (1,).""" with mock.patch("health_check.checks.connections") as mock_connections: mock_connection = mock.MagicMock() mock_connections.__getitem__.return_value = mock_connection mock_cursor = mock.MagicMock() mock_connection.cursor.return_value.__enter__.return_value = mock_cursor mock_cursor.fetchone.return_value = (0,) mock_connection.ops.compiler.return_value = mock.MagicMock( return_value=mock.MagicMock(compile=lambda x: ("SELECT 0", [])) ) check = Database() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "did not return the expected result" in str(result.error) @pytest.mark.django_db @pytest.mark.asyncio async def test_check_status__database_exception(self): """Raise ServiceUnavailable on database exception.""" with mock.patch("health_check.checks.connections") as mock_connections: mock_connection = mock.MagicMock() mock_connections.__getitem__.return_value = mock_connection # Raise a database error (not a generic RuntimeError) mock_connection.ops.compiler.side_effect = db.Error("Database error") check = Database() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) class TestDNSExceptionHandling: """Test DNS exception handling for uncovered code paths.""" @pytest.mark.asyncio async def test_check_status__nonexistent_hostname(self): """Raise ServiceUnavailable when hostname does not exist.""" check = DNS(hostname="this-domain-does-not-exist-12345.invalid") result = await check.get_result() assert result.error is not None assert "does not exist" in str(result.error) @pytest.mark.asyncio async def test_check_status__no_answer(self): """Raise ServiceUnavailable when DNS returns no answer for A record.""" # Test with a hostname that has no A record (MX-only domain for example) # Using a TXT-only record subdomain or similar check = DNS(hostname="_dmarc.github.com") result = await check.get_result() assert result.error is not None # Will get either no answer or NXDOMAIN error_msg = str(result.error).lower() assert "no answer" in error_msg or "does not exist" in error_msg @pytest.mark.asyncio async def test_check_status__timeout(self): """Raise ServiceUnavailable when DNS query times out.""" # Use a very short timeout to trigger timeout error check = DNS( hostname="example.com", timeout=datetime.timedelta(microseconds=1), ) result = await check.get_result() assert result.error is not None assert "timeout" in str(result.error).lower() @pytest.mark.asyncio async def test_check_status__not_a_nameserver(self): """Raise ServiceUnavailable when nameserver is unreachable.""" # Use an invalid/unreachable nameserver check = DNS(hostname="example.com", nameservers=["192.0.2.1"]) result = await check.get_result() assert result.error is not None # Could be timeout or no nameservers error error_msg = str(result.error).lower() assert "timeout" in error_msg or "nameserver" in error_msg @pytest.mark.asyncio async def test_check_status__no_nameservers(self): """Raise ServiceUnavailable when nameserver is unreachable.""" # Use an invalid/unreachable nameserver check = DNS(hostname="example.com", nameservers=[]) result = await check.get_result() assert result.error is not None # Could be timeout or no nameservers error error_msg = str(result.error).lower() assert "timeout" in error_msg or "nameserver" in error_msg @pytest.mark.asyncio async def test_check_status__dns_exception(self): """Raise ServiceUnavailable on general DNS exception.""" import dns.exception with mock.patch( "health_check.checks.dns.asyncresolver.Resolver" ) as mock_resolver_class: mock_resolver = mock.MagicMock() mock_resolver_class.return_value = mock_resolver mock_resolver.resolve = mock.AsyncMock( side_effect=dns.exception.DNSException("DNS error") ) check = DNS(hostname="example.com") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "DNS resolution failed" in str(result.error) class TestMailExceptionHandling: """Test Mail exception handling for uncovered code paths.""" @pytest.mark.asyncio async def test_check_status__success(self, caplog): """Successfully open and close connection logs debug message.""" with mock.patch.object(Mail, "_get_connection") as mock_get_connection: mock_connection = mock.MagicMock() mock_get_connection.return_value = mock_connection mock_connection.open.return_value = None check = Mail(backend="django.core.mail.backends.locmem.EmailBackend") with caplog.at_level(logging.DEBUG, logger="health_check.checks"): result = await check.get_result() assert result.error is None # Verify debug logging was called assert any( "Trying to open connection to mail backend" in record.message or "Connection established" in record.message for record in caplog.records ) @pytest.mark.asyncio async def test_check_status__smtp_exception(self): """Raise ServiceUnavailable when SMTPException is raised.""" import smtplib with mock.patch.object(Mail, "_get_connection") as mock_get_connection: mock_connection = mock.MagicMock() mock_get_connection.return_value = mock_connection mock_connection.open.side_effect = smtplib.SMTPException("SMTP error") check = Mail(backend="django.core.mail.backends.locmem.EmailBackend") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "SMTP server" in str(result.error) mock_connection.close.assert_called_once() @pytest.mark.asyncio async def test_check_status__connection_refused_error(self): """Raise ServiceUnavailable when ConnectionRefusedError is raised.""" with mock.patch.object(Mail, "_get_connection") as mock_get_connection: mock_connection = mock.MagicMock() mock_get_connection.return_value = mock_connection mock_connection.open.side_effect = ConnectionRefusedError( "Connection refused" ) check = Mail(backend="django.core.mail.backends.locmem.EmailBackend") result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Connection refused" in str(result.error) mock_connection.close.assert_called_once() class TestStorageExceptionHandling: """Test Storage exception handling for uncovered code paths.""" @pytest.mark.asyncio async def test_check_status__success(self): """Storage check completes successfully without exceptions.""" with ( mock.patch("health_check.checks.storages") as mock_storages, mock.patch( "health_check.checks.Storage.get_file_content" ) as get_file_content, ): mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.return_value = "test-file.txt" mock_storage.exists.side_effect = [True, False] mock_file = mock.MagicMock() mock_file.read.return_value = b"# generated by health_check.Storage at" mock_storage.open.return_value.__enter__.return_value = mock_file get_file_content.return_value = b"# generated by health_check.Storage at" check = Storage() result = await check.get_result() assert result.error is None @pytest.mark.asyncio async def test_check_status__not_deleted(self): """Storage check completes successfully without exceptions.""" with ( mock.patch("health_check.checks.storages") as mock_storages, mock.patch( "health_check.checks.Storage.get_file_content" ) as get_file_content, ): mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.return_value = "test-file.txt" mock_storage.exists.return_value = True mock_file = mock.MagicMock() mock_file.read.return_value = b"# generated by health_check.Storage at" mock_storage.open.return_value.__enter__.return_value = mock_file get_file_content.return_value = b"# generated by health_check.Storage at" check = Storage() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "File was not deleted" in str(result.error) @pytest.mark.asyncio async def test_check_status__file_not_saved(self): """Raise ServiceUnavailable when file does not exist after save.""" with mock.patch("health_check.checks.storages") as mock_storages: mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.return_value = "test-file.txt" mock_storage.exists.return_value = False check = Storage() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "does not exist" in str(result.error) @pytest.mark.asyncio async def test_check_status__file_content_mismatch(self): """Raise ServiceUnavailable when file content does not match.""" with mock.patch("health_check.checks.storages") as mock_storages: mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.return_value = "test-file.txt" mock_storage.exists.return_value = True mock_file = mock.MagicMock() mock_file.read.return_value = b"wrong content" mock_storage.open.return_value.__enter__.return_value = mock_file check = Storage() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "does not match" in str(result.error) @pytest.mark.asyncio async def test_check_status__file_content_mismatch__cleanup(self): """Ensure file is deleted even when content mismatch occurs.""" with mock.patch("health_check.checks.storages") as mock_storages: mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.return_value = "test-file.txt" mock_storage.exists.return_value = True mock_file = mock.MagicMock() mock_file.read.return_value = b"wrong content" mock_storage.open.return_value.__enter__.return_value = mock_file check = Storage() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "does not match" in str(result.error) mock_storage.delete.assert_called_once() @pytest.mark.asyncio async def test_check_status__service_unavailable_passthrough(self): """Re-raise ServiceUnavailable exceptions.""" with mock.patch("health_check.checks.storages") as mock_storages: mock_storage = mock.MagicMock() mock_storages.__getitem__.return_value = mock_storage mock_storage.save.side_effect = ServiceUnavailable("Service down") check = Storage() result = await check.get_result() assert result.error is not None assert isinstance(result.error, ServiceUnavailable) assert "Service down" in str(result.error) class TestSelectOneExpression: """Test _SelectOne expression for database queries.""" @pytest.mark.django_db def test_oracle_query__generates_correct_sql(self): """Generate Oracle-specific SQL query with DUAL table.""" from unittest.mock import MagicMock from health_check.checks import _SelectOne expr = _SelectOne() mock_compiler = MagicMock() mock_connection = MagicMock() sql, params = expr.as_oracle(mock_compiler, mock_connection) assert sql == "SELECT 1 FROM DUAL" assert params == [] @pytest.mark.django_db def test_standard_query__generates_correct_sql(self): """Generate standard SQL query.""" from unittest.mock import MagicMock from health_check.checks import _SelectOne expr = _SelectOne() mock_compiler = MagicMock() mock_connection = MagicMock() sql, params = expr.as_sql(mock_compiler, mock_connection) assert sql == "SELECT 1" assert params == [] django-health-check-4.5.1/tests/test_exceptions.py000066400000000000000000000136061524213625400222470ustar00rootroot00000000000000"""Unit tests for health_check.exceptions module.""" import datetime import pytest from django.utils import timezone from health_check.exceptions import ( HealthCheckException, ServiceReturnedUnexpectedResult, ServiceUnavailable, ServiceWarning, StatusPageWarning, ) class TestHealthCheckException: def test_init__store_message(self): """Store message passed to constructor.""" exc = HealthCheckException("test message") assert exc.message == "test message" def test_init__timestamp_defaults_to_now(self): """Default timestamp to current time when not provided.""" before = timezone.now() exc = HealthCheckException("test message") after = timezone.now() assert before <= exc.timestamp <= after def test_init__store_timestamp(self): """Store explicit timestamp passed to constructor.""" ts = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) exc = HealthCheckException("test message", timestamp=ts) assert exc.timestamp == ts def test_str__format_with_type(self): """Format string with message type and message.""" exc = HealthCheckException("foo") assert str(exc) == "Unknown Error: foo" def test_inherits_from_exception(self): """Inherit from Exception base class.""" exc = HealthCheckException("test") assert isinstance(exc, Exception) def test_can_raise_and_catch(self): """Can be raised and caught.""" with pytest.raises(HealthCheckException) as exc_info: raise HealthCheckException("error message") assert exc_info.value.message == "error message" class TestServiceWarning: def test_str__format_with_warning_type(self): """Format string with 'warning' message type.""" exc = ServiceWarning("unstable") assert str(exc) == "Warning: unstable" def test_inherits_from_health_check_exception(self): """Inherit from HealthCheckException.""" exc = ServiceWarning("test") assert isinstance(exc, HealthCheckException) assert isinstance(exc, Exception) def test_can_raise_and_catch_as_exception(self): """Can be caught as HealthCheckException.""" with pytest.raises(HealthCheckException): raise ServiceWarning("warning message") def test_can_raise_and_catch_as_specific_type(self): """Can be caught as ServiceWarning specifically.""" with pytest.raises(ServiceWarning): raise ServiceWarning("warning message") class TestServiceUnavailable: def test_str__format_with_unavailable_type(self): """Format string with 'unavailable' message type.""" exc = ServiceUnavailable("database down") assert str(exc) == "Unavailable: database down" def test_inherits_from_health_check_exception(self): """Inherit from HealthCheckException.""" exc = ServiceUnavailable("test") assert isinstance(exc, HealthCheckException) assert isinstance(exc, Exception) def test_can_raise_and_catch_as_exception(self): """Can be caught as HealthCheckException.""" with pytest.raises(HealthCheckException): raise ServiceUnavailable("unavailable message") def test_can_raise_and_catch_as_specific_type(self): """Can be caught as ServiceUnavailable specifically.""" with pytest.raises(ServiceUnavailable): raise ServiceUnavailable("unavailable message") class TestServiceReturnedUnexpectedResult: def test_str__format_with_unexpected_result_type(self): """Format string with 'unexpected result' message type.""" exc = ServiceReturnedUnexpectedResult("wrong format") assert str(exc) == "Unexpected Result: wrong format" def test_inherits_from_health_check_exception(self): """Inherit from HealthCheckException.""" exc = ServiceReturnedUnexpectedResult("test") assert isinstance(exc, HealthCheckException) assert isinstance(exc, Exception) def test_can_raise_and_catch_as_exception(self): """Can be caught as HealthCheckException.""" with pytest.raises(HealthCheckException): raise ServiceReturnedUnexpectedResult("unexpected result message") def test_can_raise_and_catch_as_specific_type(self): """Can be caught as ServiceReturnedUnexpectedResult specifically.""" with pytest.raises(ServiceReturnedUnexpectedResult): raise ServiceReturnedUnexpectedResult("unexpected result message") class TestStatusPageWarning: def test_init__store_message(self): """Store message passed to constructor.""" exc = StatusPageWarning("incident detected") assert exc.message == "incident detected" def test_init__timestamp_defaults_to_now(self): """Default timestamp to current time when not provided.""" before = timezone.now() exc = StatusPageWarning("incident detected") after = timezone.now() assert before <= exc.timestamp <= after def test_init__store_timestamp(self): """Store timestamp passed to constructor.""" ts = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc) exc = StatusPageWarning("incident detected", timestamp=ts) assert exc.timestamp == ts def test_str__format_with_warning_type(self): """Format string with 'warning' message type.""" exc = StatusPageWarning("incident detected") assert str(exc) == "Warning: incident detected" def test_inherits_from_service_warning(self): """Inherit from ServiceWarning.""" exc = StatusPageWarning("incident") assert isinstance(exc, ServiceWarning) assert isinstance(exc, HealthCheckException) def test_can_raise_and_catch_as_service_warning(self): """Can be caught as ServiceWarning.""" with pytest.raises(ServiceWarning): raise StatusPageWarning("incident") django-health-check-4.5.1/tests/test_management.py000066400000000000000000000312401524213625400221740ustar00rootroot00000000000000"""Tests for health_check management command.""" import os from io import StringIO from unittest.mock import Mock, patch from urllib.error import HTTPError from urllib.parse import urlparse import pytest from django.core.management import call_command class TestHealthCheckCommand: """Test health_check management command.""" def test_handle__success(self, live_server): """Return exit code 0 when all checks pass.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() call_command( "health_check", "health_check_test", addrport, stdout=stdout, stderr=stderr, ) output = stdout.getvalue() # Check that output contains health check results assert "Database" in output or "Cache" in output assert "OK" in output or "working" in output def test_handle__http_error(self, live_server): """Return exit code 1 when checks fail with HTTP 500.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_fail", addrport, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 1 output = stdout.getvalue() # Should display the error message from the failing check assert "Test failure" in output or "AlwaysFailingCheck" in output def test_handle__url_error__connection_refused(self): """Return exit code 2 when URL cannot be reached (connection refused).""" stdout = StringIO() stderr = StringIO() with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_test", "localhost:9999", stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 2 error_output = stderr.getvalue() assert "not reachable" in error_output def test_handle__forwarded_host(self, live_server): """Set X-Forwarded-Host header when --forwarded-host is provided.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() call_command( "health_check", "health_check_test", addrport, forwarded_host="example.com", stdout=stdout, stderr=stderr, ) output = stdout.getvalue() assert "OK" in output or "working" in output def test_handle__forwarded_proto(self, live_server): """Set X-Forwarded-Proto header when --forwarded-proto is provided.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() call_command( "health_check", "health_check_test", addrport, forwarded_proto="https", stdout=stdout, stderr=stderr, ) output = stdout.getvalue() assert "OK" in output or "working" in output def test_handle__default_forwarded_proto_is_https(self, live_server): """X-Forwarded-Proto header defaults to 'https'.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() mock_response.read.return_value = b"OK" mock_urlopen.return_value = mock_response with patch("urllib.request.Request") as mock_request: call_command( "health_check", "health_check_test", addrport, stdout=stdout, stderr=stderr, ) # Verify that Request was called with X-Forwarded-Proto: https call_args = mock_request.call_args # Extract headers from kwargs, falling back to positional args if "headers" in call_args.kwargs: headers = call_args.kwargs["headers"] else: headers = call_args.args[1] if len(call_args.args) > 1 else {} assert headers.get("X-Forwarded-Proto") == "https" # Verify command completed successfully output = stdout.getvalue() assert "OK" in output def test_handle__verbosity_level_0(self, live_server): """Verbosity level 0 shows minimal output.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() call_command( "health_check", "health_check_test", addrport, verbosity=0, stdout=stdout, stderr=stderr, ) output = stdout.getvalue() # At verbosity 0, should still show results but no debug info assert "Checking health endpoint" not in output def test_handle__verbosity_level_2(self, live_server): """Verbosity level 2 shows debug information.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() call_command( "health_check", "health_check_test", addrport, verbosity=2, stdout=stdout, stderr=stderr, ) output = stdout.getvalue() # At verbosity 2, should show debug info about the request assert "Checking health endpoint" in output assert "with headers:" in output def test_handle__http_400_error(self, live_server): """Return exit code 2 and helpful message for HTTP 400 errors.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with patch("urllib.request.urlopen") as mock_urlopen: mock_urlopen.side_effect = HTTPError( url=f"http://{addrport}/health/test/", code=400, msg="Bad Request", hdrs={}, fp=None, ) with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_test", addrport, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 2 error_output = stderr.getvalue() assert "not reachable" in error_output # Should suggest checking ALLOWED_HOSTS or using --forwarded-host assert "ALLOWED_HOSTS" in error_output assert "forwarded-host" in error_output def test_handle__unexpected_http_error(self, live_server): """Return exit code 2 and helpful message for unexpected HTTP errors.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with patch("urllib.request.urlopen") as mock_urlopen: mock_urlopen.side_effect = HTTPError( url=f"http://{addrport}/health/test/", code=404, msg="Not Found", hdrs={}, fp=None, ) with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_test", addrport, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 2 error_output = stderr.getvalue() assert "Unexpected HTTP error" in error_output assert "invalid endpoint" in error_output def test_handle__timeout_error(self, live_server): """Return exit code 2 when request times out.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with patch("urllib.request.urlopen") as mock_urlopen: mock_urlopen.side_effect = TimeoutError("Connection timed out") with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_test", addrport, timeout=1, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 2 error_output = stderr.getvalue() assert "Timeout" in error_output def test_handle__invalid_endpoint(self): """Return exit code 2 when endpoint name is invalid.""" stdout = StringIO() stderr = StringIO() with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "nonexistent_endpoint", "localhost:8000", stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 2 error_output = stderr.getvalue() assert "Could not resolve endpoint" in error_output assert "nonexistent_endpoint" in error_output def test_handle__default_addrport_from_env(self, live_server): """Default addrport uses HOST and PORT environment variables.""" parsed = urlparse(live_server.url) # Set environment variables env_vars = { "HOST": parsed.hostname, "PORT": str(parsed.port), } stdout = StringIO() stderr = StringIO() with patch.dict(os.environ, env_vars): # Call command without addrport argument to use default call_command( "health_check", "health_check_test", stdout=stdout, stderr=stderr, ) output = stdout.getvalue() assert "OK" in output or "working" in output @pytest.mark.django_db def test_handle__no_html_success(self): """Run checks directly without HTTP server when --no-http is provided.""" stdout = StringIO() stderr = StringIO() with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_test", use_http=False, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 0 output = stdout.getvalue() # Check that output contains health check results assert "Database" in output or "Cache" in output assert "OK" in output or "working" in output def test_handle__no_html_failure(self): """Return exit code 1 when checks fail with --no-http.""" stdout = StringIO() stderr = StringIO() with pytest.raises(SystemExit) as exc_info: call_command( "health_check", "health_check_fail", use_http=False, stdout=stdout, stderr=stderr, ) assert exc_info.value.code == 1 output = stdout.getvalue() # Should display the error message from the failing check assert "Test failure" in output or "AlwaysFailingCheck" in output def test_handle__html_flag_uses_http_server(self, live_server): """Run checks via HTTP server when --http is explicitly provided.""" parsed = urlparse(live_server.url) addrport = f"{parsed.hostname}:{parsed.port}" stdout = StringIO() stderr = StringIO() with patch("urllib.request.urlopen") as mock_urlopen: mock_response = Mock() mock_response.read.return_value = ( b"Database(alias='default'): OK\nCache(alias='default'): OK\n" ) mock_urlopen.return_value = mock_response call_command( "health_check", "health_check_test", addrport, use_http=True, stdout=stdout, stderr=stderr, ) mock_urlopen.assert_called_once() output = stdout.getvalue() assert "OK" in output or "working" in output django-health-check-4.5.1/tests/test_views.py000066400000000000000000001223271524213625400212240ustar00rootroot00000000000000import dataclasses import json import pytest from health_check.base import HealthCheck from health_check.exceptions import ( HealthCheckException, ServiceWarning, StatusPageWarning, ) from health_check.views import HealthCheckView, MediaType class TestMediaType: def test_lt__equal_weight(self): """Equal weights do not satisfy less-than comparison.""" assert not MediaType("*/*") < MediaType("*/*") assert not MediaType("*/*") < MediaType("*/*", 0.9) def test_lt__lesser_weight(self): """Lesser weight satisfies less-than comparison.""" assert MediaType("*/*", 0.9) < MediaType("*/*") def test_str__default_weight(self): """Format string with default weight of 1.0.""" assert str(MediaType("*/*")) == "*/*; q=1.0" def test_str__custom_weight(self): """Format string with custom weight.""" assert str(MediaType("image/*", 0.6)) == "image/*; q=0.6" def test_repr__description(self): """Return descriptive representation.""" assert repr(MediaType("*/*")) == "MediaType: */*; q=1.0" def test_eq__matching_values(self): """Equal media types compare as equal.""" assert MediaType("*/*") == MediaType("*/*") def test_eq__different_weights(self): """Different weights make media types unequal.""" assert MediaType("*/*", 0.9) != MediaType("*/*") valid_strings = [ ("*/*", MediaType("*/*")), ("*/*; q=0.9", MediaType("*/*", 0.9)), ("*/*; q=0", MediaType("*/*", 0.0)), ("*/*; q=0.0", MediaType("*/*", 0.0)), ("*/*; q=0.1", MediaType("*/*", 0.1)), ("*/*; q=0.12", MediaType("*/*", 0.12)), ("*/*; q=0.123", MediaType("*/*", 0.123)), ("*/*; q=1.000", MediaType("*/*", 1.0)), ("*/*; q=1", MediaType("*/*", 1.0)), ("*/*;q=0.9", MediaType("*/*", 0.9)), ("*/* ;q=0.9", MediaType("*/*", 0.9)), ("*/* ; q=0.9", MediaType("*/*", 0.9)), ("*/* ; q=0.9", MediaType("*/*", 0.9)), ("*/*;v=b3", MediaType("*/*")), ("*/*; q=0.5; v=b3", MediaType("*/*", 0.5)), ] @pytest.mark.parametrize("type, expected", valid_strings) def test_from_string__valid(self, type, expected): """Parse valid media type strings.""" assert MediaType.from_string(type) == expected @pytest.mark.parametrize( "mime_type", [ "*/*;0.9", 'text/html;z=""', "text/html; xxx", "text/html; =a", ], ) def test_from_string__invalid(self, mime_type): """Raise ValueError for invalid media type strings.""" with pytest.raises(ValueError) as e: MediaType.from_string(mime_type) expected_error = f'"{mime_type}" is not a valid media type' assert expected_error in str(e.value) def test_parse_header__default(self): """Parse default accept header.""" assert list(MediaType.parse_header()) == [ MediaType("*/*"), ] def test_parse_header__multiple_types(self): """Parse multiple types and sort by weight.""" assert list( MediaType.parse_header( "text/html; q=0.1, application/xhtml+xml; q=0.1 ,application/json" ) ) == [ MediaType("application/json"), MediaType("text/html", 0.1), MediaType("application/xhtml+xml", 0.1), ] class TestHealthCheckView: @pytest.mark.asyncio async def test_get__success(self, health_check_view): """Return 200 with HTML content when all checks pass.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view([SuccessBackend]) assert response.status_code == 200 assert response["content-type"] == "text/html; charset=utf-8" @pytest.mark.asyncio async def test_get__error(self, health_check_view): """Return 500 with error message when check fails.""" class FailingBackend(HealthCheck): async def run(self): raise HealthCheckException("Super Fail!") response = await health_check_view([FailingBackend]) assert response.status_code == 500 assert response["content-type"] == "text/html; charset=utf-8" assert b"Super Fail!" in response.content @pytest.mark.asyncio async def test_get__warning(self, health_check_view): """Return 500 when warning is raised (warnings are treated as errors).""" class WarningBackend(HealthCheck): async def run(self): raise ServiceWarning("so so") response = await health_check_view([WarningBackend]) assert response.status_code == 500 assert b"so so" in response.content @pytest.mark.asyncio async def test_get__non_critical_service(self, health_check_view): """Return 500 when any service fails (all services are critical by default).""" class FailingBackend(HealthCheck): async def run(self): raise HealthCheckException("Super Fail!") response = await health_check_view([FailingBackend]) assert response.status_code == 500 assert response["content-type"] == "text/html; charset=utf-8" assert b"Super Fail!" in response.content @pytest.mark.asyncio async def test_get__json_accept_header(self, health_check_view): """Return JSON when Accept header requests it.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="application/json" ) assert response["content-type"] == "application/json" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__json_preferred(self, health_check_view): """Return JSON when it is preferred in Accept header.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="application/json; q=0.8, text/html; q=0.5", ) assert response["content-type"] == "application/json" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__xhtml_fallback(self, health_check_view): """Return HTML when XHTML is requested (no XHTML support).""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="application/xhtml+xml" ) assert response["content-type"] == "text/html; charset=utf-8" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__unsupported_accept(self, health_check_view): """Return 406 when Accept header is unsupported.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="application/octet-stream" ) assert response["content-type"] == "text/plain" assert response.status_code == 406 assert ( response.content == b"Not Acceptable: Supported content types: text/plain, text/html, application/json, application/atom+xml, application/rss+xml, application/openmetrics-text" ) @pytest.mark.asyncio async def test_get__unsupported_with_fallback(self, health_check_view): """Return supported format when unsupported format requested with fallback.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="application/octet-stream, application/json; q=0.9", ) assert response["content-type"] == "application/json" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__html_preferred(self, health_check_view): """Prefer HTML when both HTML and JSON are acceptable.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="text/html, application/xhtml+xml, application/json; q=0.9, */*; q=0.1", ) assert response["content-type"] == "text/html; charset=utf-8" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__json_preferred_reverse_order(self, health_check_view): """Prefer JSON when it has higher weight.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], accept_header="text/html; q=0.1, application/xhtml+xml; q=0.1, application/json", ) assert response["content-type"] == "application/json" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__format_parameter_override(self, health_check_view): """Format parameter overrides Accept header.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view( [SuccessBackend], format_param="json", accept_header="text/html" ) assert response["content-type"] == "application/json" assert response.status_code == 200 @pytest.mark.asyncio async def test_get__html_without_accept_header(self, health_check_view): """Return HTML by default without Accept header.""" class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view([SuccessBackend]) assert response.status_code == 200 assert response["content-type"] == "text/html; charset=utf-8" @pytest.mark.asyncio async def test_get__error_json_response(self, health_check_view): """Return JSON with error when Accept header requests JSON.""" class FailingBackend(HealthCheck): async def run(self): raise HealthCheckException("JSON Error") response = await health_check_view( [FailingBackend], accept_header="application/json" ) assert response.status_code == 500 assert response["content-type"] == "application/json" assert ( "JSON Error" in json.loads(response.content.decode("utf-8"))[repr(FailingBackend())] ) @pytest.mark.asyncio async def test_get__json_format_parameter(self, health_check_view): """Return JSON response when format parameter is 'json'.""" @dataclasses.dataclass class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view([SuccessBackend], format_param="json") assert response.status_code == 200 assert response["content-type"] == "application/json" assert json.loads(response.content.decode("utf-8")) == { repr(SuccessBackend()): SuccessBackend().pretty_status() } @pytest.mark.asyncio async def test_get__error_json_format_parameter(self, health_check_view): """Return JSON error response when format parameter is 'json'.""" @dataclasses.dataclass class FailingBackend(HealthCheck): async def run(self): raise HealthCheckException("JSON Error") response = await health_check_view([FailingBackend], format_param="json") assert response.status_code == 500 assert response["content-type"] == "application/json" assert ( "JSON Error" in json.loads(response.content.decode("utf-8"))[repr(FailingBackend())] ) @pytest.mark.asyncio async def test_get__atom_format_parameter(self, health_check_view): """Return Atom feed when format parameter is 'atom'.""" @dataclasses.dataclass class SuccessBackend(HealthCheck): async def run(self): pass response = await health_check_view([SuccessBackend], format_param="atom") assert response.status_code == 200 assert "application/atom+xml" in response["content-type"] assert b"