From c89add113107c70888c413d6fee46ad7f9334705 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:39:26 +0000 Subject: [PATCH 1/2] fix: re-raise DNS failures as ConnectionError in filtered_send for universal retryability The SSRF protection in filtered_send caught socket.gaierror from _is_private_url and re-raised it as requests.exceptions.InvalidURL. While CDK streams handle InvalidURL as retryable (via PR #384), external SDKs (e.g. facebook-business) do not, causing transient DNS blips to produce unrecoverable sync failures. Change the exception type to requests.ConnectionError, which is universally retried by both CDK streams and external SDK error handlers. Co-Authored-By: bot_apk --- airbyte_cdk/entrypoint.py | 11 ++++--- unit_tests/test_entrypoint.py | 55 ++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/airbyte_cdk/entrypoint.py b/airbyte_cdk/entrypoint.py index 57820f0053..3ee0b68d11 100644 --- a/airbyte_cdk/entrypoint.py +++ b/airbyte_cdk/entrypoint.py @@ -430,10 +430,13 @@ def filtered_send(self: Any, request: PreparedRequest, **kwargs: Any) -> Respons message="Invalid URL endpoint: The endpoint that data is being requested from belongs to a private network. Source connectors only support requesting data from public API endpoints.", ) except socket.gaierror as exception: - # This is a special case where the developer specifies an IP address string that is not formatted correctly like trailing - # whitespace which will fail the socket IP lookup. This only happens when using IP addresses and not text hostnames. - # Knowing that this is a request using the requests library, we will mock the exception without calling the lib - raise requests.exceptions.InvalidURL(f"Invalid URL {parsed_url}: {exception}") + # socket.gaierror fires on transient DNS failures (EAI_AGAIN / errno -3) + # for valid hostnames, not just malformed IPs. Re-raise as ConnectionError + # so every caller (CDK streams *and* external SDKs) treats it as retryable. + hostname = parsed_url.hostname or "" + raise requests.ConnectionError( + f"DNS resolution failed for {str(hostname)}: {str(exception)}" + ) return wrapped_fn(self, request, **kwargs) diff --git a/unit_tests/test_entrypoint.py b/unit_tests/test_entrypoint.py index fcfb449151..e8a565b536 100644 --- a/unit_tests/test_entrypoint.py +++ b/unit_tests/test_entrypoint.py @@ -3,6 +3,7 @@ # import os +import socket from argparse import Namespace from collections import defaultdict from copy import deepcopy @@ -559,7 +560,7 @@ def test_invalid_command(entrypoint: AirbyteEntrypoint, config_mock): pytest.param( "CLOUD", "https://192.168.27.30 ", - ValueError, + requests.ConnectionError, id="test_cloud_incorrect_ip_format_is_rejected", ), pytest.param( @@ -944,3 +945,55 @@ def test_memory_failfast_flushes_queued_state_before_raising(mocker): with pytest.raises(AirbyteTracedException) as exc_info: next(gen) assert exc_info.value is fail_fast_exc + + +@pytest.mark.parametrize( + "gaierror_errno,gaierror_msg", + [ + pytest.param( + socket.EAI_AGAIN, "Temporary failure in name resolution", id="transient_dns_EAI_AGAIN" + ), + pytest.param(socket.EAI_NONAME, "Name or service not known", id="unknown_host_EAI_NONAME"), + pytest.param( + socket.EAI_FAIL, + "Non-recoverable failure in name resolution", + id="permanent_dns_EAI_FAIL", + ), + ], +) +def test_filtered_send_raises_connection_error_on_dns_failure(gaierror_errno, gaierror_msg): + """filtered_send must re-raise socket.gaierror as ConnectionError, not InvalidURL.""" + entrypoint_module._init_internal_request_filter() + + prepared = requests.PreparedRequest() + prepared.prepare_url("https://graph.facebook.com/v25.0/", None) + + dns_error = socket.gaierror(gaierror_errno, gaierror_msg) + + with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): + with pytest.raises( + requests.ConnectionError, match="DNS resolution failed for graph.facebook.com" + ): + requests.Session().send(prepared) + + +def test_filtered_send_does_not_raise_invalid_url_on_dns_failure(): + """Verify the old behavior (InvalidURL) no longer occurs for DNS errors.""" + entrypoint_module._init_internal_request_filter() + + prepared = requests.PreparedRequest() + prepared.prepare_url("https://graph.facebook.com/v25.0/", None) + + dns_error = socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution") + + with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): + with pytest.raises(requests.ConnectionError): + requests.Session().send(prepared) + # Confirm it is NOT InvalidURL + try: + with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): + requests.Session().send(prepared) + except requests.ConnectionError: + pass + except requests.exceptions.InvalidURL: + pytest.fail("DNS failure should raise ConnectionError, not InvalidURL") From 701f38b2dde488b0ca7e9a984d8868b7a8ea535c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:47:41 +0000 Subject: [PATCH 2/2] fix: update test expectations for DNS error message change Update connector_builder tests that checked for 'Invalid URL' in stacktraces to expect 'DNS resolution failed for domainwithoutextension' instead. Also clean up empty except clause in test per bot feedback. Co-Authored-By: bot_apk --- .../test_connector_builder_handler.py | 4 ++-- unit_tests/test_entrypoint.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/unit_tests/connector_builder/test_connector_builder_handler.py b/unit_tests/connector_builder/test_connector_builder_handler.py index 0006996090..b99834b549 100644 --- a/unit_tests/connector_builder/test_connector_builder_handler.py +++ b/unit_tests/connector_builder/test_connector_builder_handler.py @@ -1220,7 +1220,7 @@ def test_read_source_single_page_single_slice(mock_http_stream): pytest.param( "CLOUD", "https://domainwithoutextension", - "Invalid URL", + "DNS resolution failed for domainwithoutextension", id="test_cloud_read_with_invalid_url_endpoint", ), pytest.param( @@ -1300,7 +1300,7 @@ def test_handle_read_external_requests(deployment_mode, url_base, expected_error pytest.param( "CLOUD", "https://domainwithoutextension", - "Invalid URL", + "DNS resolution failed for domainwithoutextension", id="test_cloud_read_with_invalid_url_endpoint", ), pytest.param( diff --git a/unit_tests/test_entrypoint.py b/unit_tests/test_entrypoint.py index e8a565b536..4a31252112 100644 --- a/unit_tests/test_entrypoint.py +++ b/unit_tests/test_entrypoint.py @@ -4,6 +4,7 @@ import os import socket +import sys from argparse import Namespace from collections import defaultdict from copy import deepcopy @@ -977,8 +978,8 @@ def test_filtered_send_raises_connection_error_on_dns_failure(gaierror_errno, ga requests.Session().send(prepared) -def test_filtered_send_does_not_raise_invalid_url_on_dns_failure(): - """Verify the old behavior (InvalidURL) no longer occurs for DNS errors.""" +def test_filtered_send_dns_failure_is_not_invalid_url(): + """Verify DNS errors are raised as ConnectionError, not InvalidURL.""" entrypoint_module._init_internal_request_filter() prepared = requests.PreparedRequest() @@ -989,11 +990,13 @@ def test_filtered_send_does_not_raise_invalid_url_on_dns_failure(): with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): with pytest.raises(requests.ConnectionError): requests.Session().send(prepared) - # Confirm it is NOT InvalidURL + + # The raised exception must not be a subclass of InvalidURL + with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): try: - with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error): - requests.Session().send(prepared) + requests.Session().send(prepared) + pytest.fail("Expected ConnectionError to be raised") except requests.ConnectionError: - pass - except requests.exceptions.InvalidURL: - pytest.fail("DNS failure should raise ConnectionError, not InvalidURL") + assert not isinstance(sys.exc_info()[1], requests.exceptions.InvalidURL), ( + "DNS failure should raise ConnectionError, not InvalidURL" + )