├── .github └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs ├── api.md ├── images │ └── favicon.ico └── index.md ├── fastapi_health ├── __init__.py ├── endpoint.py ├── py.typed └── route.py ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt └── tests ├── __init__.py ├── test_endpoint.py └── test_health.py /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | if: github.event.repository.fork == false 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.x 15 | - run: pip install git+https://${GH_TOKEN}@github.com/squidfunk/mkdocs-material-insiders.git mkdocstrings[python] 16 | - run: mkdocs gh-deploy --force 17 | env: 18 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.8", "3.9", "3.10", "3.11"] 17 | fail-fast: true 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | cache: pip 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install hatch 29 | python -m pip install -r requirements.txt 30 | - name: Run tests 31 | run: hatch run test:run 32 | - name: Create report 33 | run: coverage combine 34 | - name: Upload coverage 35 | uses: codecov/codecov-action@v3 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: Black 6 | entry: black 7 | types: [python] 8 | language: system 9 | - repo: local 10 | hooks: 11 | - id: ruff 12 | name: Ruff 13 | entry: ruff 14 | types: [python] 15 | language: system 16 | args: 17 | - "--fix" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Marcelo Trylesinski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | FastAPI Health 🚑️ 3 |

4 |

5 | 6 | Latest Commit 7 | 8 | 9 | 10 |
11 | 12 | Package version 13 | 14 | 15 | 16 |

17 | 18 | The goal of this package is to help you to implement the [Health Check API](https://microservices.io/patterns/observability/health-check-api.html) pattern. 19 | 20 | ## Installation 21 | 22 | ``` bash 23 | pip install fastapi-health 24 | ``` 25 | 26 | ## Quick Start 27 | 28 | Create the health check endpoint dynamically using different conditions. Each condition is a 29 | callable, and you can even have dependencies inside of it: 30 | 31 | ```python 32 | from fastapi import FastAPI, Depends 33 | from fastapi_health import health 34 | 35 | 36 | def get_session(): 37 | return True 38 | 39 | 40 | def is_database_online(session: bool = Depends(get_session)): 41 | return session 42 | 43 | 44 | app = FastAPI() 45 | app.add_api_route("/health", health([is_database_online])) 46 | ``` 47 | 48 | ## Advanced Usage 49 | 50 | The `health()` method receives the following parameters: 51 | - `conditions`: A list of callables that represents the conditions of your API, it can return either `bool` or a `dict`. 52 | - `success_handler`: An optional callable which receives the `conditions` results and returns a dictionary that will be the content response of a successful health call. 53 | - `failure_handler`: An optional callable analogous to `success_handler` for failure scenarios. 54 | - `success_status`: An integer that overwrites the default status (200) in case of success. 55 | - `failure_status`: An integer that overwrites the default status (503) in case of failure. 56 | 57 | It's important to notice that you can have a _peculiar_ behavior in case of hybrid return statements (`bool` and `dict`) on the conditions. 58 | For example: 59 | 60 | ``` Python 61 | from fastapi import FastAPI 62 | from fastapi_health import health 63 | 64 | 65 | def pass_condition(): 66 | return {"database": "online"} 67 | 68 | 69 | def sick_condition(): 70 | return False 71 | 72 | 73 | app = FastAPI() 74 | app.add_api_route("/health", health([pass_condition, sick_condition])) 75 | ``` 76 | 77 | This will generate a response composed by the status being 503 (default `failure_status`), because `sick_condition` returns `False`, and the JSON body `{"database": "online"}`. It's not wrong, or a bug. It's meant to be like this. 78 | 79 | ## License 80 | 81 | This project is licensed under the terms of the MIT license. 82 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ::: fastapi_health.route 4 | options: 5 | heading_level: 3 6 | show_root_heading: false 7 | members: 8 | - health 9 | 10 | ::: fastapi_health.endpoint 11 | -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapi-health/c81925385d3ffb42b1d81d0bbd5f2e72835249f7/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # FastAPI Health :ambulance: 2 | 3 | The goal of this package is to help you to implement the 4 | [**Health Check API**] pattern. 5 | 6 | ## Installation 7 | 8 | ```bash 9 | pip install fastapi-health 10 | ``` 11 | 12 | ## Usage 13 | 14 | The easier way to use this package is to use the **`health`** function. 15 | 16 | Create the health check endpoint dynamically using different conditions. 17 | Each condition is a callable, and you can even have dependencies inside of it: 18 | 19 | === "PostgreSQL" 20 | 21 | ```python 22 | import asyncio 23 | 24 | from fastapi import FastAPI, Depends 25 | from fastapi_health import health 26 | from sqlalchemy.exc import SQLAlchemyError 27 | from sqlalchemy.ext.asyncio import AsyncSession 28 | 29 | # You need to implement yourself 👇 30 | from app.database import get_session 31 | 32 | 33 | async def is_database_online(session: AsyncSession = Depends(get_session)): 34 | try: 35 | await asyncio.wait_for(session.execute("SELECT 1"), timeout=30) 36 | except (SQLAlchemyError, TimeoutError): 37 | return False 38 | return True 39 | 40 | 41 | app = FastAPI() 42 | app.add_api_route("/health", health([is_database_online])) 43 | ``` 44 | 45 | === "Redis" 46 | 47 | ```python 48 | import asyncio 49 | 50 | from fastapi import FastAPI, Depends 51 | from fastapi_health import health 52 | from redis import ConnectionError 53 | from redis.asyncio import Redis 54 | 55 | # You need to implement yourself 👇 56 | from app.redis import get_redis 57 | 58 | 59 | async def is_redis_alive(client: Redis = Depends(get_redis)): 60 | try: 61 | await asyncio.wait_for(client.check_health(), timeout=30) 62 | except (ConnectionError, RuntimeError, TimeoutError): 63 | return False 64 | return True 65 | 66 | 67 | app = FastAPI() 68 | app.add_api_route("/health", health([is_redis_alive])) 69 | ``` 70 | 71 | === "MongoDB" 72 | 73 | ```python 74 | import asyncio 75 | 76 | from fastapi import FastAPI, Depends 77 | from fastapi_health import health 78 | from motor.motor_asyncio import AsyncIOMotorClient 79 | from pymongo.errors import ServerSelectionTimeoutError 80 | 81 | # You need to implement yourself 👇 82 | from app.mongodb import get_mongo 83 | 84 | 85 | async def is_mongo_alive(client: AsyncIOMotorClient = Depends(get_mongo)): 86 | try: 87 | await asyncio.wait_for(client.server_info(), timeout=30) 88 | except (ServerSelectionTimeoutError, TimeoutError): 89 | return False 90 | return True 91 | 92 | 93 | app = FastAPI() 94 | app.add_api_route("/health", health([is_mongo_alive])) 95 | ``` 96 | 97 | You can check the [**API reference**] for more details. 98 | 99 | [**Health Check API**]: https://microservices.io/patterns/observability/health-check-api.html 100 | [**API Reference**]: api/#fastapi_health.route.health 101 | 102 | -------------------------------------------------------------------------------- /fastapi_health/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi_health.endpoint import Check, HealthEndpoint, Status 2 | from fastapi_health.route import health 3 | 4 | __all__ = ["health", "HealthEndpoint", "Status", "Check"] 5 | -------------------------------------------------------------------------------- /fastapi_health/endpoint.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from dataclasses import dataclass, field 3 | from datetime import datetime 4 | from typing import Any, Awaitable, Callable, DefaultDict, Dict, List, Optional, Union, cast 5 | 6 | import anyio 7 | from anyio import to_thread 8 | from fastapi import Depends, FastAPI, Request 9 | from fastapi.responses import JSONResponse 10 | from pydantic import BaseModel, Field 11 | from pydantic.version import VERSION as PYDANTIC_VERSION 12 | from starlette._utils import is_async_callable 13 | 14 | PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") 15 | 16 | if PYDANTIC_V2: # pragma: no cover 17 | from pydantic import field_validator as validator 18 | 19 | else: # pragma: no cover 20 | from pydantic import validator 21 | 22 | 23 | def model_dump(model: BaseModel, exclude_none: bool) -> Dict[str, Any]: # pragma: no cover 24 | if PYDANTIC_V2: 25 | return model.model_dump(exclude_none=exclude_none) 26 | return model.dict(exclude_none=exclude_none) 27 | 28 | 29 | # https://inadarei.github.io/rfc-healthcheck/#name-the-checks-object-2 30 | class Check(BaseModel): 31 | componentId: Optional[str] = Field( 32 | default=None, 33 | description="Unique identifier of an instance of a specific sub-component/dependency of a service.", 34 | ) 35 | componentType: Optional[str] = Field( 36 | default=None, description="Type of the sub-component/dependency of a service." 37 | ) 38 | observedValue: Any = Field(default=None, description="The observed value of the component.") 39 | observedUnit: Optional[str] = Field(default=None, description="The unit of the observed value.") 40 | status: Optional[str] = Field(default=None, description="Indicates the service status.") 41 | affectedEndpoints: Optional[List[str]] = Field( 42 | default=None, description="List of affected endpoints." 43 | ) 44 | time: Optional[str] = Field( 45 | default=None, description="Datetime at which the 'observedValue' was recorded." 46 | ) 47 | output: Optional[str] = Field( 48 | default=None, 49 | description=( 50 | 'Raw error output, in case of "fail" or "warn" states. ' 51 | 'This field SHOULD be omitted for "pass" state.' 52 | ), 53 | ) 54 | links: Optional[Dict[str, str]] = Field(default=None) # TODO: missing description 55 | 56 | @validator("time") 57 | def validate_iso_8061(cls, v: str) -> str: 58 | try: 59 | datetime.fromisoformat(v) 60 | except ValueError as exc: # pragma: no cover 61 | raise exc 62 | return v 63 | 64 | 65 | class HealthBody(BaseModel): 66 | status: str = Field(default=..., description="Indicates the service status.") 67 | version: Optional[str] = Field(default=None, description="The version of the service.") 68 | releaseId: Optional[str] = Field(default=None, description="The release ID of the service.") 69 | notes: Optional[List[str]] = Field( 70 | default=None, description="Notes relevant to the current status." 71 | ) 72 | output: Optional[str] = Field( 73 | default=None, 74 | description=( 75 | 'Raw error output, in case of "fail" or "warn" states. ' 76 | 'This field SHOULD be omitted for "pass" state.' 77 | ), 78 | ) 79 | checks: Optional[Dict[str, List[Check]]] = Field( 80 | default=None, 81 | description=( 82 | "Provides detailed health statuses of additional downstream systems" 83 | " and endpoints which can affect the overall health of the main API." 84 | ), 85 | ) 86 | links: Optional[Dict[str, str]] = Field(default=None, description="Links to related resources.") 87 | serviceId: Optional[str] = Field(default=None, description="The ID of the service.") 88 | description: Optional[str] = Field(default=None, description="The description of the service.") 89 | 90 | 91 | class Condition(BaseModel): 92 | name: str = Field(default=..., description="The name of the condition. Must be unique.") 93 | calls: List[Callable[[], Union[Check, Awaitable[Check]]]] = Field( 94 | default=..., description="The function to call to check the condition." 95 | ) 96 | 97 | 98 | @dataclass(frozen=True) 99 | class Status: 100 | code: int 101 | name: str 102 | 103 | 104 | @dataclass 105 | class HealthEndpoint: 106 | conditions: List[Condition] = field(default_factory=list) 107 | allow_version: bool = field(default=False) 108 | version: Optional[str] = field(default=None) 109 | 110 | allow_description: bool = field(default=False) 111 | description: Optional[str] = field(default=None) 112 | 113 | release_id: Callable[..., Optional[None]] = field(default=lambda: None) 114 | service_id: Optional[str] = field(default=None) 115 | 116 | pass_status: Status = field(default=Status(code=200, name="pass")) 117 | fail_status: Status = field(default=Status(code=503, name="fail")) 118 | warn_status: Status = field(default=Status(code=200, name="warn")) 119 | 120 | allow_output: bool = field(default=True) 121 | 122 | links: Optional[Dict[str, str]] = field(default=None) 123 | notes: Callable[..., Optional[List[str]]] = field(default=lambda: None) 124 | 125 | def __post_init__(self): 126 | HealthEndpoint.__call__ = self.prepare_call() 127 | 128 | def prepare_call(self): 129 | def endpoint( 130 | self: "HealthEndpoint", 131 | request: Request, 132 | release_id: Optional[str] = Depends(self.release_id), 133 | notes: Optional[List[str]] = Depends(self.notes), 134 | checks: Dict[str, List[Check]] = Depends(self.run_conditions), 135 | ) -> JSONResponse: 136 | app = cast(FastAPI, request.app) 137 | status = self._get_service_status(checks) 138 | 139 | if self.version: 140 | version = self.version 141 | elif self.allow_version: 142 | version = app.version 143 | else: 144 | version = None 145 | 146 | if self.description: 147 | description = self.description 148 | elif self.allow_description: 149 | description = app.description 150 | else: 151 | description = None 152 | 153 | body = HealthBody( 154 | status=status.name, 155 | version=version, 156 | description=description, 157 | releaseId=release_id, 158 | serviceId=self.service_id, 159 | notes=notes, 160 | links=self.links, 161 | output=None, # TODO: add output 162 | checks=checks or None, 163 | ) 164 | return JSONResponse( 165 | content=model_dump(body, exclude_none=True), 166 | status_code=status.code, 167 | media_type="application/health+json", 168 | ) 169 | 170 | return endpoint 171 | 172 | async def run_conditions(self) -> Dict[str, List[Check]]: 173 | results: DefaultDict[str, List[Check]] = defaultdict(list) 174 | 175 | async def _run_condition( 176 | name: str, call: Callable[[], Union[Check, Awaitable[Check]]] 177 | ) -> None: 178 | if is_async_callable(call): 179 | result = await call() 180 | else: 181 | result = await to_thread.run_sync(call) 182 | result = cast(Check, result) 183 | if model_dump(result, exclude_none=True): 184 | results[name].append(result) 185 | 186 | async with anyio.create_task_group() as tg: 187 | for condition in self.conditions: 188 | for call in condition.calls: 189 | tg.start_soon(_run_condition, condition.name, call) 190 | 191 | return results 192 | 193 | def _get_service_status(self, checks: Dict[str, List[Check]]) -> Status: 194 | total_checks = 0 195 | warns = 0 196 | for checklist in checks.values(): 197 | for check in checklist: 198 | total_checks += 1 199 | if check.status == self.fail_status.name: 200 | return self.fail_status 201 | if check.status == self.warn_status.name: 202 | warns += 1 203 | if checks and warns == total_checks: 204 | return self.warn_status 205 | return self.pass_status 206 | -------------------------------------------------------------------------------- /fastapi_health/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapi-health/c81925385d3ffb42b1d81d0bbd5f2e72835249f7/fastapi_health/py.typed -------------------------------------------------------------------------------- /fastapi_health/route.py: -------------------------------------------------------------------------------- 1 | from inspect import Parameter, Signature 2 | from typing import Any, Awaitable, Callable, Coroutine, Dict, List, TypeVar, Union 3 | 4 | from fastapi import Depends 5 | from fastapi.encoders import jsonable_encoder 6 | from fastapi.responses import JSONResponse 7 | 8 | T = TypeVar("T") 9 | HealthcheckReturn = Union[Dict[str, Any], bool] 10 | ConditionFunc = Callable[..., Union[HealthcheckReturn, Awaitable[HealthcheckReturn]]] 11 | 12 | 13 | async def default_handler(**kwargs: T) -> Dict[str, T]: 14 | """Default handler for health check route. 15 | 16 | It's used by the success and failure handlers. 17 | 18 | Returns: 19 | Dict[str, T]: A dictionary with the results of the conditions. 20 | """ 21 | output = {} 22 | for value in kwargs.values(): 23 | if isinstance(value, dict): 24 | output.update(value) 25 | return output 26 | 27 | 28 | def health( 29 | conditions: List[ConditionFunc], 30 | *, 31 | success_handler: Callable[..., Awaitable[dict]] = default_handler, 32 | failure_handler: Callable[..., Awaitable[dict]] = default_handler, 33 | success_status: int = 200, 34 | failure_status: int = 503, 35 | ) -> Callable[..., Coroutine[None, None, JSONResponse]]: 36 | """Create a health check route. 37 | 38 | Args: 39 | conditions (List[Callable[..., Dict[str, Any] | bool]]): A list of callables 40 | that represents the condition of your service. 41 | success_handler (Callable[..., Awaitable[dict]]): A callable which receives 42 | the `conditions` results, and returns a dictionary that will be the content 43 | response of a successful health call. 44 | failure_handler (Callable[..., Awaitable[dict]]): A callable analogous to 45 | `success_handler` for failure scenarios. 46 | success_status (int): An integer that overwrites the default status (`200`) in 47 | case of success. 48 | failure_status (int): An integer that overwrites the default status (`503`) in 49 | case of failure. 50 | 51 | Returns: 52 | Callable[..., Awaitable[JSONResponse]]: The health check route. 53 | """ 54 | 55 | async def endpoint(**dependencies) -> JSONResponse: 56 | if all(dependencies.values()): 57 | handler = success_handler 58 | status_code = success_status 59 | else: 60 | handler = failure_handler 61 | status_code = failure_status 62 | 63 | output = await handler(**dependencies) 64 | return JSONResponse(jsonable_encoder(output), status_code=status_code) 65 | 66 | params = [] 67 | for condition in conditions: 68 | params.append( 69 | Parameter( 70 | f"{condition.__name__}", 71 | kind=Parameter.POSITIONAL_OR_KEYWORD, 72 | annotation=bool, 73 | default=Depends(condition), 74 | ) 75 | ) 76 | endpoint.__signature__ = Signature(params) 77 | return endpoint 78 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI Health 2 | site_description: Health check for your FastAPI application 3 | site_url: https://kludex.github.io/fastapi-health 4 | site_author: Marcelo Trylesinski 5 | copyright: Copyright © 2022 Marcelo Trylesinski 6 | 7 | repo_name: Kludex/fastapi-health 8 | repo_url: https://github.com/Kludex/fastapi-health 9 | edit_uri: edit/main/docs/ 10 | 11 | plugins: 12 | - search 13 | - mkdocstrings 14 | 15 | theme: 16 | name: material 17 | favicon: images/favicon.ico 18 | logo: images/favicon.ico 19 | icon: 20 | repo: fontawesome/brands/github 21 | 22 | palette: 23 | # Palette toggle for light mode 24 | - media: "(prefers-color-scheme: light)" 25 | scheme: slate 26 | primary: red 27 | toggle: 28 | icon: material/lightbulb 29 | name: Switch to dark mode 30 | 31 | # Palette toggle for dark mode 32 | - media: "(prefers-color-scheme: dark)" 33 | scheme: default 34 | primary: red 35 | toggle: 36 | icon: material/lightbulb-outline 37 | name: Switch to system preference 38 | 39 | markdown_extensions: 40 | - attr_list 41 | - pymdownx.emoji: 42 | emoji_index: !!python/name:materialx.emoji.twemoji 43 | emoji_generator: !!python/name:materialx.emoji.to_svg 44 | - pymdownx.highlight: 45 | anchor_linenums: true 46 | - pymdownx.inlinehilite 47 | - pymdownx.snippets 48 | - pymdownx.superfences 49 | - admonition 50 | - pymdownx.details 51 | - pymdownx.tabbed: 52 | alternate_style: true 53 | 54 | nav: 55 | - Home: index.md 56 | - API Reference: api.md 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [build-system] 3 | requires = ["hatchling"] 4 | build-backend = 'hatchling.build' 5 | 6 | [project] 7 | name = "fastapi-health" 8 | version = "0.4.0" 9 | description = "Heath check on FastAPI applications. :ambulance:" 10 | readme = "README.md" 11 | authors = [{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }] 12 | classifiers = [ 13 | "Development Status :: 3 - Alpha", 14 | "License :: OSI Approved :: MIT License", 15 | "Intended Audience :: Developers", 16 | "Natural Language :: English", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | ] 25 | license = "MIT" 26 | requires-python = ">=3.8" 27 | dependencies = ["fastapi>=0.85.0"] 28 | optional-dependencies = {} 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/Kludex/fastapi-health" 32 | Source = "https://github.com/Kludex/fastapi-health" 33 | Twitter = "https://twitter.com/marcelotryle" 34 | Funding = "https://github.com/sponsors/Kludex" 35 | 36 | [tool.hatch.envs.test] 37 | dependencies = [ 38 | "pytest", 39 | "coverage[toml]", 40 | "pytest-sugar", 41 | "pytest-asyncio", 42 | "dirty-equals", 43 | "httpx", 44 | ] 45 | 46 | [tool.hatch.envs.test.scripts] 47 | run = "coverage run -m pytest" 48 | cov = "coverage report --show-missing --skip-covered" 49 | 50 | [[tool.hatch.envs.test.matrix]] 51 | python = ["3.8", "3.9", "3.10", "3.11"] 52 | dependencies = ["pydantic<2", "pydantic>=2"] 53 | 54 | [tool.mypy] 55 | strict = true 56 | show_error_codes = true 57 | 58 | [tool.ruff] 59 | line-length = 100 60 | extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I', 'T'] 61 | extend-ignore = ['E501'] 62 | target-version = "py38" 63 | 64 | [tool.black] 65 | target-version = ["py37"] 66 | line_length = 100 67 | 68 | [tool.pytest.ini_options] 69 | addopts = ["--strict-config", "--strict-markers"] 70 | filterwarnings = [ 71 | "error", 72 | "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", 73 | ] 74 | 75 | [tool.coverage.run] 76 | source_pkgs = ["fastapi_health", "tests"] 77 | branch = true 78 | parallel = true 79 | 80 | [tool.coverage.report] 81 | show_missing = true 82 | skip_covered = true 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Linter & Formatter 4 | ruff 5 | black 6 | mypy 7 | pre-commit 8 | coverage[toml] 9 | 10 | # Documentation 11 | mkdocs-material 12 | mkdocstrings[python] 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/fastapi-health/c81925385d3ffb42b1d81d0bbd5f2e72835249f7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_endpoint.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | 4 | import httpx 5 | import pytest 6 | from fastapi import FastAPI 7 | from fastapi.routing import APIRoute 8 | 9 | from fastapi_health import HealthEndpoint 10 | from fastapi_health.endpoint import Check, Condition, Status 11 | 12 | 13 | def healthy() -> Check: 14 | return Check() 15 | 16 | 17 | def healthy_with_time() -> Check: 18 | return Check(time=datetime(year=2022, month=1, day=1).isoformat()) 19 | 20 | 21 | async def healthy_async() -> Check: 22 | return Check() 23 | 24 | 25 | def warn() -> Check: 26 | return Check(status="warn") 27 | 28 | 29 | def fail() -> Check: 30 | return Check(status="fail") 31 | 32 | 33 | def release_id() -> str: 34 | return "release_id" 35 | 36 | 37 | pass_condition = Condition(name="postgres:connection", calls=[healthy, healthy_async]) 38 | fail_condition = Condition(name="postgres:connection", calls=[fail]) 39 | warn_condition = Condition(name="postgres:connection", calls=[warn]) 40 | pass_with_time = Condition(name="postgres:connection", calls=[healthy_with_time]) 41 | 42 | 43 | def create_app(*args: Any, **kwargs: Any) -> FastAPI: 44 | app = FastAPI(routes=[APIRoute("/health", HealthEndpoint(*args, **kwargs))]) 45 | app.description = "Test app" 46 | return app 47 | 48 | 49 | healthy_app = create_app(conditions=[pass_condition]) 50 | use_version_app = create_app(conditions=[pass_condition], allow_version=True) 51 | use_custom_version_app = create_app(conditions=[pass_condition], version="1.0.0") 52 | release_id_app = create_app(conditions=[pass_condition], release_id=release_id) 53 | service_id_app = create_app(conditions=[pass_condition], service_id="service_id") 54 | pass_status_app = create_app(conditions=[pass_condition], pass_status=Status(200, "ok")) 55 | fail_app = create_app(conditions=[fail_condition]) 56 | warn_app = create_app(conditions=[warn_condition]) 57 | allow_description_app = create_app(conditions=[pass_condition], allow_description=True) 58 | description_app = create_app(conditions=[pass_condition], description="Test") 59 | time_app = create_app(conditions=[pass_with_time]) 60 | 61 | 62 | @pytest.mark.asyncio 63 | @pytest.mark.parametrize( 64 | "app, status, body", 65 | [ 66 | (healthy_app, 200, {"status": "pass"}), 67 | (use_version_app, 200, {"status": "pass", "version": "0.1.0"}), 68 | (use_custom_version_app, 200, {"status": "pass", "version": "1.0.0"}), 69 | (release_id_app, 200, {"status": "pass", "releaseId": "release_id"}), 70 | (service_id_app, 200, {"status": "pass", "serviceId": "service_id"}), 71 | (pass_status_app, 200, {"status": "ok"}), 72 | ( 73 | fail_app, 74 | 503, 75 | {"status": "fail", "checks": {"postgres:connection": [{"status": "fail"}]}}, 76 | ), 77 | ( 78 | warn_app, 79 | 200, 80 | {"status": "warn", "checks": {"postgres:connection": [{"status": "warn"}]}}, 81 | ), 82 | (allow_description_app, 200, {"status": "pass", "description": "Test app"}), 83 | (description_app, 200, {"status": "pass", "description": "Test"}), 84 | ( 85 | time_app, 86 | 200, 87 | { 88 | "status": "pass", 89 | "checks": {"postgres:connection": [{"time": "2022-01-01T00:00:00"}]}, 90 | }, 91 | ), 92 | ], 93 | ) 94 | async def test_health_endpoint(app, status: int, body: dict) -> None: 95 | async with httpx.AsyncClient(app=app, base_url="http://test") as client: 96 | response = await client.get("/health") 97 | assert response.status_code == status 98 | assert response.json() == body 99 | -------------------------------------------------------------------------------- /tests/test_health.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from fastapi import Depends, FastAPI 5 | from fastapi.routing import APIRoute 6 | from httpx import AsyncClient 7 | 8 | from fastapi_health import health 9 | 10 | 11 | def healthy(): 12 | return True 13 | 14 | 15 | def another_healthy(): 16 | return True 17 | 18 | 19 | def sick(): 20 | return False 21 | 22 | 23 | def with_dependency(condition_banana: bool = Depends(healthy)): 24 | return condition_banana 25 | 26 | 27 | async def healthy_async(): 28 | return True 29 | 30 | 31 | def healthy_dict(): 32 | return {"potato": "yes"} 33 | 34 | 35 | def another_health_dict(): 36 | return {"banana": "yes"} 37 | 38 | 39 | def create_app(*args: Any, **kwargs: Any) -> FastAPI: 40 | return FastAPI(routes=[APIRoute("/health", health(*args, **kwargs))]) 41 | 42 | 43 | async def success_handler(**kwargs): 44 | return kwargs 45 | 46 | 47 | async def custom_failure_handler(**kwargs): 48 | is_success = all(kwargs.values()) 49 | return { 50 | "status": "success" if is_success else "failure", 51 | "results": [ 52 | {"condition": condition, "output": value} for condition, value in kwargs.items() 53 | ], 54 | } 55 | 56 | 57 | healthy_app = create_app([healthy]) 58 | multiple_healthy_app = create_app([healthy, another_healthy]) 59 | sick_app = create_app([healthy, sick]) 60 | with_dependency_app = create_app([with_dependency]) 61 | healthy_async_app = create_app([healthy_async]) 62 | healthy_dict_app = create_app([healthy_dict]) 63 | multiple_healthy_dict_app = create_app([healthy_dict, another_health_dict]) 64 | hybrid_app = create_app([healthy, sick, healthy_dict]) 65 | success_handler_app = create_app([healthy], success_handler=success_handler) 66 | failure_handler_app = create_app([sick, healthy], failure_handler=custom_failure_handler) 67 | 68 | 69 | @pytest.mark.asyncio 70 | @pytest.mark.parametrize( 71 | "app, status_code, body", 72 | ( 73 | (healthy_app, 200, {}), 74 | (multiple_healthy_app, 200, {}), 75 | (sick_app, 503, {}), 76 | (with_dependency_app, 200, {}), 77 | (healthy_async_app, 200, {}), 78 | (healthy_dict_app, 200, {"potato": "yes"}), 79 | (multiple_healthy_dict_app, 200, {"potato": "yes", "banana": "yes"}), 80 | (hybrid_app, 503, {"potato": "yes"}), 81 | (success_handler_app, 200, {"healthy": True}), 82 | ( 83 | failure_handler_app, 84 | 503, 85 | { 86 | "status": "failure", 87 | "results": [ 88 | {"condition": "sick", "output": False}, 89 | {"condition": "healthy", "output": True}, 90 | ], 91 | }, 92 | ), 93 | ), 94 | ) 95 | async def test_health(app: FastAPI, status_code: int, body: dict) -> None: 96 | async with AsyncClient(app=app, base_url="http://test") as client: 97 | res = await client.get("/health") 98 | assert res.status_code == status_code 99 | assert res.json() == body 100 | --------------------------------------------------------------------------------