connpool 1.0.2__py3-none-any.whl

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.
connpool/__init__.py ADDED
@@ -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
+ ]
connpool/core/clock.py ADDED
@@ -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"]
@@ -0,0 +1,68 @@
1
+ """Asynchronous factory contract: async twin of ``sync_factory``."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, Union, cast
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ async def _maybe_await(value: Union[Any, Awaitable[Any]]) -> Any:
12
+ return await value if inspect.isawaitable(value) else value
13
+
14
+
15
+ class AsyncConnectionFactory(ABC, Generic[T]):
16
+ """Async twin of ``ConnectionFactory``."""
17
+
18
+ @abstractmethod
19
+ async def create(self) -> T: ...
20
+
21
+ async def validate(self, conn: T) -> bool:
22
+ return True
23
+
24
+ async def reset(self, conn: T) -> None:
25
+ return None
26
+
27
+ async def close(self, conn: T) -> None:
28
+ closer = getattr(conn, "close", None)
29
+ if callable(closer):
30
+ await _maybe_await(closer())
31
+
32
+ def is_fatal_error(self, exc: BaseException) -> bool:
33
+ return False
34
+
35
+
36
+ class AsyncCallableFactory(AsyncConnectionFactory[T]):
37
+ """Callables may be sync or async - both work."""
38
+
39
+ def __init__(
40
+ self,
41
+ create: Callable[[], Any],
42
+ *,
43
+ close: Optional[Callable[[T], Any]] = None,
44
+ validate: Optional[Callable[[T], Any]] = None,
45
+ reset: Optional[Callable[[T], Any]] = None,
46
+ is_fatal_error: Optional[Callable[[BaseException], bool]] = None,
47
+ ) -> None:
48
+ self._create, self._close, self._validate = create, close, validate
49
+ self._reset, self._is_fatal = reset, is_fatal_error
50
+
51
+ async def create(self) -> T:
52
+ return cast(T, await _maybe_await(self._create()))
53
+
54
+ async def validate(self, conn: T) -> bool:
55
+ return True if self._validate is None else bool(await _maybe_await(self._validate(conn)))
56
+
57
+ async def reset(self, conn: T) -> None:
58
+ if self._reset:
59
+ await _maybe_await(self._reset(conn))
60
+
61
+ async def close(self, conn: T) -> None:
62
+ if self._close:
63
+ await _maybe_await(self._close(conn))
64
+ else:
65
+ await super().close(conn)
66
+
67
+ def is_fatal_error(self, exc: BaseException) -> bool:
68
+ return bool(self._is_fatal and self._is_fatal(exc))
@@ -0,0 +1,72 @@
1
+ """Synchronous factory contract: the ONE seam between the pool and the outside world.
2
+
3
+ To pool *anything* (DB driver, HTTP session, SSH client, socket, gRPC channel...)
4
+ implement ``ConnectionFactory`` - or just pass callables via ``CallableFactory``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Callable, Generic, Optional, TypeVar
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class ConnectionFactory(ABC, Generic[T]):
15
+ """Override ``create``; override the rest only when the default isn't right."""
16
+
17
+ @abstractmethod
18
+ def create(self) -> T:
19
+ """Open a new connection. Should enforce its own connect timeout."""
20
+
21
+ def validate(self, conn: T) -> bool:
22
+ """Cheap liveness check (e.g. ``SELECT 1``). Return False/raise = dead."""
23
+ return True
24
+
25
+ def reset(self, conn: T) -> None:
26
+ """Called on release to scrub session state (e.g. ``rollback()``)."""
27
+
28
+ def close(self, conn: T) -> None:
29
+ """Dispose of a connection. Default: call ``conn.close()`` if present."""
30
+ closer = getattr(conn, "close", None)
31
+ if callable(closer):
32
+ closer()
33
+
34
+ def is_fatal_error(self, exc: BaseException) -> bool:
35
+ """True if ``exc`` (raised by user code using the connection) proves the
36
+ connection is broken, so it is discarded without further checks."""
37
+ return False
38
+
39
+
40
+ class CallableFactory(ConnectionFactory[T]):
41
+ """Build a factory from plain functions."""
42
+
43
+ def __init__(
44
+ self,
45
+ create: Callable[[], T],
46
+ *,
47
+ close: Optional[Callable[[T], None]] = None,
48
+ validate: Optional[Callable[[T], bool]] = None,
49
+ reset: Optional[Callable[[T], None]] = None,
50
+ is_fatal_error: Optional[Callable[[BaseException], bool]] = None,
51
+ ) -> None:
52
+ self._create, self._close, self._validate = create, close, validate
53
+ self._reset, self._is_fatal = reset, is_fatal_error
54
+
55
+ def create(self) -> T:
56
+ return self._create()
57
+
58
+ def validate(self, conn: T) -> bool:
59
+ return True if self._validate is None else bool(self._validate(conn))
60
+
61
+ def reset(self, conn: T) -> None:
62
+ if self._reset:
63
+ self._reset(conn)
64
+
65
+ def close(self, conn: T) -> None:
66
+ if self._close:
67
+ self._close(conn)
68
+ else:
69
+ super().close(conn)
70
+
71
+ def is_fatal_error(self, exc: BaseException) -> bool:
72
+ return bool(self._is_fatal and self._is_fatal(exc))
@@ -0,0 +1,5 @@
1
+ """Listener hooks and built-in metrics. Depends on ``core`` and ``resilience``."""
2
+ from connpool.observability.events import PoolListener
3
+ from connpool.observability.stats import PoolStats
4
+
5
+ __all__ = ["PoolListener", "PoolStats"]
@@ -0,0 +1,36 @@
1
+ """Observability hooks. Subclass ``PoolListener`` and override what you care about
2
+ (metrics, tracing, logging). Hooks run synchronously on the calling thread/task, outside
3
+ the pool's lock, so keep them fast. Exceptions raised by listeners are logged and
4
+ swallowed - a broken metrics exporter must never break the pool."""
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any, Iterable, List
9
+
10
+ from connpool.resilience.breaker import BreakerState
11
+ from connpool.core.connection import CloseReason, PooledConnection
12
+
13
+ log = logging.getLogger("connpool")
14
+
15
+
16
+ class PoolListener:
17
+ def on_connection_created(self, conn: PooledConnection[Any], duration: float) -> None: ...
18
+ def on_connection_closed(self, conn: PooledConnection[Any], reason: CloseReason) -> None: ...
19
+ def on_create_failed(self, error: BaseException) -> None: ...
20
+ def on_acquired(self, conn: PooledConnection[Any], wait_time: float) -> None: ...
21
+ def on_released(self, conn: PooledConnection[Any], hold_time: float) -> None: ...
22
+ def on_acquire_timeout(self, waited: float) -> None: ...
23
+ def on_connection_leak(self, conn: PooledConnection[Any], held_for: float) -> None: ...
24
+ def on_breaker_state_change(self, old: BreakerState, new: BreakerState) -> None: ...
25
+
26
+
27
+ class ListenerGroup:
28
+ def __init__(self, listeners: Iterable[PoolListener]) -> None:
29
+ self._listeners: List[PoolListener] = list(listeners)
30
+
31
+ def dispatch(self, hook: str, *args: object) -> None:
32
+ for listener in self._listeners:
33
+ try:
34
+ getattr(listener, hook)(*args)
35
+ except Exception: # noqa: BLE001 - listeners must never break the pool
36
+ log.exception("pool listener %r failed in %s", listener, hook)
@@ -0,0 +1,118 @@
1
+ """Built-in metrics: a listener that counts events, plus an immutable snapshot."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from collections import Counter, deque
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Deque, Dict, List, Optional
8
+
9
+ from connpool.core.connection import CloseReason, PooledConnection
10
+ from connpool.observability.events import PoolListener
11
+
12
+ ACQUIRE_WAIT_WINDOW = 1024
13
+ """How many recent acquire-wait / create-latency samples to keep. ``avg`` and all percentiles
14
+ are computed over this same window (bounded memory), so they always describe the same
15
+ population."""
16
+
17
+
18
+ def percentile(sorted_values: List[float], q: float) -> float:
19
+ """Linear-interpolated percentile of an already-sorted list. ``q`` in [0, 1]."""
20
+ if not sorted_values:
21
+ return 0.0
22
+ k = (len(sorted_values) - 1) * q
23
+ lo = int(k)
24
+ hi = min(lo + 1, len(sorted_values) - 1)
25
+ return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class PoolStats:
30
+ name: str
31
+ max_size: int
32
+ size: int # idle + in use + being created
33
+ idle: int # available for checkout right now
34
+ in_use: int # checked out: held by a caller, OR briefly reserved by a background
35
+ # idle health check (validate_idle) while it pings them off-lock
36
+ waiting: int # callers currently blocked in acquire()
37
+ created: int
38
+ create_failures: int
39
+ acquired: int
40
+ released: int
41
+ timeouts: int
42
+ closed: int
43
+ leaks: int = 0 # borrowed connections flagged as possibly leaked
44
+ closed_by_reason: Dict[str, int] = field(default_factory=dict)
45
+ # acquire-wait aggregates, all over the last ACQUIRE_WAIT_WINDOW samples
46
+ avg_acquire_wait: float = 0.0
47
+ p50_acquire_wait: float = 0.0
48
+ p95_acquire_wait: float = 0.0
49
+ p99_acquire_wait: float = 0.0
50
+ # create-latency aggregates (factory.create() duration), same window
51
+ avg_create_latency: float = 0.0
52
+ p50_create_latency: float = 0.0
53
+ p95_create_latency: float = 0.0
54
+ p99_create_latency: float = 0.0
55
+ breaker_state: Optional[str] = None
56
+
57
+ def as_dict(self) -> Dict[str, Any]:
58
+ return dict(self.__dict__)
59
+
60
+
61
+ class StatsCollector(PoolListener):
62
+ def __init__(self) -> None:
63
+ self._lock = threading.Lock()
64
+ self._n: Counter[str] = Counter()
65
+ self._by_reason: Counter[str] = Counter()
66
+ self._waits: Deque[float] = deque(maxlen=ACQUIRE_WAIT_WINDOW)
67
+ self._creates: Deque[float] = deque(maxlen=ACQUIRE_WAIT_WINDOW)
68
+
69
+ def _inc(self, key: str) -> None:
70
+ with self._lock:
71
+ self._n[key] += 1
72
+
73
+ def on_connection_created(self, conn: PooledConnection[Any], duration: float) -> None:
74
+ with self._lock:
75
+ self._n["created"] += 1
76
+ self._creates.append(duration)
77
+
78
+ def on_create_failed(self, error: BaseException) -> None:
79
+ self._inc("create_failures")
80
+
81
+ def on_connection_closed(self, conn: PooledConnection[Any], reason: CloseReason) -> None:
82
+ with self._lock:
83
+ self._n["closed"] += 1
84
+ self._by_reason[reason.value] += 1
85
+
86
+ def on_acquired(self, conn: PooledConnection[Any], wait_time: float) -> None:
87
+ with self._lock:
88
+ self._n["acquired"] += 1
89
+ self._waits.append(wait_time)
90
+
91
+ def on_released(self, conn: PooledConnection[Any], hold_time: float) -> None:
92
+ self._inc("released")
93
+
94
+ def on_acquire_timeout(self, waited: float) -> None:
95
+ self._inc("timeouts")
96
+
97
+ def on_connection_leak(self, conn: PooledConnection[Any], held_for: float) -> None:
98
+ self._inc("leaks")
99
+
100
+ def snapshot(self, **gauges: Any) -> PoolStats:
101
+ with self._lock:
102
+ n = self._n
103
+ waits = sorted(self._waits)
104
+ creates = sorted(self._creates)
105
+ return PoolStats(
106
+ created=n["created"], create_failures=n["create_failures"], acquired=n["acquired"],
107
+ released=n["released"], timeouts=n["timeouts"], closed=n["closed"],
108
+ leaks=n["leaks"], closed_by_reason=dict(self._by_reason),
109
+ avg_acquire_wait=(sum(waits) / len(waits)) if waits else 0.0,
110
+ p50_acquire_wait=percentile(waits, 0.50),
111
+ p95_acquire_wait=percentile(waits, 0.95),
112
+ p99_acquire_wait=percentile(waits, 0.99),
113
+ avg_create_latency=(sum(creates) / len(creates)) if creates else 0.0,
114
+ p50_create_latency=percentile(creates, 0.50),
115
+ p95_create_latency=percentile(creates, 0.95),
116
+ p99_create_latency=percentile(creates, 0.99),
117
+ **gauges,
118
+ )
@@ -0,0 +1,5 @@
1
+ """Configuration and the pure rules that decide when a connection must retire.
2
+ Depends on ``core`` and ``resilience``."""
3
+ from connpool.policy.config import IdleStrategy, PoolConfig
4
+
5
+ __all__ = ["IdleStrategy", "PoolConfig"]
@@ -0,0 +1,72 @@
1
+ """Pool configuration: one immutable, validated object."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ from dataclasses import dataclass, field, replace
6
+ from typing import Any, Optional
7
+
8
+ from connpool.resilience.breaker import BreakerConfig
9
+ from connpool.core.errors import PoolConfigError
10
+ from connpool.resilience.retry import RetryPolicy
11
+
12
+
13
+ class IdleStrategy(str, enum.Enum):
14
+ LIFO = "lifo" # reuse the hottest connection; cold ones age out and get evicted
15
+ FIFO = "fifo" # reuse the oldest idle connection; spreads load / rotates evenly
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PoolConfig:
20
+ # --- sizing ---
21
+ min_size: int = 0 # kept warm by open() and the maintenance pass
22
+ max_size: int = 10 # hard cap (idle + in use + being created)
23
+
24
+ # --- timeouts (seconds; None = no limit) ---
25
+ acquire_timeout: Optional[float] = 30.0 # default wait in acquire()
26
+ create_timeout: Optional[float] = 10.0 # enforced by AsyncConnectionPool only; sync
27
+ # factories must enforce their own connect timeout
28
+ max_waiters: Optional[int] = None # backpressure: cap callers blocked in acquire()
29
+ # (None = unlimited; 0 = never wait when full)
30
+
31
+ # --- rotation / recycling ---
32
+ max_lifetime: Optional[float] = 1800.0 # retire connections after this age
33
+ lifetime_jitter: float = 0.1 # shave up to 10% off each lifetime so a
34
+ # whole pool never expires at the same instant
35
+ max_idle_time: Optional[float] = 600.0 # evict idle connections (down to min_size)
36
+ max_uses: Optional[int] = None # retire after N checkouts
37
+ idle_strategy: IdleStrategy = IdleStrategy.LIFO
38
+
39
+ # --- health ---
40
+ validate_after_idle: Optional[float] = 30.0 # validate on checkout if idle >= this
41
+ # (0 = always, None = never)
42
+ maintenance_interval: Optional[float] = 30.0 # background sweep period (None = off)
43
+ leak_detection_timeout: Optional[float] = None # warn when a borrowed connection is held
44
+ # longer than this (None = off); needs open()
45
+ validate_idle: bool = False # validate idle connections during the maintenance
46
+ # sweep and evict dead ones (needs open(); adds I/O)
47
+
48
+ # --- resilience ---
49
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
50
+ breaker: Optional[BreakerConfig] = field(default_factory=BreakerConfig) # None = off
51
+
52
+ def __post_init__(self) -> None:
53
+ def need(ok: bool, msg: str) -> None:
54
+ if not ok:
55
+ raise PoolConfigError(msg)
56
+
57
+ need(self.max_size >= 1, "max_size must be >= 1")
58
+ need(0 <= self.min_size <= self.max_size, "min_size must be within [0, max_size]")
59
+ need(self.acquire_timeout is None or self.acquire_timeout >= 0, "acquire_timeout must be >= 0")
60
+ for name in ("create_timeout", "max_lifetime", "max_idle_time", "maintenance_interval",
61
+ "leak_detection_timeout"):
62
+ value = getattr(self, name)
63
+ need(value is None or value > 0, f"{name} must be > 0 or None")
64
+ need(self.validate_after_idle is None or self.validate_after_idle >= 0,
65
+ "validate_after_idle must be >= 0 or None")
66
+ need(0.0 <= self.lifetime_jitter <= 1.0, "lifetime_jitter must be within [0, 1]")
67
+ need(self.max_uses is None or self.max_uses >= 1, "max_uses must be >= 1 or None")
68
+ need(self.max_waiters is None or self.max_waiters >= 0, "max_waiters must be >= 0 or None")
69
+
70
+ def replace(self, **changes: Any) -> "PoolConfig":
71
+ """Return a modified copy (the config itself is immutable)."""
72
+ return replace(self, **changes)