Skip to content

Commit 7fc11ba

Browse files
Reflexcursoragent
andcommitted
fix: open extra H2 conns under stream pressure; isolate file transfers on HTTP/1.1
httpcore blocks on a per-connection stream semaphore once slots fill instead of opening another connection. Patch is_available + non-blocking acquire so the pool spreads load. Route upload_file/download_file through a dedicated HTTP/1.1 pool so bulk bodies cannot starve latency-sensitive RPCs on the shared H2 session. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6b9e1dc commit 7fc11ba

6 files changed

Lines changed: 512 additions & 7 deletions

File tree

src/runloop_api_client/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,13 @@
2828
APIResponseValidationError,
2929
)
3030
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
31+
from ._http2_pool_fix import install as _install_http2_pool_fix
3132
from ._utils._logs import setup_logging as _setup_logging
3233

34+
# Open additional HTTP/2 connections when a connection's stream slots are full
35+
# instead of blocking on httpcore's per-connection stream semaphore.
36+
_install_http2_pool_fix()
37+
3338
__all__ = [
3439
"types",
3540
"__version__",

src/runloop_api_client/_base_client.py

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,21 @@ async def aclose(self) -> None:
174174
weakref.WeakKeyDictionary()
175175
)
176176

177+
# Separate HTTP/1.1 pool for bulk file upload/download. Keeping these off the
178+
# main HTTP/2 connection avoids H2 session-window / stream-slot contention with
179+
# latency-sensitive RPCs (create, wait_for_status, execute, …).
180+
_shared_sync_transfer_transport: _SharedTransport | None = None
181+
_shared_async_transfer_transports: weakref.WeakKeyDictionary[
182+
asyncio.AbstractEventLoop, _SharedAsyncTransport
183+
] = weakref.WeakKeyDictionary()
184+
185+
# Paths that carry large request/response bodies and should use the transfer pool.
186+
_FILE_TRANSFER_PATH_SUFFIXES = ("/upload_file", "/download_file")
187+
188+
189+
def _is_file_transfer_path(path: str) -> bool:
190+
return path.endswith(_FILE_TRANSFER_PATH_SUFFIXES)
191+
177192
# TODO: make base page type vars covariant
178193
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
179194
AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]")
@@ -929,8 +944,10 @@ def __del__(self) -> None:
929944

930945
class SyncAPIClient(BaseClient[httpx.Client, Stream[Any]]):
931946
_client: httpx.Client
947+
_transfer_client: httpx.Client | None
932948
_default_stream_cls: type[Stream[Any]] | None = None
933949
_uses_shared_pool: bool
950+
_isolate_file_transfers: bool
934951
_closed: bool
935952

936953
def __init__(
@@ -976,6 +993,9 @@ def __init__(
976993
)
977994

978995
self._closed = False
996+
self._transfer_client = None
997+
# Custom http_client owns the full transport stack; don't invent a sibling pool.
998+
self._isolate_file_transfers = http_client is None
979999

9801000
if http_client is not None:
9811001
self._client = http_client
@@ -1000,6 +1020,38 @@ def __init__(
10001020
)
10011021
self._uses_shared_pool = False
10021022

1023+
def _ensure_transfer_client(self) -> httpx.Client:
1024+
"""Lazy HTTP/1.1 client for upload_file / download_file."""
1025+
if self._transfer_client is not None:
1026+
return self._transfer_client
1027+
1028+
timeout = cast(Timeout, self.timeout)
1029+
if self._uses_shared_pool:
1030+
global _shared_sync_transfer_transport
1031+
with _pool_lock:
1032+
if _shared_sync_transfer_transport is None or not _shared_sync_transfer_transport.acquire():
1033+
_shared_sync_transfer_transport = _SharedTransport(
1034+
httpx.HTTPTransport(limits=DEFAULT_CONNECTION_LIMITS, http2=False),
1035+
)
1036+
self._transfer_client = SyncHttpxClientWrapper(
1037+
base_url=self._base_url,
1038+
timeout=timeout,
1039+
transport=_shared_sync_transfer_transport,
1040+
http2=False,
1041+
)
1042+
else:
1043+
self._transfer_client = SyncHttpxClientWrapper(
1044+
base_url=self._base_url,
1045+
timeout=timeout,
1046+
http2=False,
1047+
)
1048+
return self._transfer_client
1049+
1050+
def _send_client_for_request(self, request: httpx.Request) -> httpx.Client:
1051+
if self._isolate_file_transfers and _is_file_transfer_path(request.url.path):
1052+
return self._ensure_transfer_client()
1053+
return self._client
1054+
10031055
def is_closed(self) -> bool:
10041056
return self._closed or self._client.is_closed
10051057

@@ -1014,6 +1066,10 @@ def close(self) -> None:
10141066
return
10151067
self._closed = True
10161068
self._client.close()
1069+
transfer = self._transfer_client
1070+
self._transfer_client = None
1071+
if transfer is not None:
1072+
transfer.close()
10171073

10181074
def __enter__(self: _T) -> _T:
10191075
return self
@@ -1114,7 +1170,7 @@ def request(
11141170

11151171
response = None
11161172
try:
1117-
response = self._client.send(
1173+
response = self._send_client_for_request(request).send(
11181174
request,
11191175
stream=stream or self._should_stream_response_body(request=request),
11201176
**kwargs,
@@ -1561,8 +1617,10 @@ def __del__(self) -> None:
15611617

15621618
class AsyncAPIClient(BaseClient[httpx.AsyncClient, AsyncStream[Any]]):
15631619
_client: httpx.AsyncClient
1620+
_transfer_client: httpx.AsyncClient | None
15641621
_default_stream_cls: type[AsyncStream[Any]] | None = None
15651622
_uses_shared_pool: bool
1623+
_isolate_file_transfers: bool
15661624
_closed: bool
15671625

15681626
def __init__(
@@ -1608,6 +1666,9 @@ def __init__(
16081666
)
16091667

16101668
self._closed = False
1669+
self._transfer_client = None
1670+
# Custom http_client owns the full transport stack; don't invent a sibling pool.
1671+
self._isolate_file_transfers = http_client is None
16111672

16121673
if http_client is not None:
16131674
self._client = http_client
@@ -1646,6 +1707,47 @@ def __init__(
16461707
)
16471708
self._uses_shared_pool = False
16481709

1710+
def _ensure_transfer_client(self) -> httpx.AsyncClient:
1711+
"""Lazy HTTP/1.1 client for upload_file / download_file."""
1712+
if self._transfer_client is not None:
1713+
return self._transfer_client
1714+
1715+
timeout = cast(Timeout, self.timeout)
1716+
if self._uses_shared_pool:
1717+
try:
1718+
loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
1719+
except RuntimeError:
1720+
loop = None
1721+
if loop is not None:
1722+
with _pool_lock:
1723+
existing = _shared_async_transfer_transports.get(loop)
1724+
if existing is not None and existing.acquire():
1725+
transport: _SharedAsyncTransport = existing
1726+
else:
1727+
transport = _SharedAsyncTransport(
1728+
httpx.AsyncHTTPTransport(limits=DEFAULT_CONNECTION_LIMITS, http2=False),
1729+
)
1730+
_shared_async_transfer_transports[loop] = transport
1731+
self._transfer_client = AsyncHttpxClientWrapper(
1732+
base_url=self._base_url,
1733+
timeout=timeout,
1734+
transport=transport,
1735+
http2=False,
1736+
)
1737+
return self._transfer_client
1738+
1739+
self._transfer_client = AsyncHttpxClientWrapper(
1740+
base_url=self._base_url,
1741+
timeout=timeout,
1742+
http2=False,
1743+
)
1744+
return self._transfer_client
1745+
1746+
def _send_client_for_request(self, request: httpx.Request) -> httpx.AsyncClient:
1747+
if self._isolate_file_transfers and _is_file_transfer_path(request.url.path):
1748+
return self._ensure_transfer_client()
1749+
return self._client
1750+
16491751
def is_closed(self) -> bool:
16501752
return self._closed or self._client.is_closed
16511753

@@ -1660,6 +1762,10 @@ async def close(self) -> None:
16601762
return
16611763
self._closed = True
16621764
await self._client.aclose()
1765+
transfer = self._transfer_client
1766+
self._transfer_client = None
1767+
if transfer is not None:
1768+
await transfer.aclose()
16631769

16641770
async def __aenter__(self: _T) -> _T:
16651771
return self
@@ -1765,7 +1871,7 @@ async def request(
17651871

17661872
response = None
17671873
try:
1768-
response = await self._client.send(
1874+
response = await self._send_client_for_request(request).send(
17691875
request,
17701876
stream=stream or self._should_stream_response_body(request=request),
17711877
**kwargs,
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Make httpcore open additional HTTP/2 connections when stream slots run low.
2+
3+
httpcore multiplexes HTTP/2 on a single connection and gates new streams with a
4+
semaphore (default MAX_CONCURRENT_STREAMS=100). Its connection pool's
5+
``is_available()`` ignores that limit, so once stream slots are exhausted,
6+
further requests *block* on the semaphore instead of the pool opening another
7+
connection — even when ``max_connections`` still has headroom.
8+
9+
Additionally, the pool may assign a *burst* of requests to a connection that
10+
only has a few free slots left (``is_available()`` is checked once per request
11+
but slots are not reserved). Winners then share an overloaded connection while
12+
the rest raise ``ConnectionNotAvailable`` and retry.
13+
14+
This module patches sync + async HTTP/2 connections so that:
15+
16+
1. ``is_available()`` is False when free stream slots are below a headroom
17+
threshold, so the pool prefers opening/reusing another connection.
18+
2. Stream-slot acquire is non-blocking; if a race still over-assigns, we raise
19+
``ConnectionNotAvailable`` and the pool retries on another connection.
20+
21+
Idempotent: safe to call ``install()`` more than once.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import logging
27+
from typing import Any, Callable
28+
29+
logger = logging.getLogger("runloop_api_client._http2_pool_fix")
30+
31+
# If a connection has fewer free stream slots than this, treat it as unavailable
32+
# so the pool opens another connection instead of stampeding the remainder.
33+
# Must be >1: the pool assigns many queued requests to one "available" conn
34+
# without reserving slots, so small remaining capacity still over-assigns.
35+
_MIN_FREE_STREAM_SLOTS = 16
36+
37+
_installed = False
38+
39+
40+
def _free_stream_slots(connection: Any) -> int | None:
41+
"""Return free H2 stream slots, or None if unknown."""
42+
max_streams = getattr(connection, "_max_streams", None)
43+
events = getattr(connection, "_events", None)
44+
if not isinstance(max_streams, int) or max_streams <= 0 or events is None:
45+
return None
46+
return max_streams - len(events)
47+
48+
49+
def _stream_slots_saturated(connection: Any) -> bool:
50+
"""True when the connection should not accept more streams."""
51+
free = _free_stream_slots(connection)
52+
if free is None:
53+
return False
54+
return free < _MIN_FREE_STREAM_SLOTS
55+
56+
57+
def _patch_is_available(cls: type) -> None:
58+
if getattr(cls.is_available, "_runloop_stream_overflow_patched", False):
59+
return
60+
61+
original: Callable[[Any], bool] = cls.is_available
62+
63+
def is_available(self: Any) -> bool:
64+
if not original(self):
65+
return False
66+
return not _stream_slots_saturated(self)
67+
68+
is_available._runloop_stream_overflow_patched = True # type: ignore[attr-defined]
69+
cls.is_available = is_available # type: ignore[method-assign]
70+
71+
72+
def _make_async_nonblocking_acquire(sem: Any, connection_not_available: type) -> Callable[[], Any]:
73+
original_acquire = sem.acquire
74+
75+
async def acquire() -> None:
76+
if not getattr(sem, "_backend", ""):
77+
sem.setup()
78+
79+
if sem._backend == "asyncio":
80+
inner = sem._anyio_semaphore
81+
try:
82+
inner.acquire_nowait()
83+
return
84+
except Exception:
85+
raise connection_not_available(
86+
"HTTP/2 connection has no free stream slots"
87+
) from None
88+
89+
if sem._backend == "trio":
90+
inner = sem._trio_semaphore
91+
acquire_nowait = getattr(inner, "acquire_nowait", None)
92+
if acquire_nowait is not None:
93+
try:
94+
acquire_nowait()
95+
return
96+
except Exception:
97+
raise connection_not_available(
98+
"HTTP/2 connection has no free stream slots"
99+
) from None
100+
101+
await original_acquire()
102+
103+
return acquire
104+
105+
106+
def _make_sync_nonblocking_acquire(sem: Any, connection_not_available: type) -> Callable[[], None]:
107+
inner = getattr(sem, "_semaphore", sem)
108+
109+
def acquire() -> None:
110+
ok = inner.acquire(False)
111+
if not ok:
112+
raise connection_not_available("HTTP/2 connection has no free stream slots")
113+
114+
return acquire
115+
116+
117+
def _patch_async_connection(cls: type) -> None:
118+
from httpcore import ConnectionNotAvailable
119+
120+
if getattr(cls.handle_async_request, "_runloop_stream_overflow_patched", False):
121+
return
122+
123+
original = cls.handle_async_request
124+
125+
async def handle_async_request(self: Any, request: Any) -> Any:
126+
sem = getattr(self, "_max_streams_semaphore", None)
127+
if sem is None:
128+
return await original(self, request)
129+
130+
real_acquire = sem.acquire
131+
sem.acquire = _make_async_nonblocking_acquire(sem, ConnectionNotAvailable)
132+
try:
133+
return await original(self, request)
134+
finally:
135+
sem.acquire = real_acquire
136+
137+
handle_async_request._runloop_stream_overflow_patched = True # type: ignore[attr-defined]
138+
cls.handle_async_request = handle_async_request # type: ignore[method-assign]
139+
140+
141+
def _patch_sync_connection(cls: type) -> None:
142+
from httpcore import ConnectionNotAvailable
143+
144+
if getattr(cls.handle_request, "_runloop_stream_overflow_patched", False):
145+
return
146+
147+
original = cls.handle_request
148+
149+
def handle_request(self: Any, request: Any) -> Any:
150+
sem = getattr(self, "_max_streams_semaphore", None)
151+
if sem is None:
152+
return original(self, request)
153+
154+
real_acquire = sem.acquire
155+
sem.acquire = _make_sync_nonblocking_acquire(sem, ConnectionNotAvailable)
156+
try:
157+
return original(self, request)
158+
finally:
159+
sem.acquire = real_acquire
160+
161+
handle_request._runloop_stream_overflow_patched = True # type: ignore[attr-defined]
162+
cls.handle_request = handle_request # type: ignore[method-assign]
163+
164+
165+
def install() -> bool:
166+
"""Patch httpcore HTTP/2 connections. Returns True if a patch was applied."""
167+
global _installed
168+
if _installed:
169+
return False
170+
171+
try:
172+
from httpcore._async.http2 import AsyncHTTP2Connection
173+
from httpcore._sync.http2 import HTTP2Connection
174+
except ImportError as exc: # pragma: no cover
175+
logger.warning("http2 stream-overflow patch skipped: %s", exc)
176+
return False
177+
178+
_patch_is_available(AsyncHTTP2Connection)
179+
_patch_is_available(HTTP2Connection)
180+
_patch_async_connection(AsyncHTTP2Connection)
181+
_patch_sync_connection(HTTP2Connection)
182+
183+
_installed = True
184+
logger.debug("installed httpcore HTTP/2 stream-overflow connection patch")
185+
return True

0 commit comments

Comments
 (0)