This is an automated email from the ASF dual-hosted git repository.
kaxil 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 6a6a24f2e1a Remove a misleading connection type warning and explain
lookup failures (#72855)
6a6a24f2e1a is described below
commit 6a6a24f2e1a10ac51c9a0c5910cb08b21d9121d2
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Sep 10 23:16:33 2026 +0100
Remove a misleading connection type warning and explain lookup failures
(#72855)
* Warn about a connection type that cannot round-trip, not one that can
`Connection.get_uri()` warned whenever `conn_type` contained '_', citing
RFC 3986.
The check was inverted. RFC 3986 forbids '_' in a URI scheme, so
`get_uri()` encodes
'_' as '-' and `_normalize_conn_type` decodes it back on read; the
underscore form is
therefore the canonical one, required for the hook-registry key, and the
URI it emits
is already compliant. The configuration that genuinely breaks is the
opposite one: a
literal '-' in `conn_type` is indistinguishable from an encoded '_' once
serialized,
so it decodes to the underscore form on read and the hook registered under
the
hyphenated name is never found.
The warning therefore fired on correct configuration, for 35 connection
types in the
provider tree, once per uncached connection fetch, and stayed silent on the
broken
kind. Its `_prenormalized_conn_type` guard could never help either: an
underscore
scheme parses with no scheme at all, so that attribute can never contain
'_'.
Inverts the condition to flag '-', and names the type the connection will
actually
resolve to so the message says what to change.
Also replaces the `Unknown hook type ""` reported for such a URI, in both
the model
and the Task SDK copy, with a message that names the empty connection type
and the
scheme rule that caused it.
* Explain a hyphenated connection type when its hook cannot be found
`get_hook()` gained an explanation for an empty `conn_type`, but a
`conn_type` set
directly with a hyphen fell through to the bare `Unknown hook type "..."`.
That is the
configuration which can never resolve, since '-' is the URI-scheme encoding
of '_', and
it is reached by exactly the connections that never round-trip through
`get_uri()`:
metadata-DB rows and object-form imports.
The message now names the registered spelling it is likely looking for. It
is worded as
a hint rather than a diagnosis, because the runtime provider schema does
not forbid a
hyphenated `connection-type`, so an out-of-tree provider could legitimately
register
one and "the provider is not installed" remains a possible cause.
Applied to both the model and the Task SDK copy, since both raise it.
* Name the registered connection type when a hook lookup fails
The previous message asserted that a connection type spelled with '-'
cannot be resolved. That is not true: hooks register under the
connection-type from provider.yaml verbatim, so a hyphenated type does
register and does resolve for a connection built with that type
directly.
The hyphen only breaks resolution once a connection has been stored,
because a URI scheme encodes '_' as '-' and reading a connection back
from a URI or from JSON decodes it again. That normalization runs in the
opposite direction from what the message described, so the message never
appeared for the failure that motivated it -- a connection for a
hyphenated connection-type fails as an unknown *underscored* type.
Look the other spelling up in the registry instead of guessing at it,
which covers both directions and says nothing that is not known:
Unknown hook type "pydanticai_vertex", but a hook is registered for
'pydanticai-vertex'. No stored connection can reach that hook, because
'-' is decoded back to '_' whenever a connection is read from a URI or
from JSON, so the provider has to declare its connection-type with '_'.
With neither spelling registered, the message stays as it was.
* Remove the connection type warning from get_uri instead of inverting it
get_uri() serializes a connection; it does not validate one. The warning
it carried said a conn_type containing '_' broke RFC 3986, which is the
one spelling that survives a round trip: get_uri() encodes '_' as '-'
because a URI scheme cannot contain '_', and reading a connection back
decodes it. Every URI it emits is compliant by construction, so the rule
being cited was never the one at stake.
Inverting the condition to flag '-' says something true but says it in
the wrong place. It fires on every uncached connection fetch, repeats for
the life of the deployment, and is addressed to whoever reads the logs
rather than to the provider author who declares the connection-type. A
connection type spelled with '-' is not merely discouraged, it is
unrepresentable, and an invariant of that kind belongs in validation.
It now has two better homes. get_hook() reports it where it fails, naming
the registered spelling it looked up, and the provider schema rejects a
hyphenated connection-type at authoring time.
The test that pinned the warning becomes one that pins its absence, so
the canonical underscore form cannot start warning again.
* Only name the other connection type spelling when its hook resolves
ProvidersManager.hooks maps a connection type to None when its hook
cannot be imported, so testing for membership was not enough to claim the
other spelling would work. A connection type of google-cloud-platform
whose google_cloud_platform hook fails to import produced advice that
still raised Unknown hook type when followed, which is worse than no
advice.
Resolve the alternative rather than testing for membership, and name it
only when it yields a hook. Resolution is already what the primary lookup
one line earlier does, it happens only on the failing path, and it cannot
raise: the discovery code returns None for a hook it could not import.
---
airflow-core/src/airflow/models/connection.py | 47 ++++--
airflow-core/tests/unit/models/test_connection.py | 159 ++++++++++++++++-----
task-sdk/src/airflow/sdk/definitions/connection.py | 40 +++++-
.../tests/task_sdk/definitions/test_connection.py | 67 +++++++++
4 files changed, 263 insertions(+), 50 deletions(-)
diff --git a/airflow-core/src/airflow/models/connection.py
b/airflow-core/src/airflow/models/connection.py
index 1b4b0f8f867..4424316c225 100644
--- a/airflow-core/src/airflow/models/connection.py
+++ b/airflow-core/src/airflow/models/connection.py
@@ -287,13 +287,6 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
Note that the URI returned by this method is **not**
SQLAlchemy-compatible, if you need a SQLAlchemy-compatible URI, use the
:attr:`~airflow.providers.common.sql.hooks.sql.DbApiHook.sqlalchemy_url`
"""
- conn_type = getattr(self, "_prenormalized_conn_type", self.conn_type)
or ""
- if "_" in conn_type:
- self.log.warning(
- "Connection schemes (type: %s) shall not contain '_' according
to RFC3986.",
- conn_type,
- )
-
if self.conn_type:
uri = f"{self.conn_type.lower().replace('_', '-')}://"
else:
@@ -390,10 +383,46 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
"""Return hook based on conn_type."""
from airflow.providers_manager import ProvidersManager
- hook = ProvidersManager().hooks.get(self.conn_type, None)
+ hooks = ProvidersManager().hooks
+ hook = hooks.get(self.conn_type, None)
if hook is None:
- raise AirflowException(f'Unknown hook type "{self.conn_type}"')
+ if not self.conn_type:
+ # A URI scheme cannot contain '_' (RFC 3986), so "foo_bar://h"
parses with no
+ # scheme at all and leaves conn_type empty. Name that, instead
of reporting an
+ # unknown hook type of "".
+ message = (
+ f"Connection {self.conn_id!r} has no connection type, so
no hook could be "
+ "looked up. If it was defined as a URI, note that a URI
scheme cannot "
+ "contain '_' (RFC 3986) and such a URI parses with no
scheme at all: use "
+ "'-' in the URI instead, which is decoded back to '_' on
read."
+ )
+ else:
+ message = f'Unknown hook type "{self.conn_type}"'
+ # get_uri() encodes '_' as '-' because RFC 3986 forbids '_' in
a scheme, and
+ # reading a connection back from a URI or from JSON decodes it
again, so both
+ # characters serialize to '-' and a connection type spelled
one way cannot
+ # resolve a hook registered the other way. Hooks register
under the
+ # connection-type verbatim, so look the other spelling up
rather than asserting
+ # which one is right, and resolve it rather than testing for
membership: a
+ # registered connection type maps to None when its hook cannot
be imported, and
+ # naming a spelling that still will not resolve is worse than
naming none.
+ alternative = (
+ self.conn_type.replace("-", "_")
+ if "-" in self.conn_type
+ else self.conn_type.replace("_", "-")
+ )
+ if alternative != self.conn_type and hooks.get(alternative) is
not None:
+ message += f", but a hook is registered for
{alternative!r}. "
+ if "-" in self.conn_type:
+ message += "Spell this connection's type with '_' to
reach it."
+ else:
+ message += (
+ "Reading a connection from a URI or from JSON
decodes '-' back to "
+ "'_', so this connection cannot reach that hook;
the provider has "
+ "to declare its connection-type with '_'."
+ )
+ raise AirflowException(message)
try:
hook_class = import_string(hook.hook_class_name)
except ImportError:
diff --git a/airflow-core/tests/unit/models/test_connection.py
b/airflow-core/tests/unit/models/test_connection.py
index 94cabe5e4da..e7f6cbf6cb1 100644
--- a/airflow-core/tests/unit/models/test_connection.py
+++ b/airflow-core/tests/unit/models/test_connection.py
@@ -272,53 +272,134 @@ class TestConnection:
assert connection.get_uri() == expected_uri
@pytest.mark.parametrize(
- ("connection", "expected_warned"),
+ ("conn_type", "registered", "expected_remedy"),
[
- (Connection(conn_id="test-uri-1",
uri="google-cloud-platform://testlogin:testpassword@"), False),
- (Connection(conn_id="test-uri-2", uri="amazon://test:test@"),
False),
- (
- Connection(
- conn_id="test-non-uri-1",
- conn_type="google-cloud-platform",
- login="testlogin",
- password="testpassword",
- ),
- False,
+ # Typed with '-' where the provider registers '_': the connection
is the thing to
+ # fix, so say which spelling reaches the hook.
+ pytest.param(
+ "google-cloud-platform",
+ "google_cloud_platform",
+ "Spell this connection's type with '_' to reach it.",
+ id="hyphenated-conn-type",
),
- (
- Connection(
- conn_id="test-non-uri-2",
- conn_type="google_cloud_platform",
- login="testlogin",
- password="testpassword",
- ),
- True,
+ # The reverse, which is what a hyphenated connection-type actually
produces: a
+ # connection read from a URI or from JSON normalizes to '_' and
cannot reach the
+ # registered name, so there is nothing that connection can do and
the provider
+ # has to rename. A metadata-DB row keeps the hyphen and does
resolve.
+ pytest.param(
+ "pydanticai_vertex",
+ "pydanticai-vertex",
+ "so this connection cannot reach that hook",
+ id="hyphenated-registration",
),
- (
- Connection(
- conn_id="test-non-uri-3", conn_type="amazon",
login="testlogin", password="testpassword"
- ),
- False,
+ ],
+ )
+ def test_get_hook_names_the_other_spelling_when_it_is_the_registered_one(
+ self, conn_type, registered, expected_remedy
+ ):
+ """
+ '-' and '_' are the same character to a URI scheme, so a connection
type spelled one
+ way cannot resolve a hook registered the other way. The bare 'Unknown
hook type' named
+ neither the cause nor which spelling would work.
+ """
+ conn = Connection(conn_id="c", conn_type=conn_type)
+
+ with mock.patch("airflow.providers_manager.ProvidersManager") as
mock_manager:
+ mock_manager.return_value.hooks = {registered: mock.MagicMock()}
+ with pytest.raises(AirflowException, match="Unknown hook type") as
exc_info:
+ conn.get_hook()
+
+ message = str(exc_info.value)
+ assert conn_type in message
+ assert registered in message
+ assert expected_remedy in message
+
+ def test_get_hook_does_not_guess_an_unregistered_spelling(self):
+ """
+ A hyphenated connection-type registers verbatim and resolves for a
connection built
+ with that type directly, so an unresolved hyphenated type is not
evidence that the
+ underscored name exists. With nothing registered either way, say only
what is known.
+ """
+ conn = Connection(conn_id="c", conn_type="google-cloud-platform")
+
+ with mock.patch("airflow.providers_manager.ProvidersManager") as
mock_manager:
+ mock_manager.return_value.hooks = {}
+ with pytest.raises(AirflowException) as exc_info:
+ conn.get_hook()
+
+ assert str(exc_info.value) == 'Unknown hook type
"google-cloud-platform"'
+
+ def
test_get_hook_does_not_advise_a_spelling_whose_hook_cannot_be_imported(self):
+ """
+ ProvidersManager.hooks holds None for a connection type whose hook
could not be
+ imported, so membership alone does not mean the other spelling would
resolve. Advice
+ that still fails when followed is worse than no advice.
+ """
+ conn = Connection(conn_id="c", conn_type="google-cloud-platform")
+
+ with mock.patch("airflow.providers_manager.ProvidersManager") as
mock_manager:
+ mock_manager.return_value.hooks = {"google_cloud_platform": None}
+ with pytest.raises(AirflowException) as exc_info:
+ conn.get_hook()
+
+ assert str(exc_info.value) == 'Unknown hook type
"google-cloud-platform"'
+
+ def test_get_hook_explains_a_uri_whose_scheme_was_dropped(self):
+ """
+ A URI scheme cannot contain '_' (RFC 3986), so ``foo_bar://h`` parses
with no scheme
+ at all and leaves conn_type empty. The resulting failure used to read
+ ``Unknown hook type ""``, which named neither the cause nor the fix.
+ """
+ conn = Connection(conn_id="c", uri="pydanticai_azure://h")
+ assert conn.conn_type == ""
+
+ with pytest.raises(AirflowException, match="has no connection type")
as exc_info:
+ conn.get_hook()
+
+ assert "RFC 3986" in str(exc_info.value)
+
+ @pytest.mark.parametrize(
+ "connection",
+ [
+ # Parsed from a URI, so _normalize_conn_type has already decoded
'-' to '_'.
+ Connection(conn_id="test-uri-1",
uri="google-cloud-platform://testlogin:testpassword@"),
+ Connection(conn_id="test-uri-2", uri="amazon://test:test@"),
+ # Set directly with a hyphen, which is the spelling that cannot
round-trip.
+ Connection(
+ conn_id="test-non-uri-1",
+ conn_type="google-cloud-platform",
+ login="testlogin",
+ password="testpassword",
+ ),
+ # The canonical underscore form, which serializes to
'google-cloud-platform://'
+ # and decodes back unchanged.
+ Connection(
+ conn_id="test-non-uri-2",
+ conn_type="google_cloud_platform",
+ login="testlogin",
+ password="testpassword",
+ ),
+ Connection(
+ conn_id="test-non-uri-3", conn_type="amazon",
login="testlogin", password="testpassword"
),
],
)
- def test_get_uri_conn_type_warning(self, connection: Connection,
expected_warned: bool):
+ def test_get_uri_does_not_warn_about_the_connection_type(self, connection:
Connection):
+ """
+ get_uri() serializes a connection; it does not validate one.
+
+ It used to warn that a conn_type containing '_' broke RFC 3986. That
is the one
+ spelling which survives the round trip, since get_uri() encodes '_' as
'-' and
+ reading a connection back decodes it, and the warning fired on every
uncached
+ connection fetch. A connection type that cannot round-trip is reported
where it
+ fails, by get_hook(), and is rejected by the provider schema.
+ """
with capture_logs() as captured_logs:
connection.get_uri()
- conn_type_warnings = list(
- filter(
- lambda captured_log: (
- captured_log["log_level"] == "warning" and "RFC3986" in
captured_log["event"]
- ),
- captured_logs,
- )
- )
- if expected_warned:
- assert conn_type_warnings, f"RFC3986 warning expected for
connection '{connection.conn_id}'."
- else:
- assert not conn_type_warnings, (
- f"RFC3986 warning not expected for connection
'{connection.conn_id}'."
- )
+
+ assert [
+ captured_log for captured_log in captured_logs if
captured_log["log_level"] == "warning"
+ ] == []
@pytest.mark.parametrize(
("connection", "expected_conn_id"),
diff --git a/task-sdk/src/airflow/sdk/definitions/connection.py
b/task-sdk/src/airflow/sdk/definitions/connection.py
index 8e9ab232a7c..ba70dca3359 100644
--- a/task-sdk/src/airflow/sdk/definitions/connection.py
+++ b/task-sdk/src/airflow/sdk/definitions/connection.py
@@ -233,10 +233,46 @@ class Connection:
"""Return hook based on conn_type."""
from airflow.sdk._shared.module_loading import import_string
- hook = ProvidersManagerTaskRuntime().hooks.get(self.conn_type, None)
+ hooks = ProvidersManagerTaskRuntime().hooks
+ hook = hooks.get(self.conn_type, None)
if hook is None:
- raise AirflowException(f'Unknown hook type "{self.conn_type}"')
+ if not self.conn_type:
+ # A URI scheme cannot contain '_' (RFC 3986), so "foo_bar://h"
parses with no
+ # scheme at all and leaves conn_type empty. Name that, instead
of reporting an
+ # unknown hook type of "".
+ message = (
+ f"Connection {self.conn_id!r} has no connection type, so
no hook could be "
+ "looked up. If it was defined as a URI, note that a URI
scheme cannot "
+ "contain '_' (RFC 3986) and such a URI parses with no
scheme at all: use "
+ "'-' in the URI instead, which is decoded back to '_' on
read."
+ )
+ else:
+ message = f'Unknown hook type "{self.conn_type}"'
+ # get_uri() encodes '_' as '-' because RFC 3986 forbids '_' in
a scheme, and
+ # reading a connection back from a URI or from JSON decodes it
again, so both
+ # characters serialize to '-' and a connection type spelled
one way cannot
+ # resolve a hook registered the other way. Hooks register
under the
+ # connection-type verbatim, so look the other spelling up
rather than asserting
+ # which one is right, and resolve it rather than testing for
membership: a
+ # registered connection type maps to None when its hook cannot
be imported, and
+ # naming a spelling that still will not resolve is worse than
naming none.
+ alternative = (
+ self.conn_type.replace("-", "_")
+ if "-" in self.conn_type
+ else self.conn_type.replace("_", "-")
+ )
+ if alternative != self.conn_type and hooks.get(alternative) is
not None:
+ message += f", but a hook is registered for
{alternative!r}. "
+ if "-" in self.conn_type:
+ message += "Spell this connection's type with '_' to
reach it."
+ else:
+ message += (
+ "Reading a connection from a URI or from JSON
decodes '-' back to "
+ "'_', so this connection cannot reach that hook;
the provider has "
+ "to declare its connection-type with '_'."
+ )
+ raise AirflowException(message)
try:
hook_class = import_string(hook.hook_class_name)
except ImportError:
diff --git a/task-sdk/tests/task_sdk/definitions/test_connection.py
b/task-sdk/tests/task_sdk/definitions/test_connection.py
index d471cccfcf6..aad4c8f46db 100644
--- a/task-sdk/tests/task_sdk/definitions/test_connection.py
+++ b/task-sdk/tests/task_sdk/definitions/test_connection.py
@@ -78,6 +78,73 @@ class TestConnections:
with pytest.raises(AirflowException, match='Unknown hook type
"unknown_type"'):
conn.get_hook()
+ @pytest.mark.parametrize(
+ ("conn_type", "registered", "expected_remedy"),
+ [
+ pytest.param(
+ "google-cloud-platform",
+ "google_cloud_platform",
+ "Spell this connection's type with '_' to reach it.",
+ id="hyphenated-conn-type",
+ ),
+ pytest.param(
+ "pydanticai_vertex",
+ "pydanticai-vertex",
+ "so this connection cannot reach that hook",
+ id="hyphenated-registration",
+ ),
+ ],
+ )
+ def test_get_hook_names_the_other_spelling_when_it_is_the_registered_one(
+ self, mock_providers_manager, conn_type, registered, expected_remedy
+ ):
+ """Worker-side copy: this is the path a task actually raises from."""
+ mock_providers_manager.return_value.hooks = {registered:
mock.MagicMock()}
+ conn = Connection(conn_id="test_conn", conn_type=conn_type)
+
+ with pytest.raises(AirflowException, match="Unknown hook type") as
exc_info:
+ conn.get_hook()
+
+ message = str(exc_info.value)
+ assert conn_type in message
+ assert registered in message
+ assert expected_remedy in message
+
+ def test_get_hook_does_not_guess_an_unregistered_spelling(self,
mock_providers_manager):
+ """With neither spelling registered, say only what is known."""
+ mock_providers_manager.return_value.hooks = {}
+ conn = Connection(conn_id="test_conn",
conn_type="google-cloud-platform")
+
+ with pytest.raises(AirflowException) as exc_info:
+ conn.get_hook()
+
+ assert str(exc_info.value) == 'Unknown hook type
"google-cloud-platform"'
+
+ def
test_get_hook_does_not_advise_a_spelling_whose_hook_cannot_be_imported(self,
mock_providers_manager):
+ """A registered connection type maps to None when its hook cannot be
imported."""
+ mock_providers_manager.return_value.hooks = {"google_cloud_platform":
None}
+ conn = Connection(conn_id="test_conn",
conn_type="google-cloud-platform")
+
+ with pytest.raises(AirflowException) as exc_info:
+ conn.get_hook()
+
+ assert str(exc_info.value) == 'Unknown hook type
"google-cloud-platform"'
+
+ def test_get_hook_explains_a_uri_whose_scheme_was_dropped(self,
mock_providers_manager):
+ """
+ A URI scheme cannot contain '_' (RFC 3986), so ``foo_bar://h`` parses
with no scheme at
+ all and leaves conn_type empty. This is the worker-side copy of that
failure, which
+ used to read ``Unknown hook type ""`` and named neither the cause nor
the fix.
+ """
+ mock_providers_manager.return_value.hooks = {}
+ conn = Connection(conn_id="test_conn", uri="pydanticai_azure://h")
+ assert conn.conn_type == ""
+
+ with pytest.raises(AirflowException, match="has no connection type")
as exc_info:
+ conn.get_hook()
+
+ assert "RFC 3986" in str(exc_info.value)
+
def test_get_uri(self):
"""Test that get_uri generates the correct URI based on connection
attributes."""