This is an automated email from the ASF dual-hosted git repository.
amoghrajesh 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 13d7df70ecd Fix DbtCloudRunJobOperator having false failures during
deferred polling (#70581)
13d7df70ecd is described below
commit 13d7df70ecd11d6103e636ccd50f8cef0b335f45
Author: Amogh Desai <[email protected]>
AuthorDate: Thu Jul 30 10:25:16 2026 +0530
Fix DbtCloudRunJobOperator having false failures during deferred polling
(#70581)
---
.../src/airflow/providers/dbt/cloud/hooks/dbt.py | 45 +++++++--
.../cloud/tests/unit/dbt/cloud/hooks/test_dbt.py | 101 +++++++++++++++++++++
2 files changed, 138 insertions(+), 8 deletions(-)
diff --git a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
index 89e512c51fb..b4ced28bbb7 100644
--- a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
+++ b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
@@ -41,7 +41,7 @@ from airflow.providers.http.hooks.http import HttpHook
if TYPE_CHECKING:
from requests.models import PreparedRequest, Response
- from airflow.models import Connection
+ from airflow.providers.common.compat.sdk import Connection
DBT_CAUSE_MAX_LENGTH = 255
@@ -254,12 +254,13 @@ class DbtCloudHook(HttpHook):
async def get_headers_tenants_from_connection(self) -> tuple[dict[str,
Any], str]:
"""Get Headers, tenants from the connection details."""
+ conn = await self._resolve_connection_async()
headers: dict[str, Any] = {}
- tenant = self._get_tenant_domain(self.connection)
+ tenant = self._get_tenant_domain(conn)
package_name, provider_version = _get_provider_info()
headers["User-Agent"] = f"{package_name}-v{provider_version}"
headers["Content-Type"] = "application/json"
- headers["Authorization"] = f"Token {self.connection.password}"
+ headers["Authorization"] = f"Token {conn.password}"
return headers, tenant
def _log_request_error(self, attempt_num: int, error: str) -> None:
@@ -307,7 +308,8 @@ class DbtCloudHook(HttpHook):
endpoint = f"{account_id}/runs/{run_id}/"
headers, tenant = await self.get_headers_tenants_from_connection()
url, params = self.get_request_url_params(tenant, endpoint,
include_related)
- proxies = self._get_proxies(self.connection) or {}
+ conn = await self._resolve_connection_async()
+ proxies = self._get_proxies(conn) or {}
proxy = proxies.get("https") if proxies and url.startswith("https")
else proxies.get("http")
extra_request_args = {}
@@ -341,13 +343,40 @@ class DbtCloudHook(HttpHook):
job_run_status: int = response["data"]["status"]
return job_run_status
+ @staticmethod
+ def _require_password(conn: Connection) -> Connection:
+ if not conn.password:
+ raise AirflowException("An API token is required to connect to dbt
Cloud.")
+ return conn
+
@cached_property
def connection(self) -> Connection:
- _connection = self.get_connection(self.dbt_cloud_conn_id)
- if not _connection.password:
- raise AirflowException("An API token is required to connect to dbt
Cloud.")
+ """
+ Resolve and cache the dbt Cloud connection (sync).
- return _connection # type: ignore[return-value]
+ Do not read this property from async code running inside the
triggerer's
+ event loop — it calls the synchronous ``get_connection()``, whose
secret-masking
+ step raises ``RuntimeError`` when invoked from a thread that's already
running an
+ event loop. Use ``_resolve_connection_async()`` instead; it shares
this property's
+ cache slot so the connection is still only looked up once per hook
instance
+ regardless of which path is used first.
+ """
+ return
self._require_password(self.get_connection(self.dbt_cloud_conn_id))
+
+ async def _resolve_connection_async(self) -> Connection:
+ """
+ Resolve and cache the dbt Cloud connection (async).
+
+ Shares the ``connection`` cached_property's cache slot so a connection
+ fetched from either the sync or async path is not looked up twice on
+ the same hook instance, and so the async path never touches the sync
+ ``get_connection()``/``mask_secret()`` path from inside a running
+ event loop (which raises in the triggerer).
+ """
+ if "connection" not in self.__dict__:
+ conn = await get_async_connection(self.dbt_cloud_conn_id)
+ self.__dict__["connection"] = self._require_password(conn)
+ return self.__dict__["connection"]
def get_conn(self, *args, **kwargs) -> Session:
tenant = self._get_tenant_domain(self.connection)
diff --git a/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
b/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
index 3ba9653bb0b..951e8505496 100644
--- a/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
+++ b/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
@@ -356,6 +356,107 @@ class TestDbtCloudHook:
assert mock_get_connection.call_count == 1
assert mock_get_async_connection.call_count == 0
+ @pytest.mark.asyncio
+ async def
test_get_headers_tenants_from_connection_does_not_use_sync_get_connection(self):
+ hook = DbtCloudHook(ACCOUNT_ID_CONN)
+
+ with (
+ patch.object(DbtCloudHook, "get_connection") as
mock_get_connection,
+ patch(
+ "airflow.providers.dbt.cloud.hooks.dbt.get_async_connection",
+ new=AsyncMock(
+ return_value=Connection(
+ conn_id=ACCOUNT_ID_CONN,
+ conn_type=DbtCloudHook.conn_type,
+ login=str(DEFAULT_ACCOUNT_ID),
+ password=TOKEN,
+ host=SINGLE_TENANT_DOMAIN,
+ )
+ ),
+ ) as mock_get_async_connection,
+ ):
+ headers, tenant = await hook.get_headers_tenants_from_connection()
+
+ assert tenant == SINGLE_TENANT_DOMAIN
+ assert headers["Authorization"] == f"Token {TOKEN}"
+ mock_get_connection.assert_not_called()
+ assert mock_get_async_connection.call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_resolve_connection_cached_async(self):
+ hook = DbtCloudHook(ACCOUNT_ID_CONN)
+
+ with patch(
+ "airflow.providers.dbt.cloud.hooks.dbt.get_async_connection",
+ new=AsyncMock(
+ return_value=Connection(
+ conn_id=ACCOUNT_ID_CONN,
+ conn_type=DbtCloudHook.conn_type,
+ login=str(DEFAULT_ACCOUNT_ID),
+ password=TOKEN,
+ )
+ ),
+ ) as mock_get_async_connection:
+ first_call = await hook._resolve_connection_async()
+ second_call = await hook._resolve_connection_async()
+
+ assert first_call.password == TOKEN
+ assert second_call.password == TOKEN
+ assert mock_get_async_connection.call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_connection_cache_shared_between_sync_and_async(self):
+ hook = DbtCloudHook(ACCOUNT_ID_CONN)
+
+ with (
+ patch.object(
+ DbtCloudHook,
+ "get_connection",
+ return_value=Connection(
+ conn_id=ACCOUNT_ID_CONN,
+ conn_type=DbtCloudHook.conn_type,
+ login=str(DEFAULT_ACCOUNT_ID),
+ password=TOKEN,
+ ),
+ ) as mock_get_connection,
+ patch(
+ "airflow.providers.dbt.cloud.hooks.dbt.get_async_connection",
+ new=AsyncMock(
+ return_value=Connection(
+ conn_id=ACCOUNT_ID_CONN,
+ conn_type=DbtCloudHook.conn_type,
+ login=str(DEFAULT_ACCOUNT_ID),
+ password=TOKEN,
+ )
+ ),
+ ) as mock_get_async_connection,
+ ):
+ sync_conn = hook.connection
+ async_conn = await hook._resolve_connection_async()
+
+ assert sync_conn.password == TOKEN
+ assert async_conn.password == TOKEN
+
+ assert mock_get_connection.call_count == 1
+ assert mock_get_async_connection.call_count == 0
+
+ @pytest.mark.asyncio
+ async def test_resolve_connection_async_requires_password(self):
+ hook = DbtCloudHook(ACCOUNT_ID_CONN)
+
+ with patch(
+ "airflow.providers.dbt.cloud.hooks.dbt.get_async_connection",
+ new=AsyncMock(
+ return_value=Connection(
+ conn_id=ACCOUNT_ID_CONN,
+ conn_type=DbtCloudHook.conn_type,
+ login=str(DEFAULT_ACCOUNT_ID),
+ )
+ ),
+ ):
+ with pytest.raises(AirflowException, match="An API token is
required"):
+ await hook._resolve_connection_async()
+
@pytest.mark.parametrize(
argnames=("conn_id", "account_id"),
argvalues=[(ACCOUNT_ID_CONN, None), (NO_ACCOUNT_ID_CONN, ACCOUNT_ID)],