Skip to content

Commit 14a655c

Browse files
committed
feat(portaswitch): make async HTTP connection-pool size configurable (WT-1720)
Follow-up to the sync->async migration. The httpx.AsyncClient connection pool — not the former ~40 Starlette thread-pool tokens — is now the real per-pod ceiling on concurrent requests toward the switch, so expose its size via env: - PORTASWITCH_MAX_CONNECTIONS (default 100) - PORTASWITCH_MAX_KEEPALIVE_CONNECTIONS (default 20) Defaults match httpx's own: an order of magnitude above the old thread cap while staying polite to the switch. Raise for a large/dedicated switch or high Cloud Run concurrency; lower to protect a small or shared one. Blank/invalid env values fall back to the defaults. Applied to the shared AsyncClient via httpx.Limits; http_api.py and the other adapters are unaffected.
1 parent 73c3d5c commit 14a655c

4 files changed

Lines changed: 60 additions & 4 deletions

File tree

app/bss/adapters/portaswitch/api/account.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ def __init__(self, portaswitch_settings: PortaSwitchSettings):
2828
self._verify_https = portaswitch_settings.VERIFY_HTTPS
2929
if portaswitch_settings.API_TIMEOUT is not None:
3030
self.DEFAULT_REQUEST_TIMEOUT = portaswitch_settings.API_TIMEOUT
31+
# httpx connection-pool limits for the shared async client (WT-1720).
32+
self._max_connections = portaswitch_settings.MAX_CONNECTIONS
33+
self._max_keepalive_connections = portaswitch_settings.MAX_KEEPALIVE_CONNECTIONS
3134

3235
async def __send_request(
3336
self, module: str, method: str, params: dict, stream: bool | None = None, access_token: str | None = None

app/bss/adapters/portaswitch/api/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ def __init__(self, portaswitch_settings: PortaSwitchSettings) -> None:
3131
self._verify_https = portaswitch_settings.VERIFY_HTTPS
3232
if portaswitch_settings.API_TIMEOUT is not None:
3333
self.DEFAULT_REQUEST_TIMEOUT = portaswitch_settings.API_TIMEOUT
34+
# httpx connection-pool limits for the shared async client (WT-1720).
35+
self._max_connections = portaswitch_settings.MAX_CONNECTIONS
36+
self._max_keepalive_connections = portaswitch_settings.MAX_KEEPALIVE_CONNECTIONS
3437
self._api_user = PortaSwitchAdminUser(
3538
user_id=portaswitch_settings.ADMIN_API_LOGIN, token=portaswitch_settings.ADMIN_API_TOKEN
3639
)

app/bss/adapters/portaswitch/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ class PortaSwitchSettings(BaseSettings):
3535
# slow/unresponsive switch can't pin worker threads indefinitely (WT-1717).
3636
# Configurable via PORTASWITCH_API_TIMEOUT; set empty to keep the base default.
3737
API_TIMEOUT: Optional[float] = 25
38+
# httpx connection-pool limits for the async client (WT-1720). This pool — not
39+
# the old ~40 Starlette thread-pool tokens — is now the real per-pod ceiling on
40+
# concurrent requests toward the switch. Defaults match httpx's own (100/20):
41+
# an order of magnitude above the former thread cap, while staying polite to the
42+
# switch. Raise MAX_CONNECTIONS for a large switch / high Cloud Run concurrency;
43+
# lower it to protect a small or shared one. Keep MAX_KEEPALIVE_CONNECTIONS
44+
# <= MAX_CONNECTIONS. Configurable via PORTASWITCH_MAX_CONNECTIONS /
45+
# PORTASWITCH_MAX_KEEPALIVE_CONNECTIONS.
46+
MAX_CONNECTIONS: int = 100
47+
MAX_KEEPALIVE_CONNECTIONS: int = 20
3848
SIGNIN_CREDENTIALS: PortaSwitchSignInCredentialsType = PortaSwitchSignInCredentialsType.SELF_CARE
3949
CONTACTS_SELECTING: PortaSwitchContactsSelectingMode = PortaSwitchContactsSelectingMode.ACCOUNTS
4050
CONTACTS_SELECTING_EXTENSION_TYPES: Union[List[PortaSwitchExtensionType], str] = list(PortaSwitchExtensionType)
@@ -60,6 +70,28 @@ def decode_api_timeout(cls, v: Union[str, float, int, None]) -> Optional[float]:
6070
return None
6171
return v if v > 0 else None
6272

73+
@staticmethod
74+
def _positive_int_or(v: Union[str, int, None], default: int) -> int:
75+
# Treat blank/invalid/non-positive as "unset" so a stray empty env var
76+
# (e.g. PORTASWITCH_MAX_CONNECTIONS="") falls back to the safe default.
77+
if v is None or (isinstance(v, str) and not v.strip()):
78+
return default
79+
try:
80+
iv = int(v)
81+
except (TypeError, ValueError):
82+
return default
83+
return iv if iv > 0 else default
84+
85+
@field_validator("MAX_CONNECTIONS", mode='before')
86+
@classmethod
87+
def decode_max_connections(cls, v: Union[str, int, None]) -> int:
88+
return cls._positive_int_or(v, 100)
89+
90+
@field_validator("MAX_KEEPALIVE_CONNECTIONS", mode='before')
91+
@classmethod
92+
def decode_max_keepalive_connections(cls, v: Union[str, int, None]) -> int:
93+
return cls._positive_int_or(v, 20)
94+
6395
@field_validator("CONTACTS_SELECTING_EXTENSION_TYPES", mode='before')
6496
@classmethod
6597
def decode_contacts_selecting_extension_types(cls, v: Union[List, str]) -> List[PortaSwitchExtensionType]:

app/bss/async_http_api.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,25 @@ def build_httpx_timeout(value) -> httpx.Timeout:
7777
_shared_clients_lock = asyncio.Lock()
7878

7979

80-
async def get_shared_async_client(verify: bool) -> httpx.AsyncClient:
80+
async def get_shared_async_client(verify: bool, limits: Optional[httpx.Limits] = None) -> httpx.AsyncClient:
8181
"""Return the process-wide :class:`httpx.AsyncClient` for the given TLS
82-
verify setting, creating it lazily inside the running event loop."""
82+
verify setting, creating it lazily inside the running event loop.
83+
84+
``limits`` (connection-pool sizing) is applied only when the client is first
85+
created for a given verify key; a later caller with different limits reuses
86+
the existing client. In practice all connectors in one deployment pass the
87+
same limits, so this is not a concern.
88+
"""
8389
client = _shared_clients.get(verify)
8490
if client is not None and not client.is_closed:
8591
return client
8692
async with _shared_clients_lock:
8793
client = _shared_clients.get(verify)
8894
if client is None or client.is_closed:
89-
client = httpx.AsyncClient(verify=verify)
95+
kwargs = {"verify": verify}
96+
if limits is not None:
97+
kwargs["limits"] = limits
98+
client = httpx.AsyncClient(**kwargs)
9099
_shared_clients[verify] = client
91100
return client
92101

@@ -119,8 +128,17 @@ def _verify(self) -> bool:
119128
def _request_timeout(self) -> httpx.Timeout:
120129
return build_httpx_timeout(self.DEFAULT_REQUEST_TIMEOUT)
121130

131+
def _limits(self) -> Optional[httpx.Limits]:
132+
# Subclasses set _max_connections / _max_keepalive_connections after
133+
# super().__init__(); returns None to keep httpx's own defaults.
134+
mc = getattr(self, "_max_connections", None)
135+
mk = getattr(self, "_max_keepalive_connections", None)
136+
if mc is None and mk is None:
137+
return None
138+
return httpx.Limits(max_connections=mc, max_keepalive_connections=mk)
139+
122140
async def _client(self) -> httpx.AsyncClient:
123-
return await get_shared_async_client(self._verify())
141+
return await get_shared_async_client(self._verify(), self._limits())
124142

125143
def add_auth_info(self, url: str, request_params: dict,
126144
auth_session: AuthSessionData) -> dict:

0 commit comments

Comments
 (0)