From 5b066084bb580728cc2de3b5a6bd9c86a53dadbd Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Thu, 12 Jun 2025 12:46:49 +0200 Subject: [PATCH 1/3] Make DNS error retryable Configuring the driver with a URL that cannot be DNS resolved will raise a (retryable) `ServiceUnavailable` error instead of a `ValueError`. --- CHANGELOG.md | 2 + src/neo4j/_addressing.py | 2 +- src/neo4j/_async_compat/network/_util.py | 9 ++- .../async_/async_compat/__init__.py | 14 ++++ .../async_/async_compat/network/__init__.py | 14 ++++ .../async_/async_compat/network/test_util.py | 64 +++++++++++++++++++ .../integration/sync/async_compat/__init__.py | 14 ++++ .../sync/async_compat/network/__init__.py | 14 ++++ .../sync/async_compat/network/test_util.py | 64 +++++++++++++++++++ 9 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 tests/integration/async_/async_compat/__init__.py create mode 100644 tests/integration/async_/async_compat/network/__init__.py create mode 100644 tests/integration/async_/async_compat/network/test_util.py create mode 100644 tests/integration/sync/async_compat/__init__.py create mode 100644 tests/integration/sync/async_compat/network/__init__.py create mode 100644 tests/integration/sync/async_compat/network/test_util.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 22a42e54d..d0faf6a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,8 @@ See also https://github.com/neo4j/neo4j-python-driver/wiki for a full changelog. - `neo4j.graph.Node`, `neo4j.graph.Relationship`, `neo4j.graph.Path` - `neo4j.time.Date`, `neo4j.time.Time`, `neo4j.time.DateTime` - `neo4j.spatial.Point` (and subclasses) +- Configuring the driver with a URL that cannot be DNS resolved will raise a (retryable) `ServiceUnavailable` error + instead of a `ValueError`. ## Version 5.28 diff --git a/src/neo4j/_addressing.py b/src/neo4j/_addressing.py index fbaa5d91d..b6d53bda8 100644 --- a/src/neo4j/_addressing.py +++ b/src/neo4j/_addressing.py @@ -200,7 +200,7 @@ def parse_list( >>> Address.parse_list("localhost:7687", "[::1]:7687") [IPv4Address(('localhost', 7687)), IPv6Address(('::1', 7687, 0, 0))] - >>> Address.parse_list("localhost:7687 [::1]:7687") + >>> Address.parse_list("localhost:7687", "[::1]:7687") [IPv4Address(('localhost', 7687)), IPv6Address(('::1', 7687, 0, 0))] :param s: The string(s) to parse. diff --git a/src/neo4j/_async_compat/network/_util.py b/src/neo4j/_async_compat/network/_util.py index 00ec54199..f17ef8128 100644 --- a/src/neo4j/_async_compat/network/_util.py +++ b/src/neo4j/_async_compat/network/_util.py @@ -22,6 +22,7 @@ Address, ResolvedAddress, ) +from ...exceptions import ServiceUnavailable from ..util import AsyncUtil @@ -69,7 +70,9 @@ async def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - raise ValueError(f"Cannot resolve address {address}") from e + raise ServiceUnavailable( + f"Failed to DNS resolve address {address}: {e}" + ) from e return list(_resolved_addresses_from_info(info, address._host_name)) @staticmethod @@ -151,7 +154,9 @@ def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - raise ValueError(f"Cannot resolve address {address}") from e + raise ServiceUnavailable( + f"Failed to DNS resolve address {address}" + ) from e return _resolved_addresses_from_info(info, address._host_name) @staticmethod diff --git a/tests/integration/async_/async_compat/__init__.py b/tests/integration/async_/async_compat/__init__.py new file mode 100644 index 000000000..3f9680994 --- /dev/null +++ b/tests/integration/async_/async_compat/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/integration/async_/async_compat/network/__init__.py b/tests/integration/async_/async_compat/network/__init__.py new file mode 100644 index 000000000..3f9680994 --- /dev/null +++ b/tests/integration/async_/async_compat/network/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/integration/async_/async_compat/network/test_util.py b/tests/integration/async_/async_compat/network/test_util.py new file mode 100644 index 000000000..7264c2a70 --- /dev/null +++ b/tests/integration/async_/async_compat/network/test_util.py @@ -0,0 +1,64 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import socket + +import pytest + +from neo4j._addressing import ( + ResolvedAddress, + ResolvedIPv4Address, + ResolvedIPv6Address, +) +from neo4j._async_compat.network import AsyncNetworkUtil +from neo4j.addressing import Address +from neo4j.exceptions import ServiceUnavailable + +from ....._async_compat import mark_async_test + + +@mark_async_test +async def test_resolve_address(): + resolved = [ + addr + async for addr in AsyncNetworkUtil.resolve_address( + Address(("localhost", 1234)), + ) + ] + assert all(isinstance(addr, ResolvedAddress) for addr in resolved) + for addr in resolved: + if isinstance(addr, ResolvedIPv4Address): + assert len(addr) == 2 + assert addr[0].startswith("127.0.0.") + assert addr[1] == 1234 + elif isinstance(addr, ResolvedIPv6Address): + assert len(addr) == 4 + assert addr[:2] == ("::1", 1234) + + +@mark_async_test +async def test_resolve_invalid_address(): + with pytest.raises(ServiceUnavailable) as exc: + await anext( + AsyncNetworkUtil.resolve_address( + Address(("example.invalid", 1234)), + ) + ) + cause = exc.value.__cause__ + assert isinstance(cause, socket.gaierror) + assert cause.errno, socket.EAI_NONAME diff --git a/tests/integration/sync/async_compat/__init__.py b/tests/integration/sync/async_compat/__init__.py new file mode 100644 index 000000000..3f9680994 --- /dev/null +++ b/tests/integration/sync/async_compat/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/integration/sync/async_compat/network/__init__.py b/tests/integration/sync/async_compat/network/__init__.py new file mode 100644 index 000000000..3f9680994 --- /dev/null +++ b/tests/integration/sync/async_compat/network/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/integration/sync/async_compat/network/test_util.py b/tests/integration/sync/async_compat/network/test_util.py new file mode 100644 index 000000000..6c52a9b28 --- /dev/null +++ b/tests/integration/sync/async_compat/network/test_util.py @@ -0,0 +1,64 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://neo4j.com] +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from __future__ import annotations + +import socket + +import pytest + +from neo4j._addressing import ( + ResolvedAddress, + ResolvedIPv4Address, + ResolvedIPv6Address, +) +from neo4j._async_compat.network import NetworkUtil +from neo4j.addressing import Address +from neo4j.exceptions import ServiceUnavailable + +from ....._async_compat import mark_sync_test + + +@mark_sync_test +def test_resolve_address(): + resolved = [ + addr + for addr in NetworkUtil.resolve_address( + Address(("localhost", 1234)), + ) + ] + assert all(isinstance(addr, ResolvedAddress) for addr in resolved) + for addr in resolved: + if isinstance(addr, ResolvedIPv4Address): + assert len(addr) == 2 + assert addr[0].startswith("127.0.0.") + assert addr[1] == 1234 + elif isinstance(addr, ResolvedIPv6Address): + assert len(addr) == 4 + assert addr[:2] == ("::1", 1234) + + +@mark_sync_test +def test_resolve_invalid_address(): + with pytest.raises(ServiceUnavailable) as exc: + next( + NetworkUtil.resolve_address( + Address(("example.invalid", 1234)), + ) + ) + cause = exc.value.__cause__ + assert isinstance(cause, socket.gaierror) + assert cause.errno, socket.EAI_NONAME From 25adc57335a57330a271aabb9acd605e561b1230 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Mon, 14 Jul 2025 16:46:34 +0200 Subject: [PATCH 2/3] fixup! Make DNS error retryable --- src/neo4j/_async_compat/network/_util.py | 14 +++++++++++--- tests/unit/async_/test_addressing.py | 6 ++++++ tests/unit/sync/test_addressing.py | 6 ++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/neo4j/_async_compat/network/_util.py b/src/neo4j/_async_compat/network/_util.py index f17ef8128..fdacce755 100644 --- a/src/neo4j/_async_compat/network/_util.py +++ b/src/neo4j/_async_compat/network/_util.py @@ -70,7 +70,11 @@ async def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - raise ServiceUnavailable( + if e.errno == socket.EAI_NONAME and isinstance(address.host, str): + raise ServiceUnavailable( + f"Failed to DNS resolve address {address}: {e}" + ) from e + raise ValueError( f"Failed to DNS resolve address {address}: {e}" ) from e return list(_resolved_addresses_from_info(info, address._host_name)) @@ -154,8 +158,12 @@ def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - raise ServiceUnavailable( - f"Failed to DNS resolve address {address}" + if e.errno == socket.EAI_NONAME and isinstance(address.host, str): + raise ServiceUnavailable( + f"Failed to DNS resolve address {address}: {e}" + ) from e + raise ValueError( + f"Failed to DNS resolve address {address}: {e}" ) from e return _resolved_addresses_from_info(info, address._host_name) diff --git a/tests/unit/async_/test_addressing.py b/tests/unit/async_/test_addressing.py index 5a0a940b5..b65a855ff 100644 --- a/tests/unit/async_/test_addressing.py +++ b/tests/unit/async_/test_addressing.py @@ -24,6 +24,7 @@ ) from neo4j._async_compat.network import AsyncNetworkUtil from neo4j._async_compat.util import AsyncUtil +from neo4j.exceptions import ServiceUnavailable from ..._async_compat import mark_async_test @@ -53,14 +54,19 @@ async def test_address_resolve_with_custom_resolver_none() -> None: @pytest.mark.parametrize( ("test_input", "expected"), [ + (Address(("example.invalid", "7687")), ServiceUnavailable), + (Address(("example.invalid", 7687)), ServiceUnavailable), (Address(("127.0.0.1", "abcd")), ValueError), (Address((None, None)), ValueError), + (Address((1234, "7687")), TypeError), ], ) @mark_async_test async def test_address_resolve_with_unresolvable_address( test_input, expected ) -> None: + # import contextlib + # with contextlib.suppress(Exception): with pytest.raises(expected): await AsyncUtil.list( AsyncNetworkUtil.resolve_address(test_input, resolver=None) diff --git a/tests/unit/sync/test_addressing.py b/tests/unit/sync/test_addressing.py index fc741716e..0b3e497c5 100644 --- a/tests/unit/sync/test_addressing.py +++ b/tests/unit/sync/test_addressing.py @@ -24,6 +24,7 @@ ) from neo4j._async_compat.network import NetworkUtil from neo4j._async_compat.util import Util +from neo4j.exceptions import ServiceUnavailable from ..._async_compat import mark_sync_test @@ -53,14 +54,19 @@ def test_address_resolve_with_custom_resolver_none() -> None: @pytest.mark.parametrize( ("test_input", "expected"), [ + (Address(("example.invalid", "7687")), ServiceUnavailable), + (Address(("example.invalid", 7687)), ServiceUnavailable), (Address(("127.0.0.1", "abcd")), ValueError), (Address((None, None)), ValueError), + (Address((1234, "7687")), TypeError), ], ) @mark_sync_test def test_address_resolve_with_unresolvable_address( test_input, expected ) -> None: + # import contextlib + # with contextlib.suppress(Exception): with pytest.raises(expected): Util.list( NetworkUtil.resolve_address(test_input, resolver=None) From 0185e16f7d98856a54901ed8ac0ba415bee7620f Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Mon, 14 Jul 2025 18:33:31 +0200 Subject: [PATCH 3/3] fixup! Make DNS error retryable --- src/neo4j/_async_compat/network/_util.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/neo4j/_async_compat/network/_util.py b/src/neo4j/_async_compat/network/_util.py index fdacce755..57322b45a 100644 --- a/src/neo4j/_async_compat/network/_util.py +++ b/src/neo4j/_async_compat/network/_util.py @@ -41,6 +41,14 @@ def _resolved_addresses_from_info(info, host_name): yield ResolvedAddress(addr, host_name=host_name) +_RETRYABLE_DNS_ERRNOS = { + socket.EAI_ADDRFAMILY, + socket.EAI_AGAIN, + socket.EAI_MEMORY, + socket.EAI_NODATA, +} + + class AsyncNetworkUtil: @staticmethod async def get_address_info( @@ -70,7 +78,10 @@ async def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - if e.errno == socket.EAI_NONAME and isinstance(address.host, str): + if e.errno in _RETRYABLE_DNS_ERRNOS or ( + e.errno == socket.EAI_NONAME + and (address.host is not None or address.port is not None) + ): raise ServiceUnavailable( f"Failed to DNS resolve address {address}: {e}" ) from e @@ -158,7 +169,10 @@ def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - if e.errno == socket.EAI_NONAME and isinstance(address.host, str): + if e.errno in _RETRYABLE_DNS_ERRNOS or ( + e.errno == socket.EAI_NONAME + and (address.host is not None or address.port is not None) + ): raise ServiceUnavailable( f"Failed to DNS resolve address {address}: {e}" ) from e