From 5b066084bb580728cc2de3b5a6bd9c86a53dadbd Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Thu, 12 Jun 2025 12:46:49 +0200 Subject: [PATCH] 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 22a42e54..d0faf6a6 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 fbaa5d91..b6d53bda 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 00ec5419..f17ef812 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 00000000..3f968099 --- /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 00000000..3f968099 --- /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 00000000..7264c2a7 --- /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 00000000..3f968099 --- /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 00000000..3f968099 --- /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 00000000..6c52a9b2 --- /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