Skip to content

Commit c89add1

Browse files
devin-ai-integration[bot]bot_apk
andcommitted
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 <apk@cognition.ai>
1 parent 977f051 commit c89add1

2 files changed

Lines changed: 61 additions & 5 deletions

File tree

airbyte_cdk/entrypoint.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,13 @@ def filtered_send(self: Any, request: PreparedRequest, **kwargs: Any) -> Respons
430430
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.",
431431
)
432432
except socket.gaierror as exception:
433-
# This is a special case where the developer specifies an IP address string that is not formatted correctly like trailing
434-
# whitespace which will fail the socket IP lookup. This only happens when using IP addresses and not text hostnames.
435-
# Knowing that this is a request using the requests library, we will mock the exception without calling the lib
436-
raise requests.exceptions.InvalidURL(f"Invalid URL {parsed_url}: {exception}")
433+
# socket.gaierror fires on transient DNS failures (EAI_AGAIN / errno -3)
434+
# for valid hostnames, not just malformed IPs. Re-raise as ConnectionError
435+
# so every caller (CDK streams *and* external SDKs) treats it as retryable.
436+
hostname = parsed_url.hostname or ""
437+
raise requests.ConnectionError(
438+
f"DNS resolution failed for {str(hostname)}: {str(exception)}"
439+
)
437440

438441
return wrapped_fn(self, request, **kwargs)
439442

unit_tests/test_entrypoint.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#
44

55
import os
6+
import socket
67
from argparse import Namespace
78
from collections import defaultdict
89
from copy import deepcopy
@@ -559,7 +560,7 @@ def test_invalid_command(entrypoint: AirbyteEntrypoint, config_mock):
559560
pytest.param(
560561
"CLOUD",
561562
"https://192.168.27.30 ",
562-
ValueError,
563+
requests.ConnectionError,
563564
id="test_cloud_incorrect_ip_format_is_rejected",
564565
),
565566
pytest.param(
@@ -944,3 +945,55 @@ def test_memory_failfast_flushes_queued_state_before_raising(mocker):
944945
with pytest.raises(AirbyteTracedException) as exc_info:
945946
next(gen)
946947
assert exc_info.value is fail_fast_exc
948+
949+
950+
@pytest.mark.parametrize(
951+
"gaierror_errno,gaierror_msg",
952+
[
953+
pytest.param(
954+
socket.EAI_AGAIN, "Temporary failure in name resolution", id="transient_dns_EAI_AGAIN"
955+
),
956+
pytest.param(socket.EAI_NONAME, "Name or service not known", id="unknown_host_EAI_NONAME"),
957+
pytest.param(
958+
socket.EAI_FAIL,
959+
"Non-recoverable failure in name resolution",
960+
id="permanent_dns_EAI_FAIL",
961+
),
962+
],
963+
)
964+
def test_filtered_send_raises_connection_error_on_dns_failure(gaierror_errno, gaierror_msg):
965+
"""filtered_send must re-raise socket.gaierror as ConnectionError, not InvalidURL."""
966+
entrypoint_module._init_internal_request_filter()
967+
968+
prepared = requests.PreparedRequest()
969+
prepared.prepare_url("https://graph.facebook.com/v25.0/", None)
970+
971+
dns_error = socket.gaierror(gaierror_errno, gaierror_msg)
972+
973+
with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error):
974+
with pytest.raises(
975+
requests.ConnectionError, match="DNS resolution failed for graph.facebook.com"
976+
):
977+
requests.Session().send(prepared)
978+
979+
980+
def test_filtered_send_does_not_raise_invalid_url_on_dns_failure():
981+
"""Verify the old behavior (InvalidURL) no longer occurs for DNS errors."""
982+
entrypoint_module._init_internal_request_filter()
983+
984+
prepared = requests.PreparedRequest()
985+
prepared.prepare_url("https://graph.facebook.com/v25.0/", None)
986+
987+
dns_error = socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution")
988+
989+
with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error):
990+
with pytest.raises(requests.ConnectionError):
991+
requests.Session().send(prepared)
992+
# Confirm it is NOT InvalidURL
993+
try:
994+
with patch.object(entrypoint_module, "_is_private_url", side_effect=dns_error):
995+
requests.Session().send(prepared)
996+
except requests.ConnectionError:
997+
pass
998+
except requests.exceptions.InvalidURL:
999+
pytest.fail("DNS failure should raise ConnectionError, not InvalidURL")

0 commit comments

Comments
 (0)