This is an automated email from the ASF dual-hosted git repository.
eladkal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new ba8cfe47e57 Add proxy support to Databricks connections (#68527)
ba8cfe47e57 is described below
commit ba8cfe47e57e101a7b9ec39d4ae4a0b042042862
Author: deepinsight coder <[email protected]>
AuthorDate: Fri Jun 26 10:36:25 2026 -0700
Add proxy support to Databricks connections (#68527)
* Add Databricks connection proxy support
* Document Databricks proxies extra relationship with proxy env vars
Clarify which Databricks auth paths honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY
(sync requests and Azure Identity) and which do not (the async aiohttp
paths), that the proxies extra overrides the environment variables, and why
the Azure managed-identity (IMDS) path is never proxied. Add a comment at
both ManagedIdentityCredential call sites and a regression test asserting
the managed-identity and IMDS metadata calls are not proxied while the
Databricks REST call is.
* Rephrase Databricks proxy docs wording
---------
Co-authored-by: Ramachandra Nalam <[email protected]>
---
.../databricks/docs/connections/databricks.rst | 30 ++++++++
.../providers/databricks/hooks/databricks_base.py | 72 ++++++++++++++++-
.../tests/unit/databricks/hooks/test_databricks.py | 90 +++++++++++++++++++++-
.../unit/databricks/hooks/test_databricks_base.py | 55 +++++++++++++
.../unit/databricks/hooks/test_databricks_sql.py | 24 ++++++
5 files changed, 267 insertions(+), 4 deletions(-)
diff --git a/providers/databricks/docs/connections/databricks.rst
b/providers/databricks/docs/connections/databricks.rst
index 630526266be..cc830aee08f 100644
--- a/providers/databricks/docs/connections/databricks.rst
+++ b/providers/databricks/docs/connections/databricks.rst
@@ -81,6 +81,36 @@ Extra (optional)
* ``token``: Specify PAT to use. Consider to switch to specification of
PAT in the Password field as it's more secure.
+ The following optional parameter can be used when Airflow workers need to
access Databricks or Azure
+ token endpoints through an HTTP proxy:
+
+ * ``proxies``: JSON object with optional ``http`` and ``https`` keys,
using the same shape as the
+ ``requests`` and Azure SDK ``proxies`` argument. Only these two keys are
accepted. The configured proxy
+ is applied to Databricks REST API calls, Databricks OAuth token
exchanges, and Azure Identity token
+ acquisition for AAD and default Azure credential authentication.
+
+ .. code-block:: json
+
+ {
+ "proxies": {
+ "http": "http://proxy.example.com:8080",
+ "https": "http://proxy.example.com:8443"
+ }
+ }
+
+ **Note:** The ``proxies`` extra is only needed for paths that do not
already pick up the standard
+ ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``NO_PROXY`` environment variables.
Synchronous REST API and token
+ requests use ``requests``, and Azure AAD / default-credential token
acquisition uses the Azure Identity
+ SDK; both honor those environment variables by default. The asynchronous
(deferrable operator and
+ triggerer) REST API and token paths use ``aiohttp`` with a session that
does **not** trust the
+ environment, so proxy environment variables are ignored there and the
``proxies`` extra is required to
+ proxy them. Use the extra when you need a proxy on the asynchronous
paths, when you want to force a
+ specific proxy regardless of the worker environment, or when proxy
access should apply only to selected endpoints.
+ When both are configured, the ``proxies`` extra takes precedence over
the environment variables. The
+ Azure managed-identity path (``use_azure_managed_identity``)
intentionally does not use the configured proxy. It
+ authenticates against the link-local ``IMDS`` endpoint
(``169.254.169.254``), which must be reached
+ directly; keep that address in ``NO_PROXY`` if your environment sets a
global proxy.
+
Following parameters are necessary if using authentication with OAuth
token for Databricks-managed Service Principal:
* ``service_principal_oauth``: required boolean flag. If specified as
``true``, use the Client ID and Client Secret as the Username and Password. See
`Authentication using OAuth for service principals
<https://docs.databricks.com/en/dev-tools/authentication-oauth.html>`_.
diff --git
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
index f846ab23a54..d66122063b2 100644
---
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
+++
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
@@ -88,6 +88,10 @@ TOKEN_EXCHANGE_DATA = {
}
+class DatabricksProxyConfigurationError(AirflowException):
+ """Raised when Databricks connection proxy configuration is invalid."""
+
+
class BaseDatabricksHook(BaseHook):
"""
Base for interaction with Databricks.
@@ -116,6 +120,7 @@ class BaseDatabricksHook(BaseHook):
"azure_ad_endpoint",
"azure_resource_id",
"azure_tenant_id",
+ "proxies",
"service_principal_oauth",
"federated_k8s",
"k8s_token_path",
@@ -234,6 +239,47 @@ class BaseDatabricksHook(BaseHook):
raise ValueError(f"`{attr_name}` must be present in Connection")
return attr
+ @cached_property
+ def proxies(self) -> dict[str, str] | None:
+ """Return validated proxy configuration from connection extras."""
+ extra_dejson = self.databricks_conn.extra_dejson
+ if not isinstance(extra_dejson, dict):
+ return None
+
+ proxies = extra_dejson.get("proxies")
+ if proxies is None:
+ return None
+ if not isinstance(proxies, dict):
+ raise DatabricksProxyConfigurationError("Connection extra
'proxies' must be a JSON object.")
+
+ invalid_keys = set(proxies) - {"http", "https"}
+ if invalid_keys:
+ invalid_keys_str = ", ".join(sorted(invalid_keys))
+ raise DatabricksProxyConfigurationError(
+ f"Connection extra 'proxies' only supports 'http' and 'https'
keys. Got: {invalid_keys_str}."
+ )
+
+ for proxy_scheme, proxy_url in proxies.items():
+ if not isinstance(proxy_url, str) or not proxy_url:
+ raise DatabricksProxyConfigurationError(
+ "Connection extra 'proxies' values must be non-empty
strings. "
+ f"Invalid value for '{proxy_scheme}'."
+ )
+
+ return proxies or None
+
+ def _get_requests_kwargs(self) -> dict[str, Any]:
+ return {"proxies": self.proxies} if self.proxies else {}
+
+ def _get_aiohttp_kwargs(self, url: str) -> dict[str, str]:
+ if not self.proxies:
+ return {}
+ proxy = self.proxies.get(urlsplit(url).scheme)
+ return {"proxy": proxy} if proxy else {}
+
+ def _get_azure_credential_kwargs(self) -> dict[str, dict[str, str]]:
+ return {"proxies": self.proxies} if self.proxies else {}
+
def _get_retry_object(self) -> Retrying:
"""
Instantiate a retry object.
@@ -269,6 +315,7 @@ class BaseDatabricksHook(BaseHook):
"Content-Type":
"application/x-www-form-urlencoded",
},
timeout=self.token_timeout_seconds,
+ **self._get_requests_kwargs(),
)
resp.raise_for_status()
@@ -307,6 +354,7 @@ class BaseDatabricksHook(BaseHook):
"Content-Type":
"application/x-www-form-urlencoded",
},
timeout=self.token_timeout_seconds,
+ **self._get_aiohttp_kwargs(resource),
) as resp:
resp.raise_for_status()
jsn = await resp.json()
@@ -345,6 +393,11 @@ class BaseDatabricksHook(BaseHook):
client_id = self.databricks_conn.extra_dejson.get(
"azure_managed_identity_client_id", None
)
+ # Managed identity authenticates against the
link-local IMDS endpoint
+ # (169.254.169.254), which must be reached directly
and is unsupported behind a
+ # proxy, so the `proxies` extra is intentionally not
forwarded here (unlike the
+ # ClientSecretCredential / DefaultAzureCredential
paths, which call the public
+ # Entra ID endpoint and do receive the proxy kwargs).
token =
ManagedIdentityCredential(client_id=client_id).get_token(
f"{resource}/.default"
)
@@ -353,6 +406,7 @@ class BaseDatabricksHook(BaseHook):
client_id=self._get_connection_attr("login"),
client_secret=self.databricks_conn.password,
tenant_id=self.databricks_conn.extra_dejson["azure_tenant_id"],
+ **self._get_azure_credential_kwargs(),
)
token = credential.get_token(f"{resource}/.default")
jsn = {
@@ -397,6 +451,11 @@ class BaseDatabricksHook(BaseHook):
client_id = self.databricks_conn.extra_dejson.get(
"azure_managed_identity_client_id", None
)
+ # Managed identity authenticates against the
link-local IMDS endpoint
+ # (169.254.169.254), which must be reached directly
and is unsupported behind a
+ # proxy, so the `proxies` extra is intentionally not
forwarded here (unlike the
+ # ClientSecretCredential / DefaultAzureCredential
paths, which call the public
+ # Entra ID endpoint and do receive the proxy kwargs).
async with
AsyncManagedIdentityCredential(client_id=client_id) as credential:
token = await
credential.get_token(f"{resource}/.default")
else:
@@ -404,6 +463,7 @@ class BaseDatabricksHook(BaseHook):
client_id=self._get_connection_attr("login"),
client_secret=self.databricks_conn.password,
tenant_id=self.databricks_conn.extra_dejson["azure_tenant_id"],
+ **self._get_azure_credential_kwargs(),
) as credential:
token = await
credential.get_token(f"{resource}/.default")
jsn = {
@@ -446,7 +506,9 @@ class BaseDatabricksHook(BaseHook):
#
# While there is a WorkloadIdentityCredential class, the
below class is advised by Microsoft
#
https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview
- token =
DefaultAzureCredential().get_token(f"{resource}/.default")
+ token =
DefaultAzureCredential(**self._get_azure_credential_kwargs()).get_token(
+ f"{resource}/.default"
+ )
jsn = {
"access_token": token.token,
@@ -491,7 +553,9 @@ class BaseDatabricksHook(BaseHook):
#
# While there is a WorkloadIdentityCredential class, the
below class is advised by Microsoft
#
https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview
- token = await
AsyncDefaultAzureCredential().get_token(f"{resource}/.default")
+ token = await AsyncDefaultAzureCredential(
+ **self._get_azure_credential_kwargs()
+ ).get_token(f"{resource}/.default")
jsn = {
"access_token": token.token,
@@ -866,6 +930,7 @@ class BaseDatabricksHook(BaseHook):
"Content-Type":
"application/x-www-form-urlencoded",
},
timeout=self.token_timeout_seconds,
+ **self._get_requests_kwargs(),
)
resp.raise_for_status()
jsn = resp.json()
@@ -913,6 +978,7 @@ class BaseDatabricksHook(BaseHook):
"Content-Type":
"application/x-www-form-urlencoded",
},
timeout=self.token_timeout_seconds,
+ **self._get_aiohttp_kwargs(token_exchange_url),
) as resp:
resp.raise_for_status()
jsn = await resp.json()
@@ -1141,6 +1207,7 @@ class BaseDatabricksHook(BaseHook):
auth=auth,
headers=headers,
timeout=self.timeout_seconds,
+ **self._get_requests_kwargs(),
)
self.log.debug("Response Status Code: %s",
response.status_code)
self.log.debug("Response text: %s", response.text)
@@ -1204,6 +1271,7 @@ class BaseDatabricksHook(BaseHook):
auth=auth,
headers={**headers, **self.user_agent_header},
timeout=self.timeout_seconds,
+ **self._get_aiohttp_kwargs(url),
) as response:
self.log.debug("Response Status Code: %s",
response.status)
self.log.debug("Response text: %s", response.text)
diff --git
a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
index 1a03934ec83..2b79d9b1044 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
@@ -79,6 +79,7 @@ HOST_WITH_SCHEME = "https://xx.cloud.databricks.com"
LOGIN = "login"
PASSWORD = "password"
TOKEN = "token"
+PROXIES = {"http": "http://proxy.example.com:8080", "https":
"http://proxy.example.com:8443"}
AZURE_DEFAULT_AD_ENDPOINT = "https://login.microsoftonline.com"
AZURE_TOKEN_SERVICE_URL = "{}/{}/oauth2/token"
RUN_PAGE_URL = "https://XX.cloud.databricks.com/#jobs/1/runs/1"
@@ -474,6 +475,23 @@ class TestDatabricksHook:
timeout=self.hook.timeout_seconds,
)
+ @mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
+ def test_do_api_call_uses_proxies_from_connection_extra(self,
mock_requests):
+ hook = DatabricksHook(retry_delay=0)
+ hook.databricks_conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host=HOST,
+ login=LOGIN,
+ password=PASSWORD,
+ extra=json.dumps({"proxies": PROXIES}),
+ )
+ mock_requests.post.return_value.json.return_value = {"run_id": "1"}
+
+ assert hook.submit_run({"notebook_task": NOTEBOOK_TASK, "new_cluster":
NEW_CLUSTER}) == "1"
+
+ assert mock_requests.post.call_args.kwargs["proxies"] == PROXIES
+
@mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
def test_create(self, mock_requests):
mock_requests.codes.ok = 200
@@ -1624,6 +1642,7 @@ class TestDatabricksHookAadToken:
extra=json.dumps(
{
"azure_tenant_id":
"3ff810a6-5504-4ab8-85cb-cd0e6f879c1d",
+ "proxies": PROXIES,
}
),
)
@@ -1643,9 +1662,11 @@ class TestDatabricksHookAadToken:
run_id = self.hook.submit_run(data)
assert run_id == "1"
+ assert mock_azure_identity.call_args.kwargs["proxies"] == PROXIES
args = mock_requests.post.call_args
kwargs = args[1]
assert kwargs["auth"].token == TOKEN
+ assert kwargs["proxies"] == PROXIES
@pytest.mark.db_test
@@ -1671,6 +1692,7 @@ class TestDatabricksHookAadTokenOtherClouds:
{
"azure_tenant_id": self.tenant_id,
"azure_ad_endpoint": self.ad_endpoint,
+ "proxies": PROXIES,
}
),
)
@@ -1693,6 +1715,7 @@ class TestDatabricksHookAadTokenOtherClouds:
azure_identity_args = mock_azure_identity.call_args.kwargs
assert azure_identity_args["tenant_id"] == self.tenant_id
assert azure_identity_args["client_id"] == self.client_id
+ assert azure_identity_args["proxies"] == PROXIES
get_token_args =
mock_azure_identity.return_value.get_token.call_args_list
assert get_token_args ==
[mock.call(f"{DEFAULT_DATABRICKS_SCOPE}/.default")]
@@ -1700,6 +1723,7 @@ class TestDatabricksHookAadTokenOtherClouds:
args = mock_requests.post.call_args
kwargs = args[1]
assert kwargs["auth"].token == TOKEN
+ assert kwargs["proxies"] == PROXIES
@pytest.mark.db_test
@@ -1810,6 +1834,42 @@ class TestDatabricksHookAadTokenManagedIdentity:
kwargs = args[1]
assert kwargs["auth"].token == TOKEN
+ @mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
+ @mock.patch.object(azure.identity, "ManagedIdentityCredential")
+ def test_managed_identity_credential_is_not_proxied(self,
mock_azure_identity, mock_requests):
+ """Managed identity targets the link-local IMDS endpoint, which must
be reached directly, so the
+ ``proxies`` extra is not forwarded to ``ManagedIdentityCredential``
nor to the metadata service call,
+ while the Databricks REST call is still proxied."""
+ hook = DatabricksHook(retry_args=DEFAULT_RETRY_ARGS)
+ hook.databricks_conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host=HOST,
+ login=None,
+ password=None,
+ extra=json.dumps({"use_azure_managed_identity": True, "proxies":
PROXIES}),
+ )
+ mock_requests.codes.ok = 200
+ mock_requests.get.side_effect = [
+ create_successful_response_mock({"compute": {"azEnvironment":
"AZUREPUBLICCLOUD"}}),
+ ]
+ mock_requests.post.side_effect = [
+ create_successful_response_mock({"run_id": "1"}),
+ ]
+ mock_azure_identity().get_token.return_value =
create_aad_token_for_resource()
+ status_code_mock = mock.PropertyMock(return_value=200)
+ type(mock_requests.post.return_value).status_code = status_code_mock
+
+ run_id = hook.submit_run({"notebook_task": NOTEBOOK_TASK,
"new_cluster": NEW_CLUSTER})
+
+ assert run_id == "1"
+ # ManagedIdentityCredential must never be constructed with a proxies
kwarg.
+ assert all("proxies" not in call.kwargs for call in
mock_azure_identity.call_args_list)
+ # The IMDS metadata service call must also bypass the proxy.
+ assert "proxies" not in mock_requests.get.call_args.kwargs
+ # The Databricks REST API call, by contrast, is proxied.
+ assert mock_requests.post.call_args.kwargs["proxies"] == PROXIES
+
@pytest.mark.db_test
class TestDatabricksHookAsyncMethods:
@@ -1927,6 +1987,25 @@ class TestDatabricksHookAsyncMethods:
timeout=self.hook.timeout_seconds,
)
+ @pytest.mark.asyncio
+
@mock.patch("airflow.providers.databricks.hooks.databricks_base.aiohttp.ClientSession.get")
+ async def test_do_api_call_uses_proxies_from_connection_extra(self,
mock_get):
+ self.hook.databricks_conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host=HOST,
+ login=LOGIN,
+ password=PASSWORD,
+ extra=json.dumps({"proxies": PROXIES}),
+ )
+ mock_get.return_value.__aenter__.return_value.json =
AsyncMock(return_value=GET_RUN_RESPONSE)
+
+ async with self.hook:
+ run_state = await self.hook.a_get_run_state(RUN_ID)
+
+ assert run_state == RunState(LIFE_CYCLE_STATE, RESULT_STATE,
STATE_MESSAGE)
+ assert mock_get.call_args.kwargs["proxy"] == PROXIES["https"]
+
@pytest.mark.asyncio
@mock.patch("airflow.providers.databricks.hooks.databricks_base.aiohttp.ClientSession.get")
async def test_get_run_page_url(self, mock_get):
@@ -2064,6 +2143,7 @@ class TestDatabricksHookAsyncAadTokenOtherClouds:
{
"azure_tenant_id": self.tenant_id,
"azure_ad_endpoint": self.ad_endpoint,
+ "proxies": PROXIES,
}
),
)
@@ -2093,6 +2173,7 @@ class TestDatabricksHookAsyncAadTokenOtherClouds:
credential_call_kwargs =
mock_client_secret_credential_class.call_args.kwargs
assert credential_call_kwargs["tenant_id"] == self.tenant_id
assert credential_call_kwargs["client_id"] == self.client_id
+ assert credential_call_kwargs["proxies"] == PROXIES
mock_credential.get_token.assert_called_once_with(f"{DEFAULT_DATABRICKS_SCOPE}/.default")
@@ -2102,6 +2183,7 @@ class TestDatabricksHookAsyncAadTokenOtherClouds:
auth=BearerAuth(TOKEN),
headers=self.hook.user_agent_header,
timeout=self.hook.timeout_seconds,
+ proxy=PROXIES["https"],
)
@@ -2249,7 +2331,7 @@ class TestDatabricksHookSpToken:
host=HOST,
login="c64f6d12-f6e4-45a4-846e-032b42b27758",
password="secret",
- extra=json.dumps({"service_principal_oauth": True}),
+ extra=json.dumps({"service_principal_oauth": True, "proxies":
PROXIES}),
)
)
self.hook = DatabricksHook(retry_args=DEFAULT_RETRY_ARGS)
@@ -2269,11 +2351,13 @@ class TestDatabricksHookSpToken:
ad_call_args = mock_requests.method_calls[0]
assert ad_call_args[1][0] ==
OIDC_TOKEN_SERVICE_URL.format(f"https://{HOST}")
assert ad_call_args[2]["data"] ==
"grant_type=client_credentials&scope=all-apis"
+ assert ad_call_args[2]["proxies"] == PROXIES
assert run_id == "1"
args = mock_requests.post.call_args
kwargs = args[1]
assert kwargs["auth"].token == TOKEN
+ assert kwargs["proxies"] == PROXIES
@pytest.mark.db_test
@@ -2292,7 +2376,7 @@ class TestDatabricksHookAsyncSpToken:
host=HOST,
login="c64f6d12-f6e4-45a4-846e-032b42b27758",
password="secret",
- extra=json.dumps({"service_principal_oauth": True}),
+ extra=json.dumps({"service_principal_oauth": True, "proxies":
PROXIES}),
)
)
self.hook = DatabricksHook(retry_args=DEFAULT_RETRY_ARGS)
@@ -2310,12 +2394,14 @@ class TestDatabricksHookAsyncSpToken:
run_state = await self.hook.a_get_run_state(RUN_ID)
assert run_state == RunState(LIFE_CYCLE_STATE, RESULT_STATE,
STATE_MESSAGE)
+ assert mock_post.call_args.kwargs["proxy"] == PROXIES["https"]
mock_get.assert_called_once_with(
get_run_endpoint(HOST),
json={"run_id": RUN_ID},
auth=BearerAuth(TOKEN),
headers=self.hook.user_agent_header,
timeout=self.hook.timeout_seconds,
+ proxy=PROXIES["https"],
)
diff --git
a/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
b/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
index 090a3e34c78..393107497d7 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks_base.py
@@ -37,9 +37,11 @@ from airflow.providers.databricks.hooks.databricks_base
import (
K8S_CA_CERT_PATH,
TOKEN_REFRESH_LEAD_TIME,
BaseDatabricksHook,
+ DatabricksProxyConfigurationError,
)
DEFAULT_CONN_ID = "databricks_default"
+PROXIES = {"http": "http://proxy.example.com:8080", "https":
"http://proxy.example.com:8443"}
class TestBaseDatabricksHook:
@@ -110,6 +112,59 @@ class TestBaseDatabricksHook:
def test_parse_host(self, input_url, expected_host):
assert BaseDatabricksHook._parse_host(input_url) == expected_host
+ @mock.patch(
+
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+ new_callable=mock.PropertyMock,
+ )
+ def test_proxies_from_extra(self, mock_conn):
+ mock_conn.return_value = Connection(extra={"proxies": PROXIES})
+ hook = BaseDatabricksHook()
+
+ assert hook.proxies == PROXIES
+ assert hook._get_requests_kwargs() == {"proxies": PROXIES}
+ assert hook._get_azure_credential_kwargs() == {"proxies": PROXIES}
+
+ @mock.patch(
+
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+ new_callable=mock.PropertyMock,
+ )
+ def test_aiohttp_proxy_uses_request_scheme(self, mock_conn):
+ mock_conn.return_value = Connection(extra={"proxies": PROXIES})
+ hook = BaseDatabricksHook()
+
+ assert hook._get_aiohttp_kwargs("https://example.databricks.com/api")
== {"proxy": PROXIES["https"]}
+ assert hook._get_aiohttp_kwargs("http://example.databricks.com/api")
== {"proxy": PROXIES["http"]}
+
+ @mock.patch(
+
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+ new_callable=mock.PropertyMock,
+ )
+ def test_aiohttp_proxy_returns_empty_kwargs_without_matching_scheme(self,
mock_conn):
+ mock_conn.return_value = Connection(extra={"proxies": {"http":
PROXIES["http"]}})
+ hook = BaseDatabricksHook()
+
+ assert hook._get_aiohttp_kwargs("https://example.databricks.com/api")
== {}
+
+ @mock.patch(
+
"airflow.providers.databricks.hooks.databricks_base.BaseDatabricksHook.databricks_conn",
+ new_callable=mock.PropertyMock,
+ )
+ @pytest.mark.parametrize(
+ ("proxies", "message"),
+ [
+ ("http://proxy.example.com:8080", "must be a JSON object"),
+ ({"ftp": "http://proxy.example.com:8080"}, "only supports 'http'
and 'https' keys"),
+ ({"https": ""}, "values must be non-empty strings"),
+ ({"https": 8080}, "values must be non-empty strings"),
+ ],
+ )
+ def test_proxies_invalid_extra_raises(self, mock_conn, proxies, message):
+ mock_conn.return_value = Connection(extra={"proxies": proxies})
+ hook = BaseDatabricksHook()
+
+ with pytest.raises(DatabricksProxyConfigurationError, match=message):
+ hook.proxies
+
@mock.patch("requests.post")
@time_machine.travel("2025-07-12 12:00:00", tick=False)
def test_get_sp_token(self, mock_post):
diff --git
a/providers/databricks/tests/unit/databricks/hooks/test_databricks_sql.py
b/providers/databricks/tests/unit/databricks/hooks/test_databricks_sql.py
index cd3c00e2839..6fd835b7872 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks_sql.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks_sql.py
@@ -854,6 +854,30 @@ def test_get_conn_no_query_tags(mock_connect,
mock_get_requests):
assert session_cfg is None or "QUERY_TAGS" not in session_cfg
[email protected]("airflow.providers.databricks.hooks.databricks_sql.sql.connect")
+def test_get_conn_does_not_leak_proxies_into_connector(mock_connect,
mock_get_requests):
+ """A ``proxies`` connection extra must not be forwarded to
``sql.connect()``.
+
+ ``proxies`` configures the REST/token HTTP paths only; the
+ databricks-sql-connector does not accept it and raises ``TypeError`` on
+ unexpected keyword arguments (>=4.0.0). It is listed in
+ ``extra_parameters`` so ``_get_extra_config`` strips it from connect
kwargs.
+ """
+ hook = DatabricksSqlHook(databricks_conn_id=DEFAULT_CONN_ID,
http_path=HTTP_PATH)
+ hook.databricks_conn = Connection(
+ conn_id=DEFAULT_CONN_ID,
+ conn_type="databricks",
+ host=HOST,
+ password=TOKEN,
+ extra={"proxies": {"https": "http://proxy.example.com:8443"}},
+ )
+
+ hook.get_conn()
+
+ mock_connect.assert_called_once()
+ assert "proxies" not in mock_connect.call_args.kwargs
+
+
class TestFormatQueryTags:
def test_simple_values(self):
result = _format_query_tags({"dag_id": "my_dag", "task_id": "my_task"})