Skip to content

Commit b2f2595

Browse files
SNOW-694457: no_proxy support
1 parent 450b023 commit b2f2595

File tree

5 files changed

+277
-2
lines changed

5 files changed

+277
-2
lines changed

src/snowflake/connector/connection.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ def _get_private_bytes_from_file(
199199
"proxy_port": (None, (type(None), str)), # snowflake
200200
"proxy_user": (None, (type(None), str)), # snowflake
201201
"proxy_password": (None, (type(None), str)), # snowflake
202+
"no_proxy": (
203+
None,
204+
(type(None), str, Iterable),
205+
), # hosts/ips to bypass proxy (str or iterable)
202206
"protocol": ("https", str), # snowflake
203207
"warehouse": (None, (type(None), str)), # snowflake
204208
"region": (None, (type(None), str)), # snowflake
@@ -1076,6 +1080,15 @@ def connect(self, **kwargs) -> None:
10761080
proxy_port=self.proxy_port,
10771081
proxy_user=self.proxy_user,
10781082
proxy_password=self.proxy_password,
1083+
no_proxy=(
1084+
",".join(str(x) for x in self.no_proxy)
1085+
if (
1086+
self.no_proxy is not None
1087+
and isinstance(self.no_proxy, Iterable)
1088+
and not isinstance(self.no_proxy, (str, bytes))
1089+
)
1090+
else self.no_proxy
1091+
),
10791092
)
10801093
self._session_manager = SessionManager(self._http_config)
10811094

src/snowflake/connector/session_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ class BaseHttpConfig:
134134
proxy_port: str | None = None
135135
proxy_user: str | None = None
136136
proxy_password: str | None = None
137+
no_proxy: str | None = None
137138

138139
def copy_with(self, **overrides: Any) -> BaseHttpConfig:
139140
"""Return a new config with overrides applied."""
@@ -477,7 +478,11 @@ def _mount_adapters(self, session: requests.Session) -> None:
477478
def make_session(self) -> Session:
478479
session = requests.Session()
479480
self._mount_adapters(session)
480-
session.proxies = {"http": self.proxy_url, "https": self.proxy_url}
481+
session.proxies = {
482+
"http": self.proxy_url,
483+
"https": self.proxy_url,
484+
"no_proxy": self._cfg.no_proxy,
485+
}
481486
return session
482487

483488
@contextlib.contextmanager

test/test_utils/cross_module_fixtures/wiremock_fixtures.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
import snowflake.connector
1010

11-
from ..wiremock.wiremock_utils import WiremockClient, get_clients_for_proxy_and_target
11+
from ..wiremock.wiremock_utils import (
12+
WiremockClient,
13+
get_clients_for_proxy_and_target,
14+
get_clients_for_proxy_target_and_storage,
15+
)
1216

1317

1418
@pytest.fixture(scope="session")
@@ -71,6 +75,7 @@ def wiremock_target_proxy_pair(wiremock_generic_mappings_dir):
7175
"""Starts a *target* Wiremock and a *proxy* Wiremock pre-configured to forward to it.
7276
7377
The fixture yields a tuple ``(target_wm, proxy_wm)`` of ``WiremockClient``
78+
where the order is (backend, proxy).
7479
instances. It is a thin wrapper around
7580
``test.test_utils.wiremock.wiremock_utils.proxy_target_pair``.
7681
"""
@@ -81,3 +86,36 @@ def wiremock_target_proxy_pair(wiremock_generic_mappings_dir):
8186
proxy_mapping_template=wiremock_proxy_mapping_path
8287
) as pair:
8388
yield pair
89+
90+
91+
@pytest.fixture
92+
def wiremock_three_clients(wiremock_generic_mappings_dir):
93+
"""Starts target (DB), storage (S3), and proxy Wiremocks using shared helper.
94+
95+
Returns (target_wm, storage_wm, proxy_wm) in that order.
96+
97+
Deprecated: Prefer ``wiremock_backend_storage_proxy`` for clearer naming.
98+
"""
99+
wiremock_proxy_mapping_path = (
100+
wiremock_generic_mappings_dir / "proxy_forward_all.json"
101+
)
102+
with get_clients_for_proxy_target_and_storage(
103+
proxy_mapping_template=wiremock_proxy_mapping_path
104+
) as triple:
105+
yield triple
106+
107+
108+
@pytest.fixture
109+
def wiremock_backend_storage_proxy(wiremock_generic_mappings_dir):
110+
"""Starts backend (DB), storage (S3), and proxy Wiremocks.
111+
112+
Returns a tuple ``(backend_wm, storage_wm, proxy_wm)`` to make roles explicit.
113+
Use when backend and storage must have distinct host:ports (e.g., selective proxy bypass).
114+
"""
115+
wiremock_proxy_mapping_path = (
116+
wiremock_generic_mappings_dir / "proxy_forward_all.json"
117+
)
118+
with get_clients_for_proxy_target_and_storage(
119+
proxy_mapping_template=wiremock_proxy_mapping_path
120+
) as triple:
121+
yield triple

test/test_utils/wiremock/wiremock_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,46 @@ def get_clients_for_proxy_and_target(
345345

346346
# Yield control back to the caller with both Wiremocks ready
347347
yield target_wm, proxy_wm
348+
349+
350+
@contextmanager
351+
def get_clients_for_proxy_target_and_storage(
352+
proxy_mapping_template: Union[str, dict, pathlib.Path, None] = None,
353+
additional_proxy_placeholders: Optional[dict[str, object]] = None,
354+
additional_proxy_args: Optional[Iterable[str]] = None,
355+
):
356+
"""Context manager that starts three Wiremock instances – *target* (DB), *storage* (S3), and *proxy*.
357+
358+
The *proxy* is configured to forward all traffic to *target* using the same
359+
mapping mechanism as ``get_clients_for_proxy_and_target``.
360+
361+
Yields a tuple ``(target_wm, storage_wm, proxy_wm)``. All processes are shut down
362+
automatically on context exit.
363+
364+
Note:
365+
In most tests a single Wiremock instance is sufficient to emulate both backend
366+
and storage endpoints. Use this helper only when backend and storage must have
367+
distinct addresses (host:port) — for example, to validate that NO_PROXY bypasses
368+
the proxy for one service while proxying the other.
369+
"""
370+
# Reuse existing helper to set up target+proxy
371+
if proxy_mapping_template is None:
372+
proxy_mapping_template = (
373+
pathlib.Path(__file__).parent.parent.parent.parent
374+
/ "test"
375+
/ "data"
376+
/ "wiremock"
377+
/ "mappings"
378+
/ "generic"
379+
/ "proxy_forward_all.json"
380+
)
381+
382+
with get_clients_for_proxy_and_target(
383+
proxy_mapping_template=proxy_mapping_template,
384+
additional_proxy_placeholders=additional_proxy_placeholders,
385+
additional_proxy_args=additional_proxy_args,
386+
) as (target_wm, proxy_wm):
387+
# Start storage with a port distinct from target and proxy
388+
forbidden = [target_wm.wiremock_http_port, proxy_wm.wiremock_http_port]
389+
with WiremockClient(forbidden_ports=forbidden) as storage_wm:
390+
yield target_wm, storage_wm, proxy_wm

test/unit/test_proxies.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import logging
55
import unittest.mock
6+
from collections import deque
67

78
import pytest
89

@@ -160,3 +161,178 @@ def test_basic_query_through_proxy(
160161
"/queries/v1/query-request" in r["request"]["url"]
161162
for r in target_reqs["requests"]
162163
)
164+
165+
166+
"""
167+
Note: The single-target no_proxy test was removed in favor of
168+
test_no_proxy_multiple_hosts_and_ports, which validates both backend and storage
169+
paths and multiple no_proxy entries using shared session manager logic.
170+
"""
171+
172+
173+
@pytest.mark.skipolddriver
174+
@pytest.mark.parametrize(
175+
"no_proxy_factory,expect_backend_proxy,expect_storage_proxy",
176+
[
177+
(lambda target, storage: [f"localhost:{target}"], False, True),
178+
(lambda target, storage: [f"localhost:{storage}"], True, False),
179+
(
180+
lambda target, storage: [f"localhost:{target}", f"localhost:{storage}"],
181+
False,
182+
False,
183+
),
184+
(
185+
lambda target, storage: ["localhost"],
186+
False,
187+
False,
188+
), # host-only bypasses both
189+
# Tuple and set variants
190+
(lambda target, storage: (f"localhost:{target}",), False, True),
191+
(lambda target, storage: {f"localhost:{storage}"}, True, False),
192+
(
193+
lambda target, storage: frozenset(
194+
{f"localhost:{target}", f"localhost:{storage}"}
195+
),
196+
False,
197+
False,
198+
),
199+
# Deque (generic iterable) variant
200+
(
201+
lambda target, storage: deque(
202+
[f"localhost:{target}", f"localhost:{storage}"]
203+
),
204+
False,
205+
False,
206+
),
207+
# One long CSV string with many irrelevant entries around both target and storage
208+
(
209+
lambda target, storage: (
210+
"foo.invalid:1,bar.invalid:2,"
211+
f"localhost:{target},"
212+
"baz.invalid:3,qux.invalid:4,"
213+
f"localhost:{storage},"
214+
"zoo.invalid:5"
215+
),
216+
False,
217+
False,
218+
),
219+
],
220+
)
221+
@pytest.mark.parametrize("proxy_method", ["explicit_args", "env_vars"])
222+
def test_no_proxy_multiple_hosts_and_ports(
223+
wiremock_backend_storage_proxy,
224+
wiremock_generic_mappings_dir,
225+
wiremock_mapping_dir,
226+
proxy_env_vars,
227+
proxy_method,
228+
no_proxy_factory,
229+
expect_backend_proxy,
230+
expect_storage_proxy,
231+
):
232+
target_wm, storage_wm, proxy_wm = wiremock_backend_storage_proxy
233+
234+
# Configure DB and storage mappings (no Via header assertion; we check journals)
235+
password_mapping = wiremock_mapping_dir / "auth/password/successful_flow.json"
236+
multi_chunk_request_mapping = (
237+
wiremock_mapping_dir / "queries/select_large_request_successful.json"
238+
)
239+
chunk_1_mapping = wiremock_mapping_dir / "queries/chunk_1.json"
240+
chunk_2_mapping = wiremock_mapping_dir / "queries/chunk_2.json"
241+
disconnect_mapping = (
242+
wiremock_generic_mappings_dir / "snowflake_disconnect_successful.json"
243+
)
244+
telemetry_mapping = wiremock_generic_mappings_dir / "telemetry.json"
245+
246+
# Import login, disconnect, telemetry on backend
247+
target_wm.import_mapping_with_default_placeholders(password_mapping)
248+
target_wm.add_mapping(disconnect_mapping)
249+
target_wm.add_mapping(telemetry_mapping)
250+
251+
# Add multi-chunk query response on backend, but point chunk URLs to storage host
252+
target_wm.add_mapping(
253+
multi_chunk_request_mapping,
254+
placeholders={
255+
"{{WIREMOCK_HTTP_HOST_WITH_PORT}}": storage_wm.http_host_with_port
256+
},
257+
)
258+
259+
# Add chunk GET mappings to storage
260+
storage_wm.add_mapping_with_default_placeholders(chunk_1_mapping)
261+
storage_wm.add_mapping_with_default_placeholders(chunk_2_mapping)
262+
263+
# Configure proxy env/args
264+
set_proxy_env_vars, clear_proxy_env_vars = proxy_env_vars
265+
connect_kwargs = {
266+
"user": "testUser",
267+
"password": "testPassword",
268+
"account": "testAccount",
269+
"host": target_wm.wiremock_host,
270+
"port": target_wm.wiremock_http_port,
271+
"protocol": "http",
272+
"warehouse": "TEST_WH",
273+
}
274+
275+
if proxy_method == "explicit_args":
276+
connect_kwargs.update(
277+
{
278+
"proxy_host": proxy_wm.wiremock_host,
279+
"proxy_port": str(proxy_wm.wiremock_http_port),
280+
}
281+
)
282+
clear_proxy_env_vars()
283+
else:
284+
proxy_url = f"http://{proxy_wm.wiremock_host}:{proxy_wm.wiremock_http_port}"
285+
set_proxy_env_vars(proxy_url)
286+
287+
# Build no_proxy (factory may return CSV string or any iterable)
288+
connect_kwargs["no_proxy"] = no_proxy_factory(
289+
target_wm.wiremock_http_port, storage_wm.wiremock_http_port
290+
)
291+
292+
# Connect and perform DB query (will return chunk URLs to storage host)
293+
cnx = snowflake.connector.connect(**connect_kwargs)
294+
cur = cnx.cursor()
295+
cur.execute("SELECT 1")
296+
# Consume results to force chunk downloads
297+
_ = list(cur)
298+
299+
# Simulate a storage(S3) GET using the same session manager (to honor connection's proxy settings)
300+
cnx._session_manager.get(f"{storage_wm.http_host_with_port}/__admin/health")
301+
302+
cur.close()
303+
cnx.close()
304+
305+
# Check proxy vs target/storage
306+
proxy_reqs = requests.get(f"{proxy_wm.http_host_with_port}/__admin/requests").json()
307+
target_reqs = requests.get(
308+
f"{target_wm.http_host_with_port}/__admin/requests"
309+
).json()
310+
storage_reqs = requests.get(
311+
f"{storage_wm.http_host_with_port}/__admin/requests"
312+
).json()
313+
314+
# DB query expectation
315+
proxy_saw_db = any(
316+
"/queries/v1/query-request" in r["request"]["url"]
317+
for r in proxy_reqs["requests"]
318+
)
319+
target_saw_db = any(
320+
"/queries/v1/query-request" in r["request"]["url"]
321+
for r in target_reqs["requests"]
322+
)
323+
assert target_saw_db
324+
assert proxy_saw_db == expect_backend_proxy
325+
326+
# Storage chunk GET expectation
327+
proxy_saw_storage = any(
328+
"/amazonaws/test/s3testaccount/stage/results/" in r["request"]["url"]
329+
for r in proxy_reqs["requests"]
330+
)
331+
storage_saw_storage = any(
332+
"/amazonaws/test/s3testaccount/stage/results/" in r["request"]["url"]
333+
for r in storage_reqs["requests"]
334+
)
335+
assert storage_saw_storage
336+
assert proxy_saw_storage == expect_storage_proxy
337+
338+
# No extra CSV branch: connection code normalizes strings/iterables equivalently

0 commit comments

Comments
 (0)