Skip to content

Commit 43fa25f

Browse files
committed
Add HTTP/2 PING keepalive support to prevent idle connection timeouts
Many cloud load balancers and proxies (e.g. AWS Global Accelerator) terminate idle TCP connections after a fixed timeout (often 340s). HTTP/2 PING frames (RFC 9113 §6.7) reset this idle timer, but httpcore does not currently send them. This adds a background thread that sends periodic HTTP/2 PING frames on active connections, configurable via: - `h2_ping_interval` parameter on ConnectionPool / HTTPConnection - `HTTPCORE_H2_PING_INTERVAL` environment variable (seconds) The env var allows enabling PING keepalive without any code changes, which is critical for users of higher-level clients like httpx and the OpenAI Python SDK that don't expose httpcore internals. Closes #1080 Made-with: Cursor
1 parent 10a6582 commit 43fa25f

8 files changed

Lines changed: 535 additions & 0 deletions

File tree

httpcore/_async/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
uds: str | None = None,
4949
network_backend: AsyncNetworkBackend | None = None,
5050
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
51+
h2_ping_interval: float | None = None,
5152
) -> None:
5253
self._origin = origin
5354
self._ssl_context = ssl_context
@@ -57,6 +58,7 @@ def __init__(
5758
self._retries = retries
5859
self._local_address = local_address
5960
self._uds = uds
61+
self._h2_ping_interval = h2_ping_interval
6062

6163
self._network_backend: AsyncNetworkBackend = (
6264
AutoBackend() if network_backend is None else network_backend
@@ -89,6 +91,7 @@ async def handle_async_request(self, request: Request) -> Response:
8991
origin=self._origin,
9092
stream=stream,
9193
keepalive_expiry=self._keepalive_expiry,
94+
h2_ping_interval=self._h2_ping_interval,
9295
)
9396
else:
9497
self._connection = AsyncHTTP11Connection(

httpcore/_async/connection_pool.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __init__(
5959
uds: str | None = None,
6060
network_backend: AsyncNetworkBackend | None = None,
6161
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
62+
h2_ping_interval: float | None = None,
6263
) -> None:
6364
"""
6465
A connection pool for making HTTP requests.
@@ -88,6 +89,10 @@ def __init__(
8889
network_backend: A backend instance to use for handling network I/O.
8990
socket_options: Socket options that have to be included
9091
in the TCP socket when the connection was established.
92+
h2_ping_interval: Interval in seconds between HTTP/2 PING frames
93+
sent to keep connections alive. Set to ``None`` to disable.
94+
Falls back to the ``HTTPCORE_H2_PING_INTERVAL`` environment
95+
variable if not specified.
9196
"""
9297
self._ssl_context = ssl_context
9398
self._proxy = proxy
@@ -114,6 +119,7 @@ def __init__(
114119
AutoBackend() if network_backend is None else network_backend
115120
)
116121
self._socket_options = socket_options
122+
self._h2_ping_interval = h2_ping_interval
117123

118124
# The mutable state on a connection pool is the queue of incoming requests,
119125
# and the set of connections that are servicing those requests.
@@ -176,6 +182,7 @@ def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
176182
uds=self._uds,
177183
network_backend=self._network_backend,
178184
socket_options=self._socket_options,
185+
h2_ping_interval=self._h2_ping_interval,
179186
)
180187

181188
@property

httpcore/_async/http2.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import enum
44
import logging
5+
import os
6+
import threading
57
import time
68
import types
79
import typing
@@ -48,6 +50,7 @@ def __init__(
4850
origin: Origin,
4951
stream: AsyncNetworkStream,
5052
keepalive_expiry: float | None = None,
53+
h2_ping_interval: float | None = None,
5154
):
5255
self._origin = origin
5356
self._network_stream = stream
@@ -64,6 +67,15 @@ def __init__(
6467
self._used_all_stream_ids = False
6568
self._connection_error = False
6669

70+
if h2_ping_interval is not None:
71+
self._h2_ping_interval: float | None = h2_ping_interval
72+
else:
73+
env_val = os.environ.get("HTTPCORE_H2_PING_INTERVAL")
74+
self._h2_ping_interval = float(env_val) if env_val else None
75+
self._ping_thread: threading.Thread | None = None
76+
self._ping_stop = threading.Event()
77+
self._ping_write_lock = threading.Lock()
78+
6779
# Mapping from stream ID to response stream events.
6880
self._events: dict[
6981
int,
@@ -217,6 +229,48 @@ async def _send_connection_init(self, request: Request) -> None:
217229
self._h2_state.increment_flow_control_window(2**24)
218230
await self._write_outgoing_data(request)
219231

232+
if self._h2_ping_interval is not None:
233+
self._start_ping_keepalive()
234+
235+
def _start_ping_keepalive(self) -> None:
236+
self._ping_stop.clear()
237+
self._ping_thread = threading.Thread(
238+
target=self._ping_keepalive_loop, daemon=True
239+
)
240+
self._ping_thread.start()
241+
logger.debug(
242+
"HTTP/2 PING keepalive started (interval=%.0fs)", self._h2_ping_interval
243+
)
244+
245+
def _ping_keepalive_loop(self) -> None:
246+
"""Background thread that sends periodic PING frames via the raw socket."""
247+
assert self._h2_ping_interval is not None
248+
249+
raw_sock = self._network_stream.get_extra_info("socket")
250+
if raw_sock is None:
251+
raw_sock = self._network_stream.get_extra_info("ssl_object")
252+
if raw_sock is None:
253+
logger.debug("HTTP/2 PING keepalive: unable to obtain raw socket, stopping")
254+
return
255+
256+
while not self._ping_stop.wait(self._h2_ping_interval):
257+
try:
258+
if self.is_closed():
259+
break
260+
with self._ping_write_lock:
261+
if self.is_closed():
262+
break
263+
opaque = int(time.monotonic_ns() & 0xFFFFFFFFFFFFFFFF).to_bytes(
264+
8, "big"
265+
)
266+
self._h2_state.ping(opaque)
267+
data_to_send = self._h2_state.data_to_send()
268+
if data_to_send:
269+
raw_sock.sendall(data_to_send)
270+
logger.debug("HTTP/2 PING sent")
271+
except Exception:
272+
break
273+
220274
# Sending the request...
221275

222276
async def _send_request_headers(self, request: Request, stream_id: int) -> None:
@@ -424,6 +478,10 @@ async def _response_closed(self, stream_id: int) -> None:
424478
async def aclose(self) -> None:
425479
# Note that this method unilaterally closes the connection, and does
426480
# not have any kind of locking in place around it.
481+
self._ping_stop.set()
482+
if self._ping_thread is not None:
483+
self._ping_thread.join(timeout=2)
484+
self._ping_thread = None
427485
self._h2_state.close_connection()
428486
self._state = HTTPConnectionState.CLOSED
429487
await self._network_stream.aclose()

httpcore/_sync/connection.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
uds: str | None = None,
4949
network_backend: NetworkBackend | None = None,
5050
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
51+
h2_ping_interval: float | None = None,
5152
) -> None:
5253
self._origin = origin
5354
self._ssl_context = ssl_context
@@ -57,6 +58,7 @@ def __init__(
5758
self._retries = retries
5859
self._local_address = local_address
5960
self._uds = uds
61+
self._h2_ping_interval = h2_ping_interval
6062

6163
self._network_backend: NetworkBackend = (
6264
SyncBackend() if network_backend is None else network_backend
@@ -89,6 +91,7 @@ def handle_request(self, request: Request) -> Response:
8991
origin=self._origin,
9092
stream=stream,
9193
keepalive_expiry=self._keepalive_expiry,
94+
h2_ping_interval=self._h2_ping_interval,
9295
)
9396
else:
9497
self._connection = HTTP11Connection(

httpcore/_sync/connection_pool.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __init__(
5959
uds: str | None = None,
6060
network_backend: NetworkBackend | None = None,
6161
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
62+
h2_ping_interval: float | None = None,
6263
) -> None:
6364
"""
6465
A connection pool for making HTTP requests.
@@ -88,6 +89,10 @@ def __init__(
8889
network_backend: A backend instance to use for handling network I/O.
8990
socket_options: Socket options that have to be included
9091
in the TCP socket when the connection was established.
92+
h2_ping_interval: Interval in seconds between HTTP/2 PING frames
93+
sent to keep connections alive. Set to ``None`` to disable.
94+
Falls back to the ``HTTPCORE_H2_PING_INTERVAL`` environment
95+
variable if not specified.
9196
"""
9297
self._ssl_context = ssl_context
9398
self._proxy = proxy
@@ -114,6 +119,7 @@ def __init__(
114119
SyncBackend() if network_backend is None else network_backend
115120
)
116121
self._socket_options = socket_options
122+
self._h2_ping_interval = h2_ping_interval
117123

118124
# The mutable state on a connection pool is the queue of incoming requests,
119125
# and the set of connections that are servicing those requests.
@@ -176,6 +182,7 @@ def create_connection(self, origin: Origin) -> ConnectionInterface:
176182
uds=self._uds,
177183
network_backend=self._network_backend,
178184
socket_options=self._socket_options,
185+
h2_ping_interval=self._h2_ping_interval,
179186
)
180187

181188
@property

httpcore/_sync/http2.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import enum
44
import logging
5+
import os
6+
import threading
57
import time
68
import types
79
import typing
@@ -48,6 +50,7 @@ def __init__(
4850
origin: Origin,
4951
stream: NetworkStream,
5052
keepalive_expiry: float | None = None,
53+
h2_ping_interval: float | None = None,
5154
):
5255
self._origin = origin
5356
self._network_stream = stream
@@ -64,6 +67,15 @@ def __init__(
6467
self._used_all_stream_ids = False
6568
self._connection_error = False
6669

70+
if h2_ping_interval is not None:
71+
self._h2_ping_interval: float | None = h2_ping_interval
72+
else:
73+
env_val = os.environ.get("HTTPCORE_H2_PING_INTERVAL")
74+
self._h2_ping_interval = float(env_val) if env_val else None
75+
self._ping_thread: threading.Thread | None = None
76+
self._ping_stop = threading.Event()
77+
self._ping_write_lock = threading.Lock()
78+
6779
# Mapping from stream ID to response stream events.
6880
self._events: dict[
6981
int,
@@ -217,6 +229,48 @@ def _send_connection_init(self, request: Request) -> None:
217229
self._h2_state.increment_flow_control_window(2**24)
218230
self._write_outgoing_data(request)
219231

232+
if self._h2_ping_interval is not None:
233+
self._start_ping_keepalive()
234+
235+
def _start_ping_keepalive(self) -> None:
236+
self._ping_stop.clear()
237+
self._ping_thread = threading.Thread(
238+
target=self._ping_keepalive_loop, daemon=True
239+
)
240+
self._ping_thread.start()
241+
logger.debug(
242+
"HTTP/2 PING keepalive started (interval=%.0fs)", self._h2_ping_interval
243+
)
244+
245+
def _ping_keepalive_loop(self) -> None:
246+
"""Background thread that sends periodic PING frames via the raw socket."""
247+
assert self._h2_ping_interval is not None
248+
249+
raw_sock = self._network_stream.get_extra_info("socket")
250+
if raw_sock is None:
251+
raw_sock = self._network_stream.get_extra_info("ssl_object")
252+
if raw_sock is None:
253+
logger.debug("HTTP/2 PING keepalive: unable to obtain raw socket, stopping")
254+
return
255+
256+
while not self._ping_stop.wait(self._h2_ping_interval):
257+
try:
258+
if self.is_closed():
259+
break
260+
with self._ping_write_lock:
261+
if self.is_closed():
262+
break
263+
opaque = int(time.monotonic_ns() & 0xFFFFFFFFFFFFFFFF).to_bytes(
264+
8, "big"
265+
)
266+
self._h2_state.ping(opaque)
267+
data_to_send = self._h2_state.data_to_send()
268+
if data_to_send:
269+
raw_sock.sendall(data_to_send)
270+
logger.debug("HTTP/2 PING sent")
271+
except Exception:
272+
break
273+
220274
# Sending the request...
221275

222276
def _send_request_headers(self, request: Request, stream_id: int) -> None:
@@ -424,6 +478,10 @@ def _response_closed(self, stream_id: int) -> None:
424478
def close(self) -> None:
425479
# Note that this method unilaterally closes the connection, and does
426480
# not have any kind of locking in place around it.
481+
self._ping_stop.set()
482+
if self._ping_thread is not None:
483+
self._ping_thread.join(timeout=2)
484+
self._ping_thread = None
427485
self._h2_state.close_connection()
428486
self._state = HTTPConnectionState.CLOSED
429487
self._network_stream.close()

0 commit comments

Comments
 (0)