connpool 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. connpool-1.0.2/LICENSE +21 -0
  2. connpool-1.0.2/PKG-INFO +157 -0
  3. connpool-1.0.2/README.md +127 -0
  4. connpool-1.0.2/pyproject.toml +66 -0
  5. connpool-1.0.2/setup.cfg +4 -0
  6. connpool-1.0.2/src/connpool/__init__.py +46 -0
  7. connpool-1.0.2/src/connpool/core/__init__.py +14 -0
  8. connpool-1.0.2/src/connpool/core/clock.py +45 -0
  9. connpool-1.0.2/src/connpool/core/connection.py +62 -0
  10. connpool-1.0.2/src/connpool/core/errors.py +41 -0
  11. connpool-1.0.2/src/connpool/core/timeouts.py +25 -0
  12. connpool-1.0.2/src/connpool/factories/__init__.py +6 -0
  13. connpool-1.0.2/src/connpool/factories/async_factory.py +68 -0
  14. connpool-1.0.2/src/connpool/factories/sync_factory.py +72 -0
  15. connpool-1.0.2/src/connpool/observability/__init__.py +5 -0
  16. connpool-1.0.2/src/connpool/observability/events.py +36 -0
  17. connpool-1.0.2/src/connpool/observability/stats.py +118 -0
  18. connpool-1.0.2/src/connpool/policy/__init__.py +5 -0
  19. connpool-1.0.2/src/connpool/policy/config.py +72 -0
  20. connpool-1.0.2/src/connpool/policy/lifecycle.py +75 -0
  21. connpool-1.0.2/src/connpool/pools/__init__.py +6 -0
  22. connpool-1.0.2/src/connpool/pools/async_pool.py +484 -0
  23. connpool-1.0.2/src/connpool/pools/registry.py +107 -0
  24. connpool-1.0.2/src/connpool/pools/sync_pool.py +466 -0
  25. connpool-1.0.2/src/connpool/py.typed +0 -0
  26. connpool-1.0.2/src/connpool/resilience/__init__.py +5 -0
  27. connpool-1.0.2/src/connpool/resilience/breaker.py +161 -0
  28. connpool-1.0.2/src/connpool/resilience/retry.py +44 -0
  29. connpool-1.0.2/src/connpool.egg-info/PKG-INFO +157 -0
  30. connpool-1.0.2/src/connpool.egg-info/SOURCES.txt +31 -0
  31. connpool-1.0.2/src/connpool.egg-info/dependency_links.txt +1 -0
  32. connpool-1.0.2/src/connpool.egg-info/requires.txt +5 -0
  33. connpool-1.0.2/src/connpool.egg-info/top_level.txt +1 -0
connpool-1.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Ilyushin (Jeffeek)
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.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: connpool
3
+ Version: 1.0.2
4
+ Summary: Generic connection pool for Python (sync + asyncio), standard library only
5
+ Author: Mikhail Ilyushin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Jeffeek/poolyglot/python
8
+ Project-URL: Repository, https://github.com/Jeffeek/poolyglot
9
+ Project-URL: Issues, https://github.com/Jeffeek/poolyglot/issues
10
+ Keywords: connection pool,pool,asyncio,database,resource,circuit breaker
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: coverage>=7; extra == "dev"
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: mypy>=1.8; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # connpool
32
+
33
+ A generic connection pool for Python 3.11+ — sync (threads) and `asyncio`. **Standard library only**
34
+ (no third-party runtime dependencies). It pools *anything*: DB drivers, HTTP sessions, SSH clients, sockets…
35
+ because the pool knows nothing about connections except what a small `ConnectionFactory` tells it.
36
+
37
+ ```python
38
+ from connpool import ConnectionPool, PoolConfig
39
+
40
+ pool = ConnectionPool.from_callables(make_conn, validate=is_alive, config=PoolConfig(max_size=20))
41
+ with pool: # optional: warm-up + background sweeper
42
+ with pool.connection() as conn: # acquire -> use -> release (errors handled)
43
+ conn.do_something()
44
+ ```
45
+
46
+ ## Project layout
47
+
48
+ ```
49
+ src/connpool/ the library (imports: standard library only)
50
+ core/ errors, clocks, PooledConnection/CloseReason, timeout helpers
51
+ resilience/ breaker.py (circuit breaker), retry.py (backoff policy)
52
+ policy/ config.py (PoolConfig), lifecycle.py (pure "when must a connection retire?" rules)
53
+ factories/ sync_factory.py, async_factory.py <- what YOU implement to pool anything
54
+ observability/ events.py (PoolListener), stats.py (metrics)
55
+ pools/ sync_pool.py, async_pool.py, registry.py (pool-per-key)
56
+ tests/ mirrors src/ (unittest, no dependencies)
57
+ support/ fake factories and helpers
58
+ examples/ client code - NOT part of the library
59
+ database/ generic DB-API factory (any driver), sqlite3 demo (runnable), psycopg demo
60
+ http_client/ ssh/ async_usage/
61
+ ```
62
+
63
+ Dependency direction is strictly downward (a layer only imports from layers listed above it):
64
+ `core → resilience → policy → factories/observability → pools`. The top-level `connpool/__init__.py` is a
65
+ facade re-exporting the public API, so `from connpool import ConnectionPool` keeps working even if internal
66
+ modules move.
67
+
68
+ ## What you get
69
+
70
+ | Concern | How it works |
71
+ |---|---|
72
+ | Sizing | `min_size` kept warm, `max_size` is a *hard* cap (slot reserved before create) |
73
+ | Timeouts | `acquire_timeout` (per-call override), `create_timeout` (async), retries never sleep past the deadline |
74
+ | Rotation | `max_lifetime` (with jitter), `max_uses`, `max_idle_time`, `LIFO`/`FIFO` reuse |
75
+ | 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 |
76
+ | Retries | exponential backoff + jitter, `retry_on` filter |
77
+ | 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 |
78
+ | Backpressure | `max_waiters` caps the blocked-caller queue; excess callers fail fast with `PoolExhaustedError` |
79
+ | Leak detection | `leak_detection_timeout` warns (via log + `on_connection_leak`) when a borrowed connection is held too long; warn-only, never force-reclaimed |
80
+ | Observability | `PoolListener` hooks + `pool.stats()` snapshot (counters, **avg + p50/p95/p99** for both acquire-wait and create-latency over a recent window) |
81
+ | 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 |
82
+ | Cancellation | asyncio cancellation never leaks a slot |
83
+ | Many backends | `PoolRegistry` / `AsyncPoolRegistry` = one lazily-created pool per key (host, tenant, DB) |
84
+
85
+ ## Writing a factory
86
+
87
+ ```python
88
+ class MyFactory(ConnectionFactory[MyConn]):
89
+ def create(self): return MyConn.connect(...) # enforce your own connect timeout
90
+ def validate(self, c): return c.ping() # cheap liveness check
91
+ def reset(self, c): c.rollback() # scrub session state on release
92
+ def close(self, c): c.close() # default: calls c.close()
93
+ def is_fatal_error(self, e): return isinstance(e, ConnectionError)
94
+ ```
95
+ Async: subclass `AsyncConnectionFactory`, or pass sync/async callables to `AsyncConnectionPool.from_callables`.
96
+ `examples/database/dbapi_factory.py` shows one factory that covers every PEP 249 database driver.
97
+
98
+ ## Configuration (`PoolConfig`)
99
+
100
+ | Field | Default | Meaning |
101
+ |---|---|---|
102
+ | `min_size` / `max_size` | 0 / 10 | warm connections / hard cap |
103
+ | `acquire_timeout` | 30 | seconds to wait (`None` = forever, `0` = don't wait) |
104
+ | `max_waiters` | None | backpressure: cap callers blocked in `acquire()` (excess → `PoolExhaustedError`; `None` = unlimited, `0` = never wait when full) |
105
+ | `create_timeout` | 10 | async only; sync factories enforce their own |
106
+ | `max_lifetime` / `lifetime_jitter` | 1800 / 0.1 | retire after age, shaved by up to 10% |
107
+ | `max_idle_time` | 600 | evict idle connections (never below `min_size`) |
108
+ | `max_uses` | None | retire after N checkouts |
109
+ | `idle_strategy` | LIFO | `FIFO` spreads load evenly |
110
+ | `validate_after_idle` | 30 | validate on checkout if idle this long (0 = always, None = never) |
111
+ | `validate_idle` | False | also validate idle connections during the background sweep and reap dead ones (needs `open()`; adds I/O) |
112
+ | `maintenance_interval` | 30 | background sweep period (None = off) |
113
+ | `leak_detection_timeout` | None | warn when a borrowed connection is held longer than this (needs `open()`; checked once per `maintenance_interval` sweep; None = off) |
114
+ | `retry` | 3 attempts, 0.1s base, ×2, 5s cap | `RetryPolicy` (`NO_RETRY` disables) |
115
+ | `breaker` | 5 failures / 30s recovery | `BreakerConfig` (`None` disables); `failure_threshold` counts failed *creations*, not retry attempts |
116
+
117
+ ## Install, test, run examples
118
+
119
+ ```
120
+ pip install -e . # or: export PYTHONPATH=src
121
+ pip install -e ".[dev]" # + coverage/pytest/mypy
122
+ python -m unittest discover -s tests -t . # 142 tests, ~2s
123
+ ./run_tests.sh # same, with a compact coverage report (gate: 100%)
124
+ mypy # strict type check (config in pyproject.toml)
125
+ python examples/database/sqlite_demo.py
126
+ ```
127
+ (`pytest` works too if installed; configuration is in `pyproject.toml`.)
128
+ Examples importing `paramiko` or `psycopg` need those packages; the library never does.
129
+
130
+ ## Limits / extension points
131
+
132
+ * The breaker guards connection *creation*. To also trip on request failures wrap calls with `CircuitBreaker.call()`.
133
+ * The breaker counts consecutive failed creations (one per logical acquire, retries included). A real backend failure or a `create_timeout` firing (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.
134
+ * 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 explicit `open()`, and a warning can lag the real leak by up to one `maintenance_interval`.
135
+ * **Not fork-safe.** A pool `open()`ed before `os.fork()` (e.g. a pre-forking server like gunicorn with `preload`, or `multiprocessing`) shares live connections/file descriptors with the children — close the pool before forking and create a fresh one per process (`max_size` is per-process).
136
+ * Pool size is fixed at construction (`PoolConfig` is immutable); there is no runtime resize. Use a new pool, or a `PoolRegistry` keyed by profile, if you need different caps at runtime.
137
+ * Wake-up order of blocked threads is not strictly FIFO.
138
+ * Sync `create()` can't be interrupted by the pool; give your client a connect timeout.
139
+ * Listener hooks are synchronous; push heavy work to a queue.
140
+ * A factory's `create()` must return a **distinct** object each call; returning a shared/singleton connection is rejected with `ConnectionCreateError` (the pool tracks borrow state by object identity).
141
+ * `acquire_wait` percentiles are estimated over the last 1024 samples (bounded memory), not the full history.
142
+ * With `validate_idle` on, idle connections being pinged by the background sweep are briefly reserved and count as `in_use` (not `idle`) in `stats()` for the duration of that check — they are genuinely unavailable while validated, not borrowed by a caller.
143
+
144
+ ## Considered, not implemented
145
+
146
+ These are deliberate scope decisions, not oversights. For most, something already in the
147
+ library covers the real need; adding them would make the pool heavier for every user to serve
148
+ a minority. Listed here so the trade-off is explicit and the upgrade path is known.
149
+
150
+ | Feature | How it would be implemented | Why not (and what covers it today) |
151
+ |---|---|---|
152
+ | **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. |
153
+ | **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). |
154
+ | **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. |
155
+ | **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. |
156
+ | **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. |
157
+ | **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. |
@@ -0,0 +1,127 @@
1
+ # connpool
2
+
3
+ A generic connection pool for Python 3.11+ — sync (threads) and `asyncio`. **Standard library only**
4
+ (no third-party runtime dependencies). It pools *anything*: DB drivers, HTTP sessions, SSH clients, sockets…
5
+ because the pool knows nothing about connections except what a small `ConnectionFactory` tells it.
6
+
7
+ ```python
8
+ from connpool import ConnectionPool, PoolConfig
9
+
10
+ pool = ConnectionPool.from_callables(make_conn, validate=is_alive, config=PoolConfig(max_size=20))
11
+ with pool: # optional: warm-up + background sweeper
12
+ with pool.connection() as conn: # acquire -> use -> release (errors handled)
13
+ conn.do_something()
14
+ ```
15
+
16
+ ## Project layout
17
+
18
+ ```
19
+ src/connpool/ the library (imports: standard library only)
20
+ core/ errors, clocks, PooledConnection/CloseReason, timeout helpers
21
+ resilience/ breaker.py (circuit breaker), retry.py (backoff policy)
22
+ policy/ config.py (PoolConfig), lifecycle.py (pure "when must a connection retire?" rules)
23
+ factories/ sync_factory.py, async_factory.py <- what YOU implement to pool anything
24
+ observability/ events.py (PoolListener), stats.py (metrics)
25
+ pools/ sync_pool.py, async_pool.py, registry.py (pool-per-key)
26
+ tests/ mirrors src/ (unittest, no dependencies)
27
+ support/ fake factories and helpers
28
+ examples/ client code - NOT part of the library
29
+ database/ generic DB-API factory (any driver), sqlite3 demo (runnable), psycopg demo
30
+ http_client/ ssh/ async_usage/
31
+ ```
32
+
33
+ Dependency direction is strictly downward (a layer only imports from layers listed above it):
34
+ `core → resilience → policy → factories/observability → pools`. The top-level `connpool/__init__.py` is a
35
+ facade re-exporting the public API, so `from connpool import ConnectionPool` keeps working even if internal
36
+ modules move.
37
+
38
+ ## What you get
39
+
40
+ | Concern | How it works |
41
+ |---|---|
42
+ | Sizing | `min_size` kept warm, `max_size` is a *hard* cap (slot reserved before create) |
43
+ | Timeouts | `acquire_timeout` (per-call override), `create_timeout` (async), retries never sleep past the deadline |
44
+ | Rotation | `max_lifetime` (with jitter), `max_uses`, `max_idle_time`, `LIFO`/`FIFO` reuse |
45
+ | 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 |
46
+ | Retries | exponential backoff + jitter, `retry_on` filter |
47
+ | 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 |
48
+ | Backpressure | `max_waiters` caps the blocked-caller queue; excess callers fail fast with `PoolExhaustedError` |
49
+ | Leak detection | `leak_detection_timeout` warns (via log + `on_connection_leak`) when a borrowed connection is held too long; warn-only, never force-reclaimed |
50
+ | Observability | `PoolListener` hooks + `pool.stats()` snapshot (counters, **avg + p50/p95/p99** for both acquire-wait and create-latency over a recent window) |
51
+ | 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 |
52
+ | Cancellation | asyncio cancellation never leaks a slot |
53
+ | Many backends | `PoolRegistry` / `AsyncPoolRegistry` = one lazily-created pool per key (host, tenant, DB) |
54
+
55
+ ## Writing a factory
56
+
57
+ ```python
58
+ class MyFactory(ConnectionFactory[MyConn]):
59
+ def create(self): return MyConn.connect(...) # enforce your own connect timeout
60
+ def validate(self, c): return c.ping() # cheap liveness check
61
+ def reset(self, c): c.rollback() # scrub session state on release
62
+ def close(self, c): c.close() # default: calls c.close()
63
+ def is_fatal_error(self, e): return isinstance(e, ConnectionError)
64
+ ```
65
+ Async: subclass `AsyncConnectionFactory`, or pass sync/async callables to `AsyncConnectionPool.from_callables`.
66
+ `examples/database/dbapi_factory.py` shows one factory that covers every PEP 249 database driver.
67
+
68
+ ## Configuration (`PoolConfig`)
69
+
70
+ | Field | Default | Meaning |
71
+ |---|---|---|
72
+ | `min_size` / `max_size` | 0 / 10 | warm connections / hard cap |
73
+ | `acquire_timeout` | 30 | seconds to wait (`None` = forever, `0` = don't wait) |
74
+ | `max_waiters` | None | backpressure: cap callers blocked in `acquire()` (excess → `PoolExhaustedError`; `None` = unlimited, `0` = never wait when full) |
75
+ | `create_timeout` | 10 | async only; sync factories enforce their own |
76
+ | `max_lifetime` / `lifetime_jitter` | 1800 / 0.1 | retire after age, shaved by up to 10% |
77
+ | `max_idle_time` | 600 | evict idle connections (never below `min_size`) |
78
+ | `max_uses` | None | retire after N checkouts |
79
+ | `idle_strategy` | LIFO | `FIFO` spreads load evenly |
80
+ | `validate_after_idle` | 30 | validate on checkout if idle this long (0 = always, None = never) |
81
+ | `validate_idle` | False | also validate idle connections during the background sweep and reap dead ones (needs `open()`; adds I/O) |
82
+ | `maintenance_interval` | 30 | background sweep period (None = off) |
83
+ | `leak_detection_timeout` | None | warn when a borrowed connection is held longer than this (needs `open()`; checked once per `maintenance_interval` sweep; None = off) |
84
+ | `retry` | 3 attempts, 0.1s base, ×2, 5s cap | `RetryPolicy` (`NO_RETRY` disables) |
85
+ | `breaker` | 5 failures / 30s recovery | `BreakerConfig` (`None` disables); `failure_threshold` counts failed *creations*, not retry attempts |
86
+
87
+ ## Install, test, run examples
88
+
89
+ ```
90
+ pip install -e . # or: export PYTHONPATH=src
91
+ pip install -e ".[dev]" # + coverage/pytest/mypy
92
+ python -m unittest discover -s tests -t . # 142 tests, ~2s
93
+ ./run_tests.sh # same, with a compact coverage report (gate: 100%)
94
+ mypy # strict type check (config in pyproject.toml)
95
+ python examples/database/sqlite_demo.py
96
+ ```
97
+ (`pytest` works too if installed; configuration is in `pyproject.toml`.)
98
+ Examples importing `paramiko` or `psycopg` need those packages; the library never does.
99
+
100
+ ## Limits / extension points
101
+
102
+ * The breaker guards connection *creation*. To also trip on request failures wrap calls with `CircuitBreaker.call()`.
103
+ * The breaker counts consecutive failed creations (one per logical acquire, retries included). A real backend failure or a `create_timeout` firing (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.
104
+ * 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 explicit `open()`, and a warning can lag the real leak by up to one `maintenance_interval`.
105
+ * **Not fork-safe.** A pool `open()`ed before `os.fork()` (e.g. a pre-forking server like gunicorn with `preload`, or `multiprocessing`) shares live connections/file descriptors with the children — close the pool before forking and create a fresh one per process (`max_size` is per-process).
106
+ * Pool size is fixed at construction (`PoolConfig` is immutable); there is no runtime resize. Use a new pool, or a `PoolRegistry` keyed by profile, if you need different caps at runtime.
107
+ * Wake-up order of blocked threads is not strictly FIFO.
108
+ * Sync `create()` can't be interrupted by the pool; give your client a connect timeout.
109
+ * Listener hooks are synchronous; push heavy work to a queue.
110
+ * A factory's `create()` must return a **distinct** object each call; returning a shared/singleton connection is rejected with `ConnectionCreateError` (the pool tracks borrow state by object identity).
111
+ * `acquire_wait` percentiles are estimated over the last 1024 samples (bounded memory), not the full history.
112
+ * With `validate_idle` on, idle connections being pinged by the background sweep are briefly reserved and count as `in_use` (not `idle`) in `stats()` for the duration of that check — they are genuinely unavailable while validated, not borrowed by a caller.
113
+
114
+ ## Considered, not implemented
115
+
116
+ These are deliberate scope decisions, not oversights. For most, something already in the
117
+ library covers the real need; adding them would make the pool heavier for every user to serve
118
+ a minority. Listed here so the trade-off is explicit and the upgrade path is known.
119
+
120
+ | Feature | How it would be implemented | Why not (and what covers it today) |
121
+ |---|---|---|
122
+ | **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. |
123
+ | **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). |
124
+ | **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. |
125
+ | **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. |
126
+ | **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. |
127
+ | **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. |
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"] # >=77 for the SPDX `license` string form (PEP 639)
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "connpool"
7
+ version = "1.0.2"
8
+ description = "Generic connection pool for Python (sync + asyncio), standard library only"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Mikhail Ilyushin" }]
14
+ keywords = ["connection pool", "pool", "asyncio", "database", "resource", "circuit breaker"]
15
+ dependencies = [] # runtime: standard library only
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Database",
26
+ "Topic :: Software Development :: Libraries",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Jeffeek/poolyglot/python"
32
+ Repository = "https://github.com/Jeffeek/poolyglot"
33
+ Issues = "https://github.com/Jeffeek/poolyglot/issues"
34
+
35
+ [project.optional-dependencies]
36
+ # Dev tooling only - the library itself stays dependency-free.
37
+ # pip install -e ".[dev]"
38
+ dev = [
39
+ "coverage>=7", # used by run_tests.sh
40
+ "pytest>=8", # optional: `pytest` also works (see [tool.pytest.ini_options])
41
+ "mypy>=1.8", # static type checking (the package ships py.typed)
42
+ ]
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.setuptools.package-data]
48
+ connpool = ["py.typed"]
49
+
50
+ [tool.pytest.ini_options] # optional: `pytest` also works if you prefer it
51
+ pythonpath = ["src"]
52
+ testpaths = ["tests"]
53
+
54
+ [tool.coverage.run]
55
+ source = ["src"]
56
+
57
+ [tool.coverage.report]
58
+ precision = 1
59
+ show_missing = true
60
+ # The library targets 100% line coverage; CI fails below it.
61
+ fail_under = 100
62
+
63
+ [tool.mypy]
64
+ python_version = "3.11"
65
+ strict = true
66
+ files = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,46 @@
1
+ """connpool - a generic connection pool for Python (sync + asyncio), standard library only.
2
+
3
+ Layers (each only depends on the ones above it):
4
+
5
+ core errors, clocks, connection bookkeeping
6
+ resilience circuit breaker, retry/backoff
7
+ policy PoolConfig + the pure retirement rules
8
+ factories ConnectionFactory: the seam users implement to pool anything
9
+ observability listeners + stats
10
+ pools ConnectionPool, AsyncConnectionPool, PoolRegistry
11
+
12
+ This module is the *public API facade*: everything users need can be imported straight
13
+ from ``connpool`` so that internal modules can move without breaking anyone.
14
+ """
15
+ from connpool.core import (
16
+ CircuitOpenError, Clock, CloseReason, ConnectionCreateError, InvalidReleaseError,
17
+ ManualClock, PoolClosedError, PoolConfigError, PoolError, PoolExhaustedError,
18
+ PoolTimeoutError, PooledConnection, SystemClock,
19
+ )
20
+ from connpool.factories import (
21
+ AsyncCallableFactory, AsyncConnectionFactory, CallableFactory, ConnectionFactory,
22
+ )
23
+ from connpool.observability import PoolListener, PoolStats
24
+ from connpool.policy import IdleStrategy, PoolConfig
25
+ from connpool.pools import AsyncConnectionPool, AsyncPoolRegistry, ConnectionPool, PoolRegistry
26
+ from connpool.resilience import (
27
+ NO_RETRY, BreakerConfig, BreakerState, CircuitBreaker, RetryPolicy,
28
+ )
29
+
30
+ __version__ = "1.0.0"
31
+
32
+ __all__ = [
33
+ # pools
34
+ "AsyncConnectionPool", "AsyncPoolRegistry", "ConnectionPool", "PoolRegistry",
35
+ # configuration
36
+ "BreakerConfig", "IdleStrategy", "NO_RETRY", "PoolConfig", "RetryPolicy",
37
+ # what you implement
38
+ "AsyncCallableFactory", "AsyncConnectionFactory", "CallableFactory", "ConnectionFactory",
39
+ # observability
40
+ "BreakerState", "CloseReason", "PoolListener", "PoolStats", "PooledConnection",
41
+ # errors
42
+ "CircuitOpenError", "ConnectionCreateError", "InvalidReleaseError", "PoolClosedError",
43
+ "PoolConfigError", "PoolError", "PoolExhaustedError", "PoolTimeoutError",
44
+ # standalone building blocks / testing
45
+ "CircuitBreaker", "Clock", "ManualClock", "SystemClock",
46
+ ]
@@ -0,0 +1,14 @@
1
+ """Foundation layer: errors, clocks, connection bookkeeping, timeout helpers.
2
+ Depends on nothing else in connpool."""
3
+ from connpool.core.clock import Clock, ManualClock, SystemClock
4
+ from connpool.core.connection import CloseReason, PooledConnection
5
+ from connpool.core.errors import (
6
+ CircuitOpenError, ConnectionCreateError, InvalidReleaseError, PoolClosedError,
7
+ PoolConfigError, PoolError, PoolExhaustedError, PoolTimeoutError,
8
+ )
9
+
10
+ __all__ = [
11
+ "CircuitOpenError", "Clock", "CloseReason", "ConnectionCreateError", "InvalidReleaseError",
12
+ "ManualClock", "PoolClosedError", "PoolConfigError", "PoolError", "PoolExhaustedError",
13
+ "PoolTimeoutError", "PooledConnection", "SystemClock",
14
+ ]
@@ -0,0 +1,45 @@
1
+ """Time source abstraction so every time-dependent rule is deterministic in tests."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from typing import Protocol
7
+
8
+
9
+ class Clock(Protocol):
10
+ def monotonic(self) -> float: ...
11
+ def sleep(self, seconds: float) -> None: ...
12
+ async def async_sleep(self, seconds: float) -> None: ...
13
+
14
+
15
+ class SystemClock:
16
+ """Real time. The default."""
17
+
18
+ def monotonic(self) -> float:
19
+ return time.monotonic()
20
+
21
+ def sleep(self, seconds: float) -> None:
22
+ time.sleep(seconds)
23
+
24
+ async def async_sleep(self, seconds: float) -> None:
25
+ await asyncio.sleep(seconds)
26
+
27
+
28
+ class ManualClock:
29
+ """Fake clock for tests: time moves only when you say so; sleeping just advances it."""
30
+
31
+ def __init__(self, start: float = 1000.0) -> None:
32
+ self._now = start
33
+
34
+ def monotonic(self) -> float:
35
+ return self._now
36
+
37
+ def advance(self, seconds: float) -> None:
38
+ self._now += seconds
39
+
40
+ def sleep(self, seconds: float) -> None:
41
+ self._now += seconds
42
+
43
+ async def async_sleep(self, seconds: float) -> None:
44
+ self._now += seconds
45
+ await asyncio.sleep(0)
@@ -0,0 +1,62 @@
1
+ """Bookkeeping wrapper around a raw connection."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ import itertools
6
+ from dataclasses import dataclass, field
7
+ from typing import Generic, Optional, TypeVar
8
+
9
+ T = TypeVar("T")
10
+ _ids = itertools.count(1)
11
+
12
+
13
+ class CloseReason(str, enum.Enum):
14
+ """Why a connection left the pool."""
15
+
16
+ EXPIRED = "expired" # max_lifetime reached (rotation)
17
+ MAX_USES = "max_uses" # max_uses reached (rotation)
18
+ IDLE = "idle" # sat idle longer than max_idle_time
19
+ UNHEALTHY = "unhealthy" # failed validation
20
+ ERROR = "error" # caller's block raised and the connection is suspect
21
+ RESET_FAILED = "reset_failed" # factory.reset() raised on release
22
+ DISCARDED = "discarded" # caller asked for it
23
+ POOL_CLOSED = "pool_closed"
24
+
25
+
26
+ @dataclass(eq=False)
27
+ class PooledConnection(Generic[T]):
28
+ """A raw connection plus the metadata the pool needs to manage it."""
29
+
30
+ raw: T
31
+ created_at: float
32
+ expires_at: Optional[float] = None
33
+ last_returned_at: Optional[float] = None
34
+ checked_out_at: Optional[float] = None
35
+ use_count: int = 0
36
+ leak_reported: bool = False
37
+ id: int = field(default_factory=lambda: next(_ids))
38
+
39
+ def __post_init__(self) -> None:
40
+ if self.last_returned_at is None:
41
+ self.last_returned_at = self.created_at
42
+
43
+ def age(self, now: float) -> float:
44
+ return now - self.created_at
45
+
46
+ def idle_time(self, now: float) -> float:
47
+ return now - (self.last_returned_at or self.created_at)
48
+
49
+ def hold_time(self, now: float) -> float:
50
+ return 0.0 if self.checked_out_at is None else now - self.checked_out_at
51
+
52
+ def mark_checked_out(self, now: float) -> None:
53
+ self.checked_out_at = now
54
+ self.use_count += 1
55
+ self.leak_reported = False
56
+
57
+ def mark_returned(self, now: float) -> None:
58
+ self.checked_out_at = None
59
+ self.last_returned_at = now
60
+
61
+ def __repr__(self) -> str:
62
+ return f"<PooledConnection #{self.id} uses={self.use_count}>"
@@ -0,0 +1,41 @@
1
+ """Exception hierarchy. Catch ``PoolError`` to handle anything the pool raises."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class PoolError(Exception):
6
+ """Base class for every error raised by connpool."""
7
+
8
+
9
+ class PoolConfigError(PoolError, ValueError):
10
+ """Invalid pool / breaker / retry configuration."""
11
+
12
+
13
+ class PoolClosedError(PoolError):
14
+ """The pool has been closed; no more connections can be acquired."""
15
+
16
+
17
+ class PoolTimeoutError(PoolError, TimeoutError):
18
+ """No connection became available before the acquire deadline."""
19
+
20
+
21
+ class PoolExhaustedError(PoolError):
22
+ """The pool is full and the waiter queue is at ``max_waiters``; shedding load."""
23
+
24
+
25
+ class ConnectionCreateError(PoolError):
26
+ """The factory failed to create a connection (after all retries).
27
+
28
+ The original exception is available as ``__cause__``.
29
+ """
30
+
31
+
32
+ class CircuitOpenError(PoolError):
33
+ """The circuit breaker is rejecting calls to protect a failing backend."""
34
+
35
+ def __init__(self, retry_after: float) -> None:
36
+ super().__init__(f"circuit breaker is open; retry in ~{retry_after:.1f}s")
37
+ self.retry_after = retry_after
38
+
39
+
40
+ class InvalidReleaseError(PoolError):
41
+ """Released an object that is not checked out from this pool (double release?)."""
@@ -0,0 +1,25 @@
1
+ """Tiny shared helpers (internal)."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ from typing import Optional, Union
6
+
7
+
8
+ class _Unset(enum.Enum):
9
+ UNSET = "unset"
10
+
11
+
12
+ UNSET = _Unset.UNSET
13
+ """Sentinel: "use the configured default" (``None`` already means "wait forever")."""
14
+
15
+ Timeout = Union[float, None, _Unset]
16
+
17
+
18
+ def compute_deadline(timeout: Timeout, default: Optional[float], now: float) -> Optional[float]:
19
+ if isinstance(timeout, _Unset):
20
+ timeout = default
21
+ return None if timeout is None else now + timeout
22
+
23
+
24
+ def remaining(deadline: Optional[float], now: float) -> Optional[float]:
25
+ return None if deadline is None else deadline - now
@@ -0,0 +1,6 @@
1
+ """The seam to the outside world: what users implement to pool *any* kind of connection.
2
+ Has no dependency on the rest of connpool."""
3
+ from connpool.factories.async_factory import AsyncCallableFactory, AsyncConnectionFactory
4
+ from connpool.factories.sync_factory import CallableFactory, ConnectionFactory
5
+
6
+ __all__ = ["AsyncCallableFactory", "AsyncConnectionFactory", "CallableFactory", "ConnectionFactory"]