python-connection-pool-tuning
Use this when a Python SDK uses aiohttp / httpx / asyncpg / redis / aiokafka, recovering from a pool-exhaustion incident, sizing a pool for a declared throughput target, or hitting file-descriptor exh
SKILL.mdskills/python-connection-pool-tuning/SKILL.md
GitHubpython-connection-pool-tuning (v1.0.0)
Rationale
Default pool sizes are wrong for SDK code:
aiohttp.TCPConnector()→limit=100,limit_per_host=0(unlimited per host) — fan-out can saturate the host's port table.asyncpg.create_pool()→min_size=10, max_size=10(fixed at 10) — doesn't scale up under load.redis.asyncio.ConnectionPool()→max_connections=10(default) — generous for some workloads, tight for others.aiokafka.AIOKafkaProducer→ no explicit pool concept; one producer = one connection.
Each library's Config knob is named differently. The Python pack's convention: declare per-pool sizing in the SDK's Config (typed; not library-leak), translate to library-specific settings in the constructor, and emit pool metrics. Pool exhaustion raises a typed PoolExhaustedError rather than blocking forever.
This skill is cited by code-reviewer-python (resilience review-criteria), python-asyncio-patterns (Semaphore as concurrency cap analog), python-asyncio-leak-prevention (pool close in __aexit__), python-otel-instrumentation (pool depth metrics), python-sdk-config-pattern (Config pool fields).
Activation signals
- SDK uses an HTTP / DB / Redis / Kafka client.
- TPRD §3 declares throughput / concurrency target.
- Production incident: pool exhaustion blocked the entire client.
- Reviewing default pool size — does it match the workload?
- File-descriptor exhaustion at scale.
Sizing heuristic
max_connections = ceil(throughput_per_second × p99_latency_seconds)
min_connections = ceil(max_connections × 0.1) # 10% warm
idle_timeout_s = 300 # 5 min, longer than typical inter-request gap
healthcheck_interval_s = 30 # ping idle conns to detect dead pools
Worked example: 500 RPS at p99 of 200ms → max = ceil(500 × 0.2) = 100. Round up for headroom: max_connections = 128. Warm pool: min = 16.
This is a STARTING point. Tune by watching pool depth + exhaustion-error rate in production. Document the heuristic in the Config docstring.
Library mapping
aiohttp
import aiohttp
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
base_url: str
pool_max: int = 128
pool_max_per_host: int = 32
pool_keepalive_s: float = 30.0
pool_idle_timeout_s: float = 300.0
pool_dns_ttl_s: float = 10.0
def make_aiohttp_connector(config: Config) -> aiohttp.TCPConnector:
return aiohttp.TCPConnector(
limit=config.pool_max,
limit_per_host=config.pool_max_per_host,
keepalive_timeout=config.pool_keepalive_s,
ttl_dns_cache=config.pool_dns_ttl_s,
force_close=False, # reuse connections
enable_cleanup_closed=True, # reap closed-but-not-released
)
limit_per_host is critical for SDKs talking to ONE host — without it, a single base_url can monopolize the pool budget.
enable_cleanup_closed=True (recommended; default False) catches a known aiohttp bug where TLS connections sometimes leave residual sockets after server-side close; the cleanup task reaps them.
asyncpg
import asyncpg
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
db_dsn: str
db_pool_min: int = 4
db_pool_max: int = 32
db_pool_max_inactive_lifetime_s: float = 300.0
db_pool_command_timeout_s: float = 5.0
async def make_asyncpg_pool(config: Config) -> asyncpg.Pool:
return await asyncpg.create_pool(
dsn=config.db_dsn,
min_size=config.db_pool_min,
max_size=config.db_pool_max,
max_inactive_connection_lifetime=config.db_pool_max_inactive_lifetime_s,
command_timeout=config.db_pool_command_timeout_s,
)
max_inactive_connection_lifetime (default 300s) recycles idle connections — essential for cloud Postgres (RDS, Cloud SQL) which often kills idle conns after 30 min.
command_timeout is per-statement, not per-connection-acquire — prevents a runaway query from holding a pool slot forever.
asyncpg has no built-in pool-acquisition timeout; wrap the acquire:
import asyncio
async def get_conn_or_fail(pool: asyncpg.Pool, *, timeout_s: float) -> asyncpg.Connection:
try:
async with asyncio.timeout(timeout_s):
return await pool.acquire()
except builtins.TimeoutError as e:
raise PoolExhaustedError("asyncpg pool exhausted") from e
redis (redis-py >= 5.0 async)
import redis.asyncio as redis
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
redis_url: str
redis_pool_max: int = 50
redis_pool_socket_keepalive: bool = True
redis_pool_health_check_interval: float = 30.0
def make_redis_pool(config: Config) -> redis.ConnectionPool:
return redis.ConnectionPool.from_url(
config.redis_url,
max_connections=config.redis_pool_max,
socket_keepalive=config.redis_pool_socket_keepalive,
health_check_interval=config.redis_pool_health_check_interval,
decode_responses=False,
)
# Then in the client:
redis_pool = make_redis_pool(config)
self._redis = redis.Redis(connection_pool=redis_pool)
health_check_interval=30 triggers a ping on idle connections, catching dead pools before they're handed back to the caller.
max_connections enforced at acquire-time; exceeded → redis.exceptions.ConnectionError. Wrap in PoolExhaustedError:
try:
async with self._redis.client() as client:
await client.set("k", b"v")
except redis.exceptions.ConnectionError as e:
if "Too many connections" in str(e):
raise PoolExhaustedError("redis pool exhausted") from e
raise
httpx
import httpx
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
base_url: str
http_pool_max_keepalive: int = 20
http_pool_max: int = 128
http_pool_keepalive_s: float = 30.0
def make_httpx_client(config: Config, *, ssl_ctx: ssl.SSLContext | None = None) -> httpx.AsyncClient:
limits = httpx.Limits(
max_keepalive_connections=config.http_pool_max_keepalive,
max_connections=config.http_pool_max,
keepalive_expiry=config.http_pool_keepalive_s,
)
return httpx.AsyncClient(
base_url=config.base_url,
limits=limits,
verify=ssl_ctx if ssl_ctx is not None else True,
)
max_keepalive_connections < max_connections — keepalive is a SUBSET of total. Beyond keepalive, connections are short-lived (closed after one request).
httpx blocks at acquire when pool is full, but you can layer a Semaphore + timeout for explicit PoolExhaustedError:
self._pool_sem = asyncio.Semaphore(config.http_pool_max)
async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
try:
async with asyncio.timeout(config.pool_acquire_timeout_s):
async with self._pool_sem:
return await self._http.request(method, url, **kwargs)
except builtins.TimeoutError as e:
raise PoolExhaustedError("http pool exhausted") from e
aiokafka
from aiokafka import AIOKafkaProducer
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
kafka_bootstrap: str
kafka_max_in_flight_requests: int = 5
kafka_send_buffer_bytes: int = 33554432 # 32 MiB
kafka_request_timeout_ms: int = 5000
async def make_kafka_producer(config: Config) -> AIOKafkaProducer:
return AIOKafkaProducer(
bootstrap_servers=config.kafka_bootstrap,
max_in_flight_requests_per_connection=config.kafka_max_in_flight_requests,
send_buffer_bytes=config.kafka_send_buffer_bytes,
request_timeout_ms=config.kafka_request_timeout_ms,
)
aiokafka's pool concept is INFLIGHT requests, not connections (it maintains one connection per broker). max_in_flight_requests_per_connection caps unacked in-flight; default 5 is conservative; increase to 100+ for high-throughput producers (with idempotent=True for ordering guarantees).
Rule 1 — PoolExhaustedError is the surface
from motadatapysdk.errors import MotadataError
class PoolExhaustedError(MotadataError):
"""A connection pool reached its capacity and a new acquisition timed out.
The caller's options:
1. Catch and wait + retry.
2. Catch and route to a different client / pool.
3. Catch and surface to the user with backpressure.
Indicates either: (a) the SDK is over-loaded for its pool size,
or (b) the downstream is slow and connections are tied up.
"""
Surface at every pool-acquisition path. The exception's __cause__ carries the library-specific underlying error (asyncio.TimeoutError, redis.exceptions.ConnectionError, asyncpg.PoolError).
Rule 2 — Pool depth observable
from opentelemetry import metrics
meter = metrics.get_meter(__name__)
class Client:
def __init__(self, config: Config) -> None:
self._pool: asyncpg.Pool | None = None
meter.create_observable_gauge(
"motadata.db.pool.size",
callbacks=[self._observe_pool_size],
description="Currently held connections (busy + idle)",
)
meter.create_observable_gauge(
"motadata.db.pool.idle",
callbacks=[self._observe_pool_idle],
description="Currently idle connections",
)
def _observe_pool_size(self, options) -> Iterable[metrics.Observation]:
if self._pool is None:
return []
return [metrics.Observation(self._pool.get_size())]
def _observe_pool_idle(self, options) -> Iterable[metrics.Observation]:
if self._pool is None:
return []
return [metrics.Observation(self._pool.get_idle_size())]
Operators graph pool.size over time:
- Constantly at
max_size→ pool is undersized; raisepool_max. - Constantly at
min_sizewithidle ~ size→ pool is oversized; lowermin_sizefor memory. - Spiky between
minandmax→ bursty traffic; the pool is correctly absorbing burst.
asyncpg exposes get_size() and get_idle_size(); redis exposes pool.connection_kwargs; httpx 0.27+ exposes httpx.AsyncClient._transport._pool (private; use Semaphore counter as proxy).
Rule 3 — Pool close on __aexit__
class Client:
async def __aexit__(self, exc_type, exc_val, tb) -> None:
if self._pool is not None:
await self._pool.close() # asyncpg
self._pool = None
if self._redis is not None:
await self._redis.aclose() # redis-py 5.x
self._redis = None
if self._http is not None:
await self._http.aclose()
self._http = None
Per python-asyncio-leak-prevention, every pool ends on every exit path. Without explicit close, GC may run the destructor while the event loop is closed → RuntimeError: Event loop is closed.
Rule 4 — Pool pre-ping for cloud DBs
Cloud DBs (RDS, Cloud SQL, Aurora) often terminate idle connections silently. The SDK MUST detect this BEFORE handing a stale connection to the caller.
# asyncpg: max_inactive_connection_lifetime triggers reconnect
asyncpg.create_pool(
...,
max_inactive_connection_lifetime=300.0, # < cloud's idle kill of 1800s
)
# SQLAlchemy: pool_pre_ping=True (slight latency cost; worth it)
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(
config.db_dsn,
pool_pre_ping=True,
pool_recycle=300,
)
# redis-py: health_check_interval pings idle conns
redis.ConnectionPool.from_url(..., health_check_interval=30.0)
max_inactive_connection_lifetime should be < the cloud's idle-kill timeout. AWS RDS: default tcp_keepalives_idle = 7200s; aggressive deployments set 5min. Always less.
Rule 5 — Don't share pools across processes
# WRONG — pool initialized at module import; survives fork
_pool: asyncpg.Pool | None = None
async def get_pool() -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(...)
return _pool
# After multiprocessing.fork(): both parent + child share file descriptors;
# concurrent pool ops corrupt asyncpg state.
In multi-process deployments (Gunicorn workers, multiprocessing pool, Celery), each process MUST construct its own pool POST-FORK. Provide a Client.fork_safe_init() or document that the Client is constructed inside worker_init hooks:
# Gunicorn worker init
def post_fork(server, worker):
"""Per-worker init."""
global client
client = Client(Config(...)) # constructed AFTER fork
This is a classic Python deployment footgun. Document in the Client's class docstring.
Rule 6 — asyncio.Semaphore as portable concurrency cap
If the SDK wraps multiple pool-using clients, a top-level asyncio.Semaphore enforces a shared cap regardless of per-pool sizes:
class Client:
def __init__(self, config: Config) -> None:
self._global_inflight = asyncio.Semaphore(config.global_max_inflight)
async def call(self, ...) -> ...:
async with self._global_inflight:
return await self._do_call(...)
Useful for memory-pressure caps that span HTTP + DB + Redis. Per python-asyncio-patterns Rule 6.
Rule 7 — Test pool exhaustion
import asyncio
import pytest
async def test_pool_exhaustion_raises_typed_error(client_factory) -> None:
cfg = Config(http_pool_max=2, pool_acquire_timeout_s=0.1, ...)
async with client_factory(cfg) as client:
# Hold 2 connections; third should exhaust
async with client._pool_sem, client._pool_sem: # block both
with pytest.raises(PoolExhaustedError):
await client.request("GET", "/")
For asyncpg / redis / aiokafka, mocking pool exhaustion needs a slow downstream (the test holds connections for timeout + epsilon). Use a small pool_max + parallel calls + a tiny acquire_timeout.
Rule 8 — DNS caching pitfalls
aiohttp caches DNS for ttl_dns_cache=10 by default. For load-balanced backends with short DNS TTLs, this causes the SDK to keep hitting a stale IP after rebalance:
aiohttp.TCPConnector(
ttl_dns_cache=10, # 10s; respects backend's DNS TTL
use_dns_cache=True,
)
Tradeoff: lower TTL = more DNS queries but faster failover; higher TTL = fewer queries but slower failover. Match backend's DNS TTL.
For multi-IP backends, also set family=0 (default) to allow IPv4 + IPv6.
GOOD: full client with all pools
from __future__ import annotations
import asyncio
import contextlib
import ssl
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Self
import aiohttp
import asyncpg
import redis.asyncio as redis
from opentelemetry import metrics
from motadatapysdk.errors import PoolExhaustedError, MotadataError
meter = metrics.get_meter(__name__)
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
base_url: str
db_dsn: str
redis_url: str
http_pool_max: int = 128
http_pool_max_per_host: int = 32
http_pool_keepalive_s: float = 30.0
http_pool_acquire_timeout_s: float = 5.0
db_pool_min: int = 4
db_pool_max: int = 32
db_pool_idle_lifetime_s: float = 300.0
db_pool_command_timeout_s: float = 5.0
redis_pool_max: int = 50
redis_pool_health_check_s: float = 30.0
class Client:
def __init__(self, config: Config) -> None:
self._config = config
self._http: aiohttp.ClientSession | None = None
self._db_pool: asyncpg.Pool | None = None
self._redis: redis.Redis | None = None
self._closed = False
meter.create_observable_gauge(
"motadata.db.pool.size",
callbacks=[self._observe_db_pool_size],
)
async def __aenter__(self) -> Self:
connector = aiohttp.TCPConnector(
limit=self._config.http_pool_max,
limit_per_host=self._config.http_pool_max_per_host,
keepalive_timeout=self._config.http_pool_keepalive_s,
enable_cleanup_closed=True,
)
self._http = aiohttp.ClientSession(connector=connector)
self._db_pool = await asyncpg.create_pool(
dsn=self._config.db_dsn,
min_size=self._config.db_pool_min,
max_size=self._config.db_pool_max,
max_inactive_connection_lifetime=self._config.db_pool_idle_lifetime_s,
command_timeout=self._config.db_pool_command_timeout_s,
)
redis_pool = redis.ConnectionPool.from_url(
self._config.redis_url,
max_connections=self._config.redis_pool_max,
health_check_interval=self._config.redis_pool_health_check_s,
)
self._redis = redis.Redis(connection_pool=redis_pool)
return self
async def __aexit__(self, exc_type, exc_val, tb) -> None:
if self._closed:
return
self._closed = True
if self._http is not None:
await self._http.close()
self._http = None
if self._db_pool is not None:
await self._db_pool.close()
self._db_pool = None
if self._redis is not None:
await self._redis.aclose()
self._redis = None
async def db_query(self, query: str, *args) -> list[asyncpg.Record]:
if self._db_pool is None:
raise RuntimeError("client not entered")
try:
async with asyncio.timeout(self._config.http_pool_acquire_timeout_s):
async with self._db_pool.acquire() as conn:
return await conn.fetch(query, *args)
except builtins.TimeoutError as e:
raise PoolExhaustedError("asyncpg pool exhausted") from e
def _observe_db_pool_size(self, options) -> Iterable[metrics.Observation]:
if self._db_pool is None:
return []
return [metrics.Observation(self._db_pool.get_size())]
Demonstrates: per-pool sizing in Config (Rule 1), aiohttp + asyncpg + redis integrated (Library mapping), PoolExhaustedError on acquire timeout (Rule 1), pool depth observable (Rule 2), close in __aexit__ (Rule 3), idle-lifetime recycle (Rule 4).
BAD anti-patterns
# 1. Default pool sizes
async with aiohttp.ClientSession() as session: ...
# limit=100, limit_per_host=0 — single host can monopolize
# 2. Module-level pool init (fork-unsafe)
_pool = asyncio.run(asyncpg.create_pool(...)) # crashes after fork
# 3. No idle-lifetime recycling
asyncpg.create_pool(..., max_inactive_connection_lifetime=0.0)
# cloud DB silently kills conns; SDK gets stale handles
# 4. Block forever on pool acquire
async with self._db_pool.acquire() as conn: # no timeout; one slow query holds N callers
# 5. Catch ConnectionError as success
try:
async with pool.acquire() as conn: ...
except redis.exceptions.ConnectionError:
pass # operator can't see exhaustion
# 6. Pool not closed
self._db_pool = await asyncpg.create_pool(...)
# missing await self._db_pool.close() in __aexit__
# 7. Pool size pinned regardless of workload
DB_MAX = 10 # works for low traffic; fails at 1000 RPS
# 8. health_check_interval=0 (off) on cloud Redis
redis.ConnectionPool.from_url(..., health_check_interval=0)
# stale conns linger; first request after idle cycles fails
# 9. limit_per_host unset for single-host SDK
aiohttp.TCPConnector(limit=100) # all 100 may go to one host
# 10. No OTel pool metric
# Operator can't tune; flying blind on sizing
Cross-references
python-asyncio-patternsRule 6 (Semaphore for global cap).python-asyncio-leak-prevention(close pool in__aexit__).python-client-shutdown-lifecycle(ordered teardown — pool last).python-otel-instrumentation(pool depth observable).python-sdk-config-pattern(per-pool Config fields).python-exception-patterns(PoolExhaustedErrorextendsMotadataError).python-client-tls-configuration— TCPConnector takesssl=from TLS skill.network-error-classification(shared) — pool exhaustion vs network failure.