Skip to content

Commit 820ce32

Browse files
committed
refactor(client): drop private httpx imports for proxy discovery
connect/client/fluent.py imported `Proxy` from httpx._config and `get_environment_proxies` from httpx._utils. Both are private httpx APIs that can change or vanish on any minor release, and the import happens at module load, so a break would take down the whole client, not just proxy support. Reimplement the environment proxy discovery locally as _get_environment_proxies() on top of stdlib urllib.request.getproxies() and ipaddress, faithfully porting httpx 0.28's algorithm: http/https/all schemes (normalizing bare host:port to a http:// URL) and NO_PROXY handling for domains, IPv4, IPv6, localhost, explicit-scheme hosts, and the NO_PROXY=* global bypass. Use the public httpx.Proxy instead of the private one. Add tests for this previously untested logic (scheme normalization, each NO_PROXY host form, and the wildcard bypass). All 407 tests pass.
1 parent 27c6c0b commit 820ce32

2 files changed

Lines changed: 124 additions & 5 deletions

File tree

connect/client/fluent.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
# Copyright (c) 2025 CloudBlue. All Rights Reserved.
55
#
66
import contextvars
7+
import ipaddress
78
import threading
89
from functools import cache
910
from json.decoder import JSONDecodeError
1011
from typing import Union
12+
from urllib.request import getproxies
1113

1214
import httpx
1315
import requests
14-
from httpx._config import Proxy
15-
from httpx._utils import get_environment_proxies
1616
from requests.adapters import HTTPAdapter
1717

1818
from connect.client.constants import CONNECT_ENDPOINT_URL, CONNECT_SPECS_URL
@@ -240,6 +240,59 @@ def _get_namespace_class(self):
240240
_SSL_CONTEXT = httpx.create_ssl_context()
241241

242242

243+
def _is_ipv4_hostname(hostname):
244+
try:
245+
ipaddress.IPv4Address(hostname.split('/')[0])
246+
except ValueError:
247+
return False
248+
return True
249+
250+
251+
def _is_ipv6_hostname(hostname):
252+
try:
253+
ipaddress.IPv6Address(hostname.split('/')[0])
254+
except ValueError:
255+
return False
256+
return True
257+
258+
259+
def _get_environment_proxies():
260+
"""
261+
Build httpx mount patterns from the standard proxy environment variables
262+
(HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY and lowercase variants).
263+
264+
Ported from httpx's internal ``get_environment_proxies`` so we don't depend
265+
on the private ``httpx._utils`` module.
266+
"""
267+
proxy_info = getproxies()
268+
mounts = {}
269+
270+
for scheme in ('http', 'https', 'all'):
271+
if proxy_info.get(scheme):
272+
hostname = proxy_info[scheme]
273+
mounts[f'{scheme}://'] = hostname if '://' in hostname else f'http://{hostname}'
274+
275+
no_proxy_hosts = [host.strip() for host in proxy_info.get('no', '').split(',')]
276+
for hostname in no_proxy_hosts:
277+
# NO_PROXY=* bypasses every proxy, so ignore all proxy configuration.
278+
if hostname == '*':
279+
return {}
280+
if not hostname:
281+
continue
282+
if '://' in hostname:
283+
mounts[hostname] = None
284+
elif _is_ipv4_hostname(hostname):
285+
mounts[f'all://{hostname}'] = None
286+
elif _is_ipv6_hostname(hostname):
287+
mounts[f'all://[{hostname}]'] = None
288+
elif hostname.lower() == 'localhost':
289+
mounts[f'all://{hostname}'] = None
290+
else:
291+
mounts[f'all://*{hostname}'] = None
292+
293+
return mounts
294+
295+
243296
@cache
244297
def _get_async_mounts():
245298
"""
@@ -249,8 +302,8 @@ def _get_async_mounts():
249302
return {
250303
key: None
251304
if url is None
252-
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=Proxy(url=url))
253-
for key, url in get_environment_proxies().items()
305+
else httpx.AsyncHTTPTransport(verify=_SSL_CONTEXT, proxy=httpx.Proxy(url=url))
306+
for key, url in _get_environment_proxies().items()
254307
}
255308

256309

tests/client/test_fluent.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from requests import RequestException, Timeout
77

88
from connect.client.exceptions import ClientError
9-
from connect.client.fluent import ConnectClient
9+
from connect.client.fluent import ConnectClient, _get_environment_proxies
1010
from connect.client.logger import RequestLogger
1111
from connect.client.models import NS, Collection
1212

@@ -605,3 +605,69 @@ def test_getattr():
605605
assert c.__getattr__('session') == 'mysession'
606606
assert c.__getattr__('response') == 'myresponse'
607607
assert isinstance(c.__getattr__('anything'), Collection)
608+
609+
610+
def test_get_environment_proxies_schemes(mocker):
611+
"""
612+
Verify proxy env vars are turned into the httpx mount patterns the async
613+
client expects, normalizing bare host:port values to a http:// URL.
614+
615+
Confidence: our stdlib reimplementation of httpx's proxy discovery keeps the
616+
same behaviour after dropping the private ``httpx._utils`` import.
617+
"""
618+
mocker.patch(
619+
'connect.client.fluent.getproxies',
620+
return_value={
621+
'http': 'proxy.example.org:8080',
622+
'https': 'http://secure.example.org:8080',
623+
},
624+
)
625+
626+
mounts = _get_environment_proxies()
627+
628+
assert mounts['http://'] == 'http://proxy.example.org:8080'
629+
assert mounts['https://'] == 'http://secure.example.org:8080'
630+
631+
632+
@pytest.mark.parametrize(
633+
('no_proxy_host', 'expected_key'),
634+
(
635+
('localhost', 'all://localhost'),
636+
('.internal', 'all://*.internal'),
637+
('example.com', 'all://*example.com'),
638+
('127.0.0.1', 'all://127.0.0.1'),
639+
('::1', 'all://[::1]'),
640+
('http://direct.example.org', 'http://direct.example.org'),
641+
),
642+
)
643+
def test_get_environment_proxies_no_proxy(mocker, no_proxy_host, expected_key):
644+
"""
645+
Verify each NO_PROXY host form (domain, IPv4, IPv6, localhost, explicit
646+
scheme) maps to the mount pattern that disables proxying for it.
647+
648+
Confidence: hosts meant to bypass the proxy actually do, matching curl/httpx
649+
NO_PROXY semantics.
650+
"""
651+
mocker.patch(
652+
'connect.client.fluent.getproxies',
653+
return_value={'all': 'http://proxy.example.org:8080', 'no': no_proxy_host},
654+
)
655+
656+
mounts = _get_environment_proxies()
657+
658+
assert mounts['all://'] == 'http://proxy.example.org:8080'
659+
assert mounts[expected_key] is None
660+
661+
662+
def test_get_environment_proxies_wildcard_disables_all(mocker):
663+
"""
664+
Verify NO_PROXY=* discards every configured proxy.
665+
666+
Confidence: the global bypass wins over any HTTP_PROXY/ALL_PROXY setting.
667+
"""
668+
mocker.patch(
669+
'connect.client.fluent.getproxies',
670+
return_value={'all': 'http://proxy.example.org:8080', 'no': '*'},
671+
)
672+
673+
assert _get_environment_proxies() == {}

0 commit comments

Comments
 (0)