Pool metrics and health¶
What a pool of connections holds and has done, and how usable each connection is. On this page:
- a snapshot of every pool;
- the events of a wait that ran out and of a failed connect;
- the pool metrics in OpenTelemetry;
- a health check with criteria of your choice — for a readiness probe, a monitoring page or an alert.
Pool status¶
from hare import Connections
status = Connections.get("default").get_pool_status()
status.size, status.idle, status.in_use, status.waiting # 20, 0, 20, 3
for status in Connections.get_pool_statuses(): # every open pool of the current context
print(status.connection_alias, status.role, status.schema, status.in_use, status.waiting)
get_pool_status() returns a PoolStatus (hare.instrumentation.declarations), or None while the
client's pool isn't open yet:
| Field | What it is |
|---|---|
connection_alias |
The connection of the configuration. |
role |
Which client of the connection the pool belongs to (below). |
schema |
The tenant schema of a tenant schema's pool, None otherwise. |
size, idle, in_use |
The open connections, the idle ones and the taken ones. |
waiting |
The tasks waiting for a connection right now. |
min_size, max_size |
The pool's bounds. |
acquire_count |
How many times a connection was taken. |
acquire_timeouts |
How many waits for a connection ran out of pool_acquire_timeout. |
acquire_wait_seconds_total |
How long the waits that got a connection took in all — measured only while PoolMetrics is enabled; a wait that ran out counts in acquire_timeouts only. |
connect_count, connect_failures |
The connections the pool opened, and the failures to open one. |
The counters belong to the client, not to its pool: they survive Connections.reconnect(), which
replaces the pools. On SQLite the "pool" is the client's one connection — size is 1 and waiting
counts the tasks and transactions waiting for it.
A connection may have several pools, each with its PoolRole (hare.instrumentation.enums):
| Role | The pool of |
|---|---|
MAIN |
The connection's main client. |
TENANT_SCHEMA |
The client of one tenant's schema (schema per tenant). |
DIRECT |
The client of the server itself past a transaction pooler (direct_host, Connection poolers). |
INDEPENDENT |
A client of its own — Transactions.autonomous(), a migration's session with a lock timeout. |
Connections.get_pool_statuses(connection_alias=None) lists every open pool of the current context, by
connection, role and schema. A client whose driver reports no pool status
(Features.supports_pool_status false — ClickHouse over HTTP, whose library keeps its connections to
itself, or a third-party dialect) is not in the list, and its get_pool_status() raises
UnSupportedError.
Measuring the waits: PoolMetrics¶
How long each wait for a connection takes is measured only while the pool metrics are enabled — without them, taking a connection costs no call at all:
from hare.instrumentation.pools.pool_metrics import PoolMetrics
PoolMetrics.enable()
...
PoolMetrics.disable()
Each enable() is matched by a disable() — the measuring stops once every user disabled it. The
OpenTelemetry instrumentor enables it while instrumented. The time a connection takes to open is always
measured: it happens once per connection, never on the query path.
Events¶
Two observer events, sent only on the failure:
PoolAcquireTimedOut— a wait for a connection ran out ofpool_acquire_timeout; the call raisesPoolTimeoutError(aDBConnectionError). Carries the connection, role, schema, how long the call waited, and the pool'ssize,max_sizeandwaitingat that moment.ConnectionFailed— opening a connection failed: opening a pool (each attempt ofconnect_max_retries), or a pool growing. Carries the connection, role, the server's address, the exception's type name (never its text — it may hold the user and host), the attempt and whether another attempt follows.
from hare.instrumentation import Observers
from hare.instrumentation.declarations import PoolAcquireTimedOut
Observers.observe(PoolAcquireTimedOut, lambda event: alert(f"{event.connection_alias} pool exhausted"))
OpenTelemetry metrics¶
OpenTelemetryInstrumentor (OpenTelemetry) reports every open pool of the process,
named by OpenTelemetry's semantic conventions for database clients (their status is still "development"):
| Metric | Type | Value |
|---|---|---|
db.client.connection.count |
up-down counter, db.client.connection.state = idle/used |
idle, in_use |
db.client.connection.max |
up-down counter | max_size |
db.client.connection.idle.min |
up-down counter | min_size |
db.client.connection.pending_requests |
up-down counter | waiting |
db.client.connection.timeouts |
counter | acquire_timeouts |
db.client.connection.wait_time |
histogram, seconds | each wait that got a connection |
db.client.connection.create_time |
histogram, seconds | each connect |
hare.pool.connect_failures |
counter | connect_failures |
Every metric carries the attributes:
db.client.connection.pool.name(the connection);hare.pool.role;db.system.name;- for a tenant schema's pool,
hare.pool.schema.
Pools with equal attributes (one connection of several HareContext objects in a process) are summed.
The values are read when the metrics are collected, on the exporter's thread. A histogram can't be observed, so the waits and connects measured since the previous collection are recorded during the collection. Each pool keeps its latest 4096 durations of each type for its readers; a reader further behind loses the oldest ones.
Health check¶
from hare.health import ConnectionCriteria, HealthCheck
from hare.health.criteria import PingFailed, PoolSaturation, WaitingRequests
health_check = HealthCheck(
timeout_seconds=2.0,
degraded_when=[WaitingRequests(at_least=1), PoolSaturation(ratio=0.9)], # the default
unhealthy_when=[PingFailed()], # the default
criteria_by_connection={"analytics": ConnectionCriteria(degraded_when=[PoolSaturation(ratio=1.0)])},
)
report = await health_check.run()
report.status # HealthStatus.DEGRADED
report.connections["default"].reasons # ("3 tasks wait for a connection of the main pool (at least 1)",)
run() pings every connection of the current context at once, each within timeout_seconds, and judges
it by the criteria:
- an unhealthy criterion holds —
UNHEALTHY; - else a degrading one holds —
DEGRADED; - else —
HEALTHY.
The report's status is its worst connection's.
| Argument | Default | Meaning |
|---|---|---|
timeout_seconds |
2.0 | How long a ping waits. |
degraded_when |
[WaitingRequests(at_least=1), PoolSaturation(ratio=0.9)] |
The criteria making a connection degraded — [] for never. |
unhealthy_when |
[PingFailed()] |
The criteria making a connection unhealthy. |
criteria_by_connection |
none | Other criteria for some connections (ConnectionCriteria); a list it leaves None is the check's own. |
connections |
every connection | The connections checked. |
check_direct |
False | Ping the server past a connection's transaction pooler (direct_host) too. |
run()raises nothing about the database: a ping failing — a refused connection, a wrong password, a server that doesn't answer in time — is an unhealthy status with the exception's type name. It raisesConfigurationErroronly with no current context, or for a connection alias the configuration lacks.run(ping=False)judges only the pools' state and opens no pool —PingFailedandPingLatencynever hold then.- Keep one
HealthCheckobject: the criteria counting "since the last check" compare with its previous run.
Criteria¶
Criterion (hare.health.criteria) |
Holds when |
|---|---|
PingFailed() |
the ping failed or didn't answer in time — or, with check_direct, the ping of the server past the pooler |
PingLatency(max_ms) |
the ping took longer than max_ms |
PoolSaturation(ratio) |
a pool has at least ratio of its max_size in use |
WaitingRequests(at_least) |
at least at_least tasks wait for a connection of a pool |
AcquireWaitTime(max_seconds) |
a wait for a connection since the last check took longer than max_seconds — needs PoolMetrics enabled when the check is made, else ConfigurationError |
AcquireTimeouts(at_least) |
at least at_least waits ran out since the last check |
ConnectFailures(at_least) |
opening a connection failed at least at_least times since the last check |
AllOf(*criteria) |
every one of its criteria holds |
A criterion of your own subclasses HealthCriterion and returns its reason from check():
from hare.health.criteria import HealthCriterion
class TenantPoolsBusy(HealthCriterion):
def check(self, connection_check):
busy = [pool for pool in connection_check.pools if pool.status.schema and pool.status.waiting]
return f"{len(busy)} tenant pools have waiting tasks" if len(busy) > 2 else None
connection_check (ConnectionCheck) holds the ping, the ping of the server past the pooler, and each
pool (PoolChange): its status, and what it did since the last check — connections taken, timeouts,
failed connects, the measured waits.
A criterion counting what a pool did since the last check subclasses PoolCountCriterion: it names the
count of PoolChange (counted_attribute, "timeouts" for AcquireTimeouts) and the reason it gives
(reason, formatted with count, pool and at_least), and holds once a pool's count reaches
at_least.
The report¶
HealthReport.to_dict() gives the report as JSON values — only the statuses by default, for an answer
anyone may read; to_dict(include_details=True) adds each connection's ping latency, the reasons, the
exception's type name and its pools — the fields of its ConnectionHealth (report.connections[alias]:
status, latency_ms, reasons, error_type, pools):
{
"status": "degraded",
"checked_at": "2026-10-06T14:03:11.204871+00:00",
"connections": {
"default": {
"status": "degraded",
"latency_ms": 1.8,
"reasons": ["3 tasks wait for a connection of the main pool (at least 1)"],
"error_type": null,
"pools": [{"connection_alias": "default", "role": "main", "schema": null, "size": 20, "idle": 0,
"in_use": 20, "waiting": 3, "min_size": 5, "max_size": 20, "acquire_count": 18211,
"acquire_timeouts": 0, "acquire_wait_seconds_total": 3.42, "connect_count": 20,
"connect_failures": 0}]
}
}
}
The text of an exception never gets into the report — it may hold the server's address and user.
Liveness and readiness¶
A readiness probe asks "can this instance take traffic" — HealthCheck.run(). A liveness probe asks "is
the process alive" and must not touch the database: if it failed while the database is down, the
orchestrator would restart every instance of the application during the outage. The framework
integrations add both routes (FastAPI,
Litestar, Robyn):
from hare.contrib.frameworks.health_routes import HealthRoutes
app = HareFastAPI(
hare_config=HARE_CONFIG,
health_routes=HealthRoutes(
health_check,
readiness_path="/health/ready", # the default
liveness_path="/health/live", # the default
degraded_status_code=200, # the default: a degraded instance still takes traffic
include_details=False, # the default: only the statuses
),
)
| Status | /health/ready |
/health/live |
|---|---|---|
| healthy | 200 | 200 |
| degraded | degraded_status_code |
200 |
| unhealthy | 503 | 200 |
Neither route runs in the request's transaction (atomic_requests) or appears in the OpenAPI schema.
readinessProbe: {httpGet: {path: /health/ready, port: 8000}, periodSeconds: 5, timeoutSeconds: 3}
livenessProbe: {httpGet: {path: /health/live, port: 8000}, periodSeconds: 10}
Behind PgBouncer¶
Through a transaction pooler the ping reaches the server through the pooler. check_direct=True pings
the server itself as well, by the connection's direct_host/direct_port (Connection
poolers): a pooler that is up while the server is down — or the
other way round — shows. A connection through a pooler without direct_host fails that ping with
ConfigurationError.