├── .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 |
7 |
8 |
9 |
10 |
11 |
12 |
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 |
--------------------------------------------------------------------------------