Serving the health endpoint
Probirka computes the result; publishing it over HTTP is a separate step. There are three ways to do it, and all of them return the same body and take the same options:
a generic ASGI application —
probirka.make_asgi_app(), registered as a route, mounted into any ASGI framework, or served on its own port;a framework adapter — one factory per framework, registered the way that framework registers routes;
your own handler — three lines around
probirka.Probirka.run(), for everything else.
The adapters are thin wrappers over probirka.Probirka.run(): they run the probes, map ok
to a status code and return probirka.ProbirkaResult.to_dict() as JSON. They are importable
from the probirka package and resolved on first access; the framework itself must be installed,
otherwise an ImportError names the package. probirka.make_asgi_app() is the exception —
it speaks the ASGI protocol directly and needs nothing but probirka.
Choosing an integration
Where it runs |
Use |
Notes |
|---|---|---|
FastAPI |
|
the ASGI app mounts into FastAPI too, but a mount stays out of |
Starlette |
|
nothing Starlette-specific to gain from a dedicated adapter |
Litestar |
|
Litestar answers the mount path itself, no redirect |
aiohttp |
|
not an ASGI framework |
Django |
|
not mountable from |
Falcon, BlackSheep, Esmerald |
|
anything that mounts an ASGI application |
Sanic, Quart, Flask |
your own handler |
these cannot mount an ASGI application; see Without an adapter |
A port of its own |
|
answers even while the main application is saturated |
Common options
Every factory on this page takes the same keyword arguments:
Option |
Default |
Meaning |
|---|---|---|
|
|
Overall timeout of the run, in seconds. Probes that do not finish in time are reported as
failed and the whole result gets |
|
|
Which optional groups to run, on top of the required probes. |
|
|
Skip the probes registered without a group, running only |
|
|
Whether to send the result as the response body. |
|
|
Status code when every probe passed. |
|
|
Status code when at least one probe failed or the run timed out. |
Response format
The body is probirka.ProbirkaResult.to_dict(), the same for every integration:
{
"ok": true,
"started_at": "2024-04-02T10:00:00.000123+03:00",
"elapsed": 0.001,
"info": null,
"checks": [
{
"name": "api",
"ok": true,
"cached": null,
"started_at": "2024-04-02T10:00:00.000123+03:00",
"elapsed": 0.001,
"info": null,
"error": null,
"allow_failure": false
}
],
"error": null
}
started_at is a timezone-aware ISO 8601 timestamp and elapsed is the duration in seconds.
Every integration renders the body the same way: json.dumps over
ProbirkaResult.to_dict(), with values of info that are not JSON types written as their
str(). Secrets are masked on the way out — values under keys like password or api_key
become '***', passwords inside URLs and sensitive query parameters are masked in every string,
error included — and the adapters do not offer a way to turn that off. When you need the raw
data, run the probes from your own handler with to_dict(redact=False).
Generic ASGI app
probirka.make_asgi_app() returns a plain ASGI 3 application with no third-party
dependencies — the only integration that works on a bare pip install probirka.
Warning
A mounted application gets no help from the host router, so the app takes care of two things itself, and both are visible from the outside:
The request path is ignored. Whatever reaches the application is answered, so an app mounted at
/healthalso answers/health/anything.Only
GETandHEADare served. Every other method gets405with anallow: GET, HEADheader.HEADreturns the status and headers of the matchingGETwith an empty body.
Starlette
Register it as a route, so that the path is matched exactly:
from starlette.applications import Starlette
from starlette.routing import Route
from probirka import Probirka, make_asgi_app
probirka_instance = Probirka()
@probirka_instance.add(name="api")
async def check_api():
return True
app = Starlette(
routes=[
Route("/health", make_asgi_app(probirka_instance), methods=["GET", "HEAD"]),
],
)
Warning
Mount("/health", app) works as well, but it answers on /health/ and redirects the bare
/health with a 307. A Kubernetes httpGet probe treats every status below 400 as
success, so it would report the service healthy without the probes ever running. Use Route,
or point the probe at the trailing slash.
FastAPI
app.mount("/health", make_asgi_app(probirka_instance))
Note
A mounted application is invisible to OpenAPI: it does not appear in /docs or in
openapi.json. Prefer probirka.make_fastapi_endpoint() when the route should be
documented.
Litestar
from litestar import Litestar, asgi
from probirka import Probirka, make_asgi_app
probirka_instance = Probirka()
app = Litestar(
route_handlers=[
asgi("/health", is_mount=True)(make_asgi_app(probirka_instance)),
],
)
On its own port
The application is a valid ASGI app by itself, so it can be served separately from the one it watches — useful when the health port must not be exposed next to the public one, or when the answer should arrive even while the main application is saturated:
# health.py
from probirka import Probirka, make_asgi_app
probirka_instance = Probirka()
# add probes...
health_app = make_asgi_app(probirka_instance)
uvicorn health:health_app --port 8081
The lifespan protocol is acknowledged, so servers start it without warnings. The application keeps no state of its own, so there is nothing to set up or tear down.
Native adapters
FastAPI
from fastapi import FastAPI
from probirka import Probirka, make_fastapi_endpoint
app = FastAPI()
probirka_instance = Probirka()
# Define health checks
@probirka_instance.add(name="api")
async def check_api():
return True
# Create and add the endpoint
fastapi_endpoint = make_fastapi_endpoint(probirka_instance)
app.add_api_route("/health", fastapi_endpoint)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
A complete example with liveness, readiness and startup probes and an authenticated report is in Kubernetes probes below.
aiohttp
from aiohttp import web
from probirka import Probirka, make_aiohttp_endpoint
app = web.Application()
probirka_instance = Probirka()
# Define health checks
@probirka_instance.add(name="api")
async def check_api():
return True
# Create and add the endpoint
app.router.add_get("/health", make_aiohttp_endpoint(probirka_instance))
if __name__ == "__main__":
web.run_app(app, host="0.0.0.0", port=8000)
Django
# urls.py
from django.urls import path
from probirka import Probirka, make_django_view
probirka_instance = Probirka()
# Define health checks
@probirka_instance.add(name="api")
async def check_api():
return True
urlpatterns = [
path("health", make_django_view(probirka_instance)),
]
The view accepts GET and HEAD; other HTTP methods get 405 Method Not Allowed.
Kubernetes probes
probirka.make_fastapi_endpoint() returns a plain endpoint function, so it is registered with
add_api_route and takes everything that call accepts — a path, dependencies, tags,
include_in_schema. That is enough to serve the three probes of a pod and a report for operators
from one Probirka instance: probes registered without a group are the liveness check and run on
every endpoint, optional groups hold what each of the other endpoints adds.
import os
import secrets
from contextlib import asynccontextmanager
from typing import Annotated
import asyncpg
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from redis.asyncio import Redis
from probirka import PostgresAsyncpgProbe, Probirka, RedisProbe, make_fastapi_endpoint
HEALTH_USER = os.environ["HEALTH_USER"]
HEALTH_PASSWORD = os.environ["HEALTH_PASSWORD"]
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pool = await asyncpg.create_pool(os.environ["DATABASE_DSN"])
app.state.redis = Redis.from_url(os.environ["REDIS_URL"])
yield
await app.state.redis.aclose()
await app.state.pool.close()
app = FastAPI(lifespan=lifespan)
probirka_instance = Probirka()
probirka_instance.add_info("name", "orders-api")
probirka_instance.add_info("version", "1.4.0")
# Liveness: the process answers. Probes without a group run on every endpoint.
@probirka_instance.add(name="self")
async def check_self():
return True
# Readiness: the dependencies needed to serve traffic.
probirka_instance.add_probes(
PostgresAsyncpgProbe(lambda: app.state.pool, name="postgres", timeout=2),
RedisProbe(lambda: app.state.redis, name="redis", timeout=1),
groups="readiness",
)
# Startup: checks that must pass once and are too slow to repeat every few seconds.
@probirka_instance.add(name="migrations", groups="startup", timeout=5)
async def check_migrations():
return await migrations_are_applied(app.state.pool)
# Kubernetes reads only the status code, so send no body: nothing to leak.
app.add_api_route("/livez", make_fastapi_endpoint(probirka_instance, return_results=False))
app.add_api_route(
"/readyz",
make_fastapi_endpoint(probirka_instance, with_groups="readiness", return_results=False, error_code=503),
)
app.add_api_route(
"/startupz",
make_fastapi_endpoint(probirka_instance, with_groups="startup", return_results=False, error_code=503),
)
# The full report — every probe, error messages, metadata — is for operators, behind authentication.
basic = HTTPBasic()
def require_operator(credentials: Annotated[HTTPBasicCredentials, Depends(basic)]) -> None:
user_ok = secrets.compare_digest(credentials.username.encode(), HEALTH_USER.encode())
password_ok = secrets.compare_digest(credentials.password.encode(), HEALTH_PASSWORD.encode())
if not (user_ok and password_ok):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, headers={"WWW-Authenticate": "Basic"})
app.add_api_route(
"/info",
make_fastapi_endpoint(probirka_instance, with_groups=["readiness", "startup"]),
dependencies=[Depends(require_operator)],
)
/livezruns onlyself: it passes as long as the process serves requests. Dependencies stay out of it, because a liveness probe that fails when the database blips restarts the pod instead of fixing the database./readyzadds thereadinessgroup. While it fails the pod is removed from the Service endpoints and put back when it passes; nothing is restarted./startupzadds thestartupgroup. Kubernetes holds liveness and readiness back until it succeeds once and never calls it again — the place for checks that must pass once and are too slow to repeat every ten seconds. The server accepts no connections until the lifespan has finished, so the startup budget covers that time too. Without such checks, point the startup probe at/livezand skip the group./infois the full report: every group, theerrormessages, the metadata fromadd_info(). Route-leveldependenciesrun before the endpoint, so an unauthenticated request runs no probe at all, andHTTPBasicputs an Authorize button on the route in/docs.require_operatoris an ordinary FastAPI dependency: replace it with the one your application already has — a JWT or OAuth2 scheme, a session cookie,Depends(get_current_admin).
The three Kubernetes endpoints send no body (return_results=False). The kubelet reads only the
status code, and an unauthenticated URL is the last place to show an error that may quote a
connection string — the masking described under Response format is a safety net for exactly
that, and with no body there is nothing to mask. Kubernetes counts every status from 200 to 399 as
success and anything else as failure, so error_code=503 is not for it: it tells load balancers
and log filters that “not ready” is unavailability, not a crashed handler like the default 500.
The matching probe configuration. timeoutSeconds defaults to one second, so set it above the
slowest probe timeout or the kubelet gives up before Probirka answers; the startup budget covers the
lifespan and the migrations check:
livenessProbe:
httpGet:
path: /livez
port: 8000
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: 8000
periodSeconds: 10
timeoutSeconds: 3
startupProbe:
httpGet:
path: /startupz
port: 8000
periodSeconds: 5
timeoutSeconds: 6
failureThreshold: 24
Without an adapter
An adapter saves a handful of lines, it is not a requirement. Anything that can return a status code and a body can serve the result — this is how to expose it from Sanic, Quart, Flask, or any framework probirka does not ship an adapter for:
import json
from probirka import Probirka
probirka_instance = Probirka()
async def health(request):
res = await probirka_instance.run()
return YourFrameworkResponse(
json.dumps(res.to_dict(), default=str),
status=200 if res.ok else 500,
content_type="application/json",
)
default=str matters: info may hold values that are not JSON types, and without it
json.dumps raises instead of answering. To mask the str() of such objects the way the
adapters do, pass default=lambda obj: probirka.redact_value(str(obj)) instead.