connpool
A generic connection pool for Python 3.11+ — sync (threads) and asyncio. Standard library only
(no third-party runtime dependencies). It pools anything: DB drivers, HTTP sessions, SSH clients, sockets…
because the pool knows nothing about connections except what a small ConnectionFactory tells it.
from connpool import ConnectionPool, PoolConfig
pool = ConnectionPool.from_callables(make_conn, validate=is_alive, config=PoolConfig(max_size=20))
with pool: # optional: warm-up + background sweeper
with pool.connection() as conn: # acquire -> use -> release (errors handled)
conn.do_something()
Project layout
src/connpool/ the library (imports: standard library only)
core/ errors, clocks, PooledConnection/CloseReason, timeout helpers
resilience/ breaker.py (circuit breaker), retry.py (backoff policy)
policy/ config.py (PoolConfig), lifecycle.py (pure "when must a connection retire?" rules)
factories/ sync_factory.py, async_factory.py <- what YOU implement to pool anything
observability/ events.py (PoolListener), stats.py (metrics)
pools/ sync_pool.py, async_pool.py, registry.py (pool-per-key)
tests/ mirrors src/ (unittest, no dependencies)
support/ fake factories and helpers
examples/ client code - NOT part of the library
database/ generic DB-API factory (any driver), sqlite3 demo (runnable), psycopg demo
http_client/ ssh/ async_usage/
Dependency direction is strictly downward (a layer only imports from layers listed above it):
core → resilience → policy → factories/observability → pools. The top-level connpool/__init__.py is a
facade re-exporting the public API, so from connpool import ConnectionPool keeps working even if internal
modules move.
What you get
| Concern | How it works |
|---|---|
| Sizing | min_size kept warm, max_size is a hard cap (slot reserved before create) |
| Timeouts | acquire_timeout (per-call override), create_timeout (async), retries never sleep past the deadline |
| Rotation | max_lifetime (with jitter), max_uses, max_idle_time, LIFO/FIFO reuse |
| Health | validate on checkout if idle ≥ validate_after_idle; reset() on release; after an exception: fatal → discard, otherwise re-validate; optional validate_idle pings idle connections in the background sweep and reaps dead ones |
| Retries | exponential backoff + jitter, retry_on filter |
| Circuit breaker | closed → open → half-open, probe limits, state-change events; open = fail fast. Counts one failure per logical creation (a whole retry sequence), not per attempt. A create failure or a create_timeout (slow backend) counts; an acquire-timeout or cancellation mid-create does not |
| Backpressure | max_waiters caps the blocked-caller queue; excess callers fail fast with PoolExhaustedError |
| Leak detection | leak_detection_timeout warns (via log + on_connection_leak) when a borrowed connection is held too long; warn-only, never force-reclaimed |
| Observability | PoolListener hooks + pool.stats() snapshot (counters, avg + p50/p95/p99 for both acquire-wait and create-latency over a recent window) |
| Shutdown | idempotent close(), wakes blocked callers, in-use connections close on release; close(timeout=) drains gracefully; a ResourceWarning fires if a pool is GC'd un-closed |
| Cancellation | asyncio cancellation never leaks a slot |
| Many backends | PoolRegistry / AsyncPoolRegistry = one lazily-created pool per key (host, tenant, DB) |
Writing a factory
class MyFactory(ConnectionFactory[MyConn]):
def create(self): return MyConn.connect(...) # enforce your own connect timeout
def validate(self, c): return c.ping() # cheap liveness check
def reset(self, c): c.rollback() # scrub session state on release
def close(self, c): c.close() # default: calls c.close()
def is_fatal_error(self, e): return isinstance(e, ConnectionError)
Async: subclass AsyncConnectionFactory, or pass sync/async callables to AsyncConnectionPool.from_callables.
examples/database/dbapi_factory.py shows one factory that covers every PEP 249 database driver.
Configuration (PoolConfig)
| Field | Default | Meaning |
|---|---|---|
min_size / max_size |
0 / 10 | warm connections / hard cap |
acquire_timeout |
30 | seconds to wait (None = forever, 0 = don't wait) |
max_waiters |
None | backpressure: cap callers blocked in acquire() (excess → PoolExhaustedError; None = unlimited, 0 = never wait when full) |
create_timeout |
10 | async only; sync factories enforce their own |
max_lifetime / lifetime_jitter |
1800 / 0.1 | retire after age, shaved by up to 10% |
max_idle_time |
600 | evict idle connections (never below min_size) |
max_uses |
None | retire after N checkouts |
idle_strategy |
LIFO | FIFO spreads load evenly |
validate_after_idle |
30 | validate on checkout if idle this long (0 = always, None = never) |
validate_idle |
False | also validate idle connections during the background sweep and reap dead ones (needs open(); adds I/O) |
maintenance_interval |
30 | background sweep period (None = off) |
leak_detection_timeout |
None | warn when a borrowed connection is held longer than this (needs open(); checked once per maintenance_interval sweep; None = off) |
retry |
3 attempts, 0.1s base, ×2, 5s cap | RetryPolicy (NO_RETRY disables) |
breaker |
5 failures / 30s recovery | BreakerConfig (None disables); failure_threshold counts failed creations, not retry attempts |
Install, test, run examples
pip install -e . # or: export PYTHONPATH=src
pip install -e ".[dev]" # + coverage/pytest/mypy
python -m unittest discover -s tests -t . # 142 tests, ~2s
./run_tests.sh # same, with a compact coverage report (gate: 100%)
mypy # strict type check (config in pyproject.toml)
python examples/database/sqlite_demo.py
(pytest works too if installed; configuration is in pyproject.toml.)
Examples importing paramiko or psycopg need those packages; the library never does.
Limits / extension points
- The breaker guards connection creation. To also trip on request failures wrap calls with
CircuitBreaker.call(). - The breaker counts consecutive failed creations (one per logical acquire, retries included). A real backend failure or a
create_timeoutfiring (the backend is too slow to connect — a genuine fault) both count; an acquire-timeout (caller impatience) or cancellation mid-create does not. A rate-based window is a drop-in replacement of one class. - Leak detection is warn-only by design — the pool never yanks a connection a caller may still be using. It also only runs while the pool is
open()(the maintenance sweep drives it), so registry pools need an explicitopen(), and a warning can lag the real leak by up to onemaintenance_interval. - Not fork-safe. A pool
open()ed beforeos.fork()(e.g. a pre-forking server like gunicorn withpreload, ormultiprocessing) shares live connections/file descriptors with the children — close the pool before forking and create a fresh one per process (max_sizeis per-process). - Pool size is fixed at construction (
PoolConfigis immutable); there is no runtime resize. Use a new pool, or aPoolRegistrykeyed by profile, if you need different caps at runtime. - Wake-up order of blocked threads is not strictly FIFO.
- Sync
create()can't be interrupted by the pool; give your client a connect timeout. - Listener hooks are synchronous; push heavy work to a queue.
- A factory's
create()must return a distinct object each call; returning a shared/singleton connection is rejected withConnectionCreateError(the pool tracks borrow state by object identity). acquire_waitpercentiles are estimated over the last 1024 samples (bounded memory), not the full history.- With
validate_idleon, idle connections being pinged by the background sweep are briefly reserved and count asin_use(notidle) instats()for the duration of that check — they are genuinely unavailable while validated, not borrowed by a caller.
Considered, not implemented
These are deliberate scope decisions, not oversights. For most, something already in the library covers the real need; adding them would make the pool heavier for every user to serve a minority. Listed here so the trade-off is explicit and the upgrade path is known.
| Feature | How it would be implemented | Why not (and what covers it today) |
|---|---|---|
| Runtime resize | Make max_size/min_size mutable behind the lock; on grow, raise the cap and notify_all waiters; on shrink, mark excess idle connections for disposal and let in-use ones close on release (never force-closed). |
Erodes the immutable-PoolConfig invariant (predictability is a feature); shrink is fiddly. Use a fresh pool or a PoolRegistry keyed by profile — and min_size↔max_size already flexes with load. |
| Adaptive sizing | A control loop that tunes max_size from live metrics (saturation, acquire latency), with hysteresis to avoid oscillation. |
Genuinely hard to do well and easy to make worse than static. min_size + max_size + idle eviction is already a simple, deterministic adaptive scheme (grow to max under load, shrink to min when quiet). |
| Priority acquisition | Replace the single Condition with a priority heap of per-waiter futures; release() hands the connection to the highest-priority waiter; timeouts/cancellation remove waiters from the heap. |
Ordering ≠ isolation, and it complicates the hot path. Prefer separate pools (a PoolRegistry acts as bulkheads): give batch its own small pool and interactive its own. |
| Create-rate limiting | A token-bucket / min-interval gate in the create path, checked after reserving a slot and bounded by acquire_timeout. |
Connection storms are already bounded by max_size (caps concurrent creates) and the circuit breaker (halts storms after failures). Add only if a backend imposes a literal connect/sec quota — this is the cheapest of these to add if ever needed. |
| Lease handles (use-after-release protection) | Return a proxy wrapping the raw connection, forwarding attribute access and invalidating itself on release. | A Python proxy is bypassable (the underlying object is always reachable), so it is a guardrail, not a guarantee, and it taxes every call. The context manager + identity tracking already give deterministic release and detect double/foreign release. |
| Broken-connection quarantine | Move suspect connections to a side store with a reason/timestamp and a debug API, instead of closing them. | Quarantine's only real value is forensic; in steady state you want broken connections closed — hoarding dead file descriptors is a resource leak by design (HikariCP/asyncpg discard too). Use the on_connection_closed(conn, reason) hook to snapshot metadata at close time if you need diagnostics. |
Metadata
Release files for connpool 1.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| connpool-1.0.2.tar.gz | 31.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| connpool-1.0.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 68.6 kB
Release files / connpool-1.0.2.tar.gz
| Download URL | connpool-1.0.2.tar.gz |
|---|---|
| Size | 31.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5ea17c39f158afc56dbf3c82fce4b2826c0f6dfecb0b6bcf85f3a5724f9a041b
|
|
BLAKE2b-256 checksum How to use checksums |
ab831e7af4a6a749f655d25be8c0a9b2dbaf89fd08b090553cd6c878057d064c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / connpool-1.0.2-py3-none-any.whl
| Download URL | connpool-1.0.2-py3-none-any.whl |
|---|---|
| Size | 36.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1d0d10c5341a259fbf85c2903c47e668908b7f8f787aa78c4aadb62d72788ba6
|
|
BLAKE2b-256 checksum How to use checksums |
916f213b3ed735de5b0df7afcb91a761e699c67dc93808541302528514abccd6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for connpool 1.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for connpool-1.0.2.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.