From d1279878b27b2249183d6a850b2d3b63aeee815e Mon Sep 17 00:00:00 2001 From: drshvik Date: Sat, 31 Jan 2026 03:25:50 +0530 Subject: [PATCH 1/3] Fix: Pass timeout to SOCKS5 handshake to prevent hanging --- httpcore/_async/socks_proxy.py | 43 +++++++--------------------------- httpcore/_sync/socks_proxy.py | 43 +++++++--------------------------- 2 files changed, 18 insertions(+), 68 deletions(-) diff --git a/httpcore/_async/socks_proxy.py b/httpcore/_async/socks_proxy.py index b363f55a..f1f6e822 100644 --- a/httpcore/_async/socks_proxy.py +++ b/httpcore/_async/socks_proxy.py @@ -45,6 +45,7 @@ async def _init_socks5_connection( host: bytes, port: int, auth: tuple[bytes, bytes] | None = None, + timeout: float | None = None, # <--- FIX 1: Add timeout argument ) -> None: conn = socksio.socks5.SOCKS5Connection() @@ -56,10 +57,10 @@ async def _init_socks5_connection( ) conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method])) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 2: Pass timeout # Auth method response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 3: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -75,10 +76,10 @@ async def _init_socks5_connection( username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout # Username/password response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 5: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -91,10 +92,10 @@ async def _init_socks5_connection( ) ) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout # Connect response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 7: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -122,33 +123,6 @@ def __init__( ) -> None: """ A connection pool for making HTTP requests. - - Parameters: - proxy_url: The URL to use when connecting to the proxy server. - For example `"http://127.0.0.1:8080/"`. - ssl_context: An SSL context to use for verifying connections. - If not specified, the default `httpcore.default_ssl_context()` - will be used. - max_connections: The maximum number of concurrent HTTP connections that - the pool should allow. Any attempt to send a request on a pool that - would exceed this amount will block until a connection is available. - max_keepalive_connections: The maximum number of idle HTTP connections - that will be maintained in the pool. - keepalive_expiry: The duration in seconds that an idle HTTP connection - may be maintained for before being expired from the pool. - http1: A boolean indicating if HTTP/1.1 requests should be supported - by the connection pool. Defaults to True. - http2: A boolean indicating if HTTP/2 requests should be supported by - the connection pool. Defaults to False. - retries: The maximum number of retries when trying to establish - a connection. - local_address: Local address to connect from. Can also be used to - connect using a particular address family. Using - `local_address="0.0.0.0"` will connect using an `AF_INET` address - (IPv4), while using `local_address="::"` will connect using an - `AF_INET6` address (IPv6). - uds: Path to a Unix Domain Socket to use instead of TCP sockets. - network_backend: A backend instance to use for handling network I/O. """ super().__init__( ssl_context=ssl_context, @@ -237,6 +211,7 @@ async def handle_async_request(self, request: Request) -> Response: "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, + "timeout": timeout, # <--- FIX 8: Pass timeout argument } async with Trace( "setup_socks5_connection", logger, request, kwargs @@ -338,4 +313,4 @@ def info(self) -> str: return self._connection.info() def __repr__(self) -> str: - return f"<{self.__class__.__name__} [{self.info()}]>" + return f"<{self.__class__.__name__} [{self.info()}]>" \ No newline at end of file diff --git a/httpcore/_sync/socks_proxy.py b/httpcore/_sync/socks_proxy.py index 0ca96ddf..b8b0f9d6 100644 --- a/httpcore/_sync/socks_proxy.py +++ b/httpcore/_sync/socks_proxy.py @@ -45,6 +45,7 @@ def _init_socks5_connection( host: bytes, port: int, auth: tuple[bytes, bytes] | None = None, + timeout: float | None = None, # <--- FIX 1: Add timeout argument ) -> None: conn = socksio.socks5.SOCKS5Connection() @@ -56,10 +57,10 @@ def _init_socks5_connection( ) conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method])) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 2: Pass timeout # Auth method response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 3: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -75,10 +76,10 @@ def _init_socks5_connection( username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout # Username/password response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 5: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -91,10 +92,10 @@ def _init_socks5_connection( ) ) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout # Connect response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 7: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -122,33 +123,6 @@ def __init__( ) -> None: """ A connection pool for making HTTP requests. - - Parameters: - proxy_url: The URL to use when connecting to the proxy server. - For example `"http://127.0.0.1:8080/"`. - ssl_context: An SSL context to use for verifying connections. - If not specified, the default `httpcore.default_ssl_context()` - will be used. - max_connections: The maximum number of concurrent HTTP connections that - the pool should allow. Any attempt to send a request on a pool that - would exceed this amount will block until a connection is available. - max_keepalive_connections: The maximum number of idle HTTP connections - that will be maintained in the pool. - keepalive_expiry: The duration in seconds that an idle HTTP connection - may be maintained for before being expired from the pool. - http1: A boolean indicating if HTTP/1.1 requests should be supported - by the connection pool. Defaults to True. - http2: A boolean indicating if HTTP/2 requests should be supported by - the connection pool. Defaults to False. - retries: The maximum number of retries when trying to establish - a connection. - local_address: Local address to connect from. Can also be used to - connect using a particular address family. Using - `local_address="0.0.0.0"` will connect using an `AF_INET` address - (IPv4), while using `local_address="::"` will connect using an - `AF_INET6` address (IPv6). - uds: Path to a Unix Domain Socket to use instead of TCP sockets. - network_backend: A backend instance to use for handling network I/O. """ super().__init__( ssl_context=ssl_context, @@ -237,6 +211,7 @@ def handle_request(self, request: Request) -> Response: "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, + "timeout": timeout, # <--- FIX 8: Pass timeout to handshake } with Trace( "setup_socks5_connection", logger, request, kwargs @@ -338,4 +313,4 @@ def info(self) -> str: return self._connection.info() def __repr__(self) -> str: - return f"<{self.__class__.__name__} [{self.info()}]>" + return f"<{self.__class__.__name__} [{self.info()}]>" \ No newline at end of file From 3d39b823c07bf79bf7c04dda2f1e4e167b19d83d Mon Sep 17 00:00:00 2001 From: drshvik Date: Sat, 31 Jan 2026 03:47:38 +0530 Subject: [PATCH 2/3] Fix: Apply ruff formatting --- httpcore/_async/socks_proxy.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/httpcore/_async/socks_proxy.py b/httpcore/_async/socks_proxy.py index f1f6e822..cb77660d 100644 --- a/httpcore/_async/socks_proxy.py +++ b/httpcore/_async/socks_proxy.py @@ -57,10 +57,12 @@ async def _init_socks5_connection( ) conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method])) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 2: Pass timeout + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 2: Pass timeout # Auth method response - incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 3: Pass timeout + incoming_bytes = await stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 3: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -76,10 +78,12 @@ async def _init_socks5_connection( username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout # Username/password response - incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 5: Pass timeout + incoming_bytes = await stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 5: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -92,10 +96,12 @@ async def _init_socks5_connection( ) ) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout + await stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout # Connect response - incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 7: Pass timeout + incoming_bytes = await stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 7: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -211,7 +217,7 @@ async def handle_async_request(self, request: Request) -> Response: "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, - "timeout": timeout, # <--- FIX 8: Pass timeout argument + "timeout": timeout, # <--- FIX 8: Pass timeout argument } async with Trace( "setup_socks5_connection", logger, request, kwargs @@ -313,4 +319,4 @@ def info(self) -> str: return self._connection.info() def __repr__(self) -> str: - return f"<{self.__class__.__name__} [{self.info()}]>" \ No newline at end of file + return f"<{self.__class__.__name__} [{self.info()}]>" From 794bc961df68a6f14ebad5564700ad073577f302 Mon Sep 17 00:00:00 2001 From: drshvik Date: Sat, 31 Jan 2026 03:51:21 +0530 Subject: [PATCH 3/3] Fix: Run unasync and ruff formatting --- httpcore/_sync/socks_proxy.py | 20 ++++++---- reproduce_httpcore.py | 63 ++++++++++++++++++++++++++++++++ reproduce_httpcore_async.py | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 reproduce_httpcore.py create mode 100644 reproduce_httpcore_async.py diff --git a/httpcore/_sync/socks_proxy.py b/httpcore/_sync/socks_proxy.py index b8b0f9d6..4f89aca0 100644 --- a/httpcore/_sync/socks_proxy.py +++ b/httpcore/_sync/socks_proxy.py @@ -60,7 +60,9 @@ def _init_socks5_connection( stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 2: Pass timeout # Auth method response - incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 3: Pass timeout + incoming_bytes = stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 3: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -76,10 +78,12 @@ def _init_socks5_connection( username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout + stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 4: Pass timeout # Username/password response - incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 5: Pass timeout + incoming_bytes = stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 5: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -92,10 +96,12 @@ def _init_socks5_connection( ) ) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout + stream.write(outgoing_bytes, timeout=timeout) # <--- FIX 6: Pass timeout # Connect response - incoming_bytes = stream.read(max_bytes=4096, timeout=timeout) # <--- FIX 7: Pass timeout + incoming_bytes = stream.read( + max_bytes=4096, timeout=timeout + ) # <--- FIX 7: Pass timeout response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -211,7 +217,7 @@ def handle_request(self, request: Request) -> Response: "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, - "timeout": timeout, # <--- FIX 8: Pass timeout to handshake + "timeout": timeout, # <--- FIX 8: Pass timeout argument } with Trace( "setup_socks5_connection", logger, request, kwargs @@ -313,4 +319,4 @@ def info(self) -> str: return self._connection.info() def __repr__(self) -> str: - return f"<{self.__class__.__name__} [{self.info()}]>" \ No newline at end of file + return f"<{self.__class__.__name__} [{self.info()}]>" diff --git a/reproduce_httpcore.py b/reproduce_httpcore.py new file mode 100644 index 00000000..be45af37 --- /dev/null +++ b/reproduce_httpcore.py @@ -0,0 +1,63 @@ +import httpcore +import socket +import threading +import time + +# --- Same Server Setup as before --- +TIMEOUT = 2.0 +HANG_TIME = 20 + +def get_free_port(): + with socket.socket() as s: + s.bind(('', 0)) + return s.getsockname()[1] + +def blackhole_proxy_server(port, stop_event): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(('127.0.0.1', port)) + server.listen(1) + server.settimeout(1.0) + while not stop_event.is_set(): + try: + client, _ = server.accept() + time.sleep(HANG_TIME) # Hang the handshake + client.close() + except socket.timeout: continue + except: break + server.close() + +# --- The Test --- +def run_test(): + proxy_port = get_free_port() + stop_event = threading.Event() + t = threading.Thread(target=blackhole_proxy_server, args=(proxy_port, stop_event)) + t.start() + time.sleep(0.5) + + print(f"[*] Testing httpcore SOCKSProxy with {TIMEOUT}s timeout...") + start_time = time.time() + + # We use the low-level SOCKSProxy directly + with httpcore.SOCKSProxy( + proxy_url=f"socks5://127.0.0.1:{proxy_port}" + ) as pool: + try: + # We assume httpcore 1.0+ style request + pool.request( + "GET", + "http://example.com", + extensions={'timeout': {'connect': TIMEOUT, 'read': TIMEOUT}} + ) + except httpcore.TimeoutException: + print("[SUCCESS] Caught timeout correctly!") + except Exception as e: + print(f"[ERROR] {e}") + finally: + duration = time.time() - start_time + print(f"[*] Duration: {duration:.2f}s") + stop_event.set() + t.join() + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/reproduce_httpcore_async.py b/reproduce_httpcore_async.py new file mode 100644 index 00000000..3e577cfe --- /dev/null +++ b/reproduce_httpcore_async.py @@ -0,0 +1,69 @@ +import httpcore +import socket +import threading +import time +import asyncio + +# --- Server Setup (Same as before) --- +HANG_TIME = 20 +TIMEOUT = 2.0 + +def get_free_port(): + with socket.socket() as s: + s.bind(('', 0)) + return s.getsockname()[1] + +def blackhole_proxy_server(port, stop_event): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(('127.0.0.1', port)) + server.listen(1) + server.settimeout(1.0) + + while not stop_event.is_set(): + try: + client, _ = server.accept() + # print("[Server] Accepted connection, sleeping...") + time.sleep(HANG_TIME) + client.close() + except socket.timeout: continue + except: break + server.close() + +# --- Async Test --- +async def run_async_test(port): + print(f"[*] Testing ASYNC httpcore SOCKSProxy with {TIMEOUT}s timeout...") + start_time = time.time() + + async with httpcore.AsyncSOCKSProxy( + proxy_url=f"socks5://127.0.0.1:{port}" + ) as pool: + try: + await pool.request( + "GET", + "http://example.com", + extensions={'timeout': {'connect': TIMEOUT, 'read': TIMEOUT}} + ) + except httpcore.TimeoutException: + print("[SUCCESS] Caught timeout correctly!") + except Exception as e: + print(f"[ERROR] {type(e).__name__}: {e}") + finally: + duration = time.time() - start_time + print(f"[*] Duration: {duration:.2f}s") + +def main(): + port = get_free_port() + stop_event = threading.Event() + t = threading.Thread(target=blackhole_proxy_server, args=(port, stop_event)) + t.start() + time.sleep(0.5) + + try: + asyncio.run(run_async_test(port)) + finally: + stop_event.set() + t.join() + +if __name__ == "__main__": + main() \ No newline at end of file