From e2a597c2a23fe2d7f3228dbc15b231bd7690650d Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 2 Jul 2026 15:44:15 -0500 Subject: [PATCH 1/2] fix: address mac dns resolver issue Signed-off-by: Samantha Coyle --- dapr/__init__.py | 21 ++++++++++ tests/test_grpc_dns_resolver_default.py | 52 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/test_grpc_dns_resolver_default.py diff --git a/dapr/__init__.py b/dapr/__init__.py index 6b681aa2..9202bb41 100644 --- a/dapr/__init__.py +++ b/dapr/__init__.py @@ -54,6 +54,25 @@ } +_GRPC_DNS_RESOLVER_ENV = 'GRPC_DNS_RESOLVER' +_GRPC_DNS_RESOLVER_NATIVE = 'native' + + +def _default_grpc_dns_resolver_native() -> None: + """Default gRPC to the OS-native DNS resolver on macOS. + + grpcio bundles the c-ares resolver, which on macOS frequently ignores the + system DNS configuration (VPN, split-DNS, IPv6) and cancels lookups that + resolve fine system-wide. The failure surfaces as ``StatusCode.UNAVAILABLE`` + with "DNS query cancelled" even though the endpoint is reachable. Deferring + to getaddrinfo respects the OS resolver and avoids this. This must run before grpc's C-core + reads its config, which is why it lives in this package init. + """ + if sys.platform != 'darwin': + return + os.environ.setdefault(_GRPC_DNS_RESOLVER_ENV, _GRPC_DNS_RESOLVER_NATIVE) + + def _safe_dist_version(dist: importlib.metadata.Distribution) -> str: try: return dist.version @@ -180,6 +199,8 @@ def _check_for_legacy_extension_issues() -> None: ) +_default_grpc_dns_resolver_native() + try: _check_for_legacy_extension_issues() except Exception: diff --git a/tests/test_grpc_dns_resolver_default.py b/tests/test_grpc_dns_resolver_default.py new file mode 100644 index 00000000..a43f1898 --- /dev/null +++ b/tests/test_grpc_dns_resolver_default.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +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 + http://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. +""" + +import unittest +from unittest import mock + +import dapr as dapr_pkg + +_ENV = dapr_pkg._GRPC_DNS_RESOLVER_ENV +_NATIVE = dapr_pkg._GRPC_DNS_RESOLVER_NATIVE + + +class TestDefaultGrpcDnsResolverNative(unittest.TestCase): + def test_sets_native_on_darwin_when_unset(self): + with ( + mock.patch.object(dapr_pkg.sys, 'platform', 'darwin'), + mock.patch.dict(dapr_pkg.os.environ, {}, clear=True), + ): + dapr_pkg._default_grpc_dns_resolver_native() + self.assertEqual(dapr_pkg.os.environ[_ENV], _NATIVE) + + def test_preserves_explicit_value_on_darwin(self): + with ( + mock.patch.object(dapr_pkg.sys, 'platform', 'darwin'), + mock.patch.dict(dapr_pkg.os.environ, {_ENV: 'ares'}, clear=True), + ): + dapr_pkg._default_grpc_dns_resolver_native() + self.assertEqual(dapr_pkg.os.environ[_ENV], 'ares') + + def test_noop_on_non_darwin(self): + with ( + mock.patch.object(dapr_pkg.sys, 'platform', 'linux'), + mock.patch.dict(dapr_pkg.os.environ, {}, clear=True), + ): + dapr_pkg._default_grpc_dns_resolver_native() + self.assertNotIn(_ENV, dapr_pkg.os.environ) + + +if __name__ == '__main__': + unittest.main() From b968b72337d58d131eef980a9752b4671bf158cb Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 2 Jul 2026 15:59:51 -0500 Subject: [PATCH 2/2] fix: scope down to grpc client helpers Signed-off-by: Samantha Coyle --- dapr/__init__.py | 21 ---------------- dapr/aio/clients/grpc/client.py | 3 +++ dapr/clients/grpc/_helpers.py | 24 +++++++++++++++++++ dapr/clients/grpc/client.py | 3 +++ tests/test_grpc_dns_resolver_default.py | 32 ++++++++++++------------- 5 files changed, 46 insertions(+), 37 deletions(-) diff --git a/dapr/__init__.py b/dapr/__init__.py index 9202bb41..6b681aa2 100644 --- a/dapr/__init__.py +++ b/dapr/__init__.py @@ -54,25 +54,6 @@ } -_GRPC_DNS_RESOLVER_ENV = 'GRPC_DNS_RESOLVER' -_GRPC_DNS_RESOLVER_NATIVE = 'native' - - -def _default_grpc_dns_resolver_native() -> None: - """Default gRPC to the OS-native DNS resolver on macOS. - - grpcio bundles the c-ares resolver, which on macOS frequently ignores the - system DNS configuration (VPN, split-DNS, IPv6) and cancels lookups that - resolve fine system-wide. The failure surfaces as ``StatusCode.UNAVAILABLE`` - with "DNS query cancelled" even though the endpoint is reachable. Deferring - to getaddrinfo respects the OS resolver and avoids this. This must run before grpc's C-core - reads its config, which is why it lives in this package init. - """ - if sys.platform != 'darwin': - return - os.environ.setdefault(_GRPC_DNS_RESOLVER_ENV, _GRPC_DNS_RESOLVER_NATIVE) - - def _safe_dist_version(dist: importlib.metadata.Distribution) -> str: try: return dist.version @@ -199,8 +180,6 @@ def _check_for_legacy_extension_issues() -> None: ) -_default_grpc_dns_resolver_native() - try: _check_for_legacy_extension_issues() except Exception: diff --git a/dapr/aio/clients/grpc/client.py b/dapr/aio/clients/grpc/client.py index 61924a60..11aefadf 100644 --- a/dapr/aio/clients/grpc/client.py +++ b/dapr/aio/clients/grpc/client.py @@ -57,6 +57,7 @@ MetadataTuple, convert_dict_to_grpc_dict_of_any, convert_value_to_struct, + set_default_grpc_dns_resolver, to_bytes, validateNotBlankString, validateNotNone, @@ -189,6 +190,8 @@ def __init__( ) interceptors.append(api_token_interceptor) + set_default_grpc_dns_resolver() + # Create gRPC channel if self._uri.tls: self._channel = grpc.aio.secure_channel( diff --git a/dapr/clients/grpc/_helpers.py b/dapr/clients/grpc/_helpers.py index 1ab1f9e6..fdfb6dbd 100644 --- a/dapr/clients/grpc/_helpers.py +++ b/dapr/clients/grpc/_helpers.py @@ -13,6 +13,9 @@ limitations under the License. """ +import logging +import os +import sys from enum import Enum from typing import Any, Dict, List, Optional, Tuple, Union, cast @@ -33,6 +36,27 @@ MetadataDict = Dict[str, List[Union[bytes, str]]] MetadataTuple = Tuple[Tuple[str, Union[bytes, str]], ...] +_logger = logging.getLogger(__name__) + +_GRPC_DNS_RESOLVER_ENV = 'GRPC_DNS_RESOLVER' +_GRPC_DNS_RESOLVER_NATIVE = 'native' + + +def set_default_grpc_dns_resolver() -> None: + """Default gRPC to the OS-native DNS resolver on macOS. + + grpcio's bundled c-ares resolver often ignores macOS DNS config and fails + with "DNS query cancelled" for endpoints that resolve fine system-wide. + Resolver choice is process-wide with no per-channel override, so it is set + only on Darwin and only when unset, leaving an explicit value untouched. + """ + if sys.platform != 'darwin': + return + if _GRPC_DNS_RESOLVER_ENV in os.environ: + return + os.environ[_GRPC_DNS_RESOLVER_ENV] = _GRPC_DNS_RESOLVER_NATIVE + _logger.debug('Defaulted %s=%s on macOS', _GRPC_DNS_RESOLVER_ENV, _GRPC_DNS_RESOLVER_NATIVE) + def tuple_to_dict(tupledata: MetadataTuple) -> MetadataDict: """Converts tuple to dict. diff --git a/dapr/clients/grpc/client.py b/dapr/clients/grpc/client.py index c4130329..16379ff6 100644 --- a/dapr/clients/grpc/client.py +++ b/dapr/clients/grpc/client.py @@ -44,6 +44,7 @@ MetadataTuple, convert_dict_to_grpc_dict_of_any, convert_value_to_struct, + set_default_grpc_dns_resolver, to_bytes, validateNotBlankString, validateNotNone, @@ -169,6 +170,8 @@ def __init__( except ValueError as error: raise DaprInternalError(f'{error}') from error + set_default_grpc_dns_resolver() + if self._uri.tls: self._channel = grpc.secure_channel( # type: ignore self._uri.endpoint, diff --git a/tests/test_grpc_dns_resolver_default.py b/tests/test_grpc_dns_resolver_default.py index a43f1898..47c3df40 100644 --- a/tests/test_grpc_dns_resolver_default.py +++ b/tests/test_grpc_dns_resolver_default.py @@ -16,36 +16,36 @@ import unittest from unittest import mock -import dapr as dapr_pkg +from dapr.clients.grpc import _helpers -_ENV = dapr_pkg._GRPC_DNS_RESOLVER_ENV -_NATIVE = dapr_pkg._GRPC_DNS_RESOLVER_NATIVE +_ENV = _helpers._GRPC_DNS_RESOLVER_ENV +_NATIVE = _helpers._GRPC_DNS_RESOLVER_NATIVE -class TestDefaultGrpcDnsResolverNative(unittest.TestCase): +class TestSetDefaultGrpcDnsResolver(unittest.TestCase): def test_sets_native_on_darwin_when_unset(self): with ( - mock.patch.object(dapr_pkg.sys, 'platform', 'darwin'), - mock.patch.dict(dapr_pkg.os.environ, {}, clear=True), + mock.patch.object(_helpers.sys, 'platform', 'darwin'), + mock.patch.dict(_helpers.os.environ, {}, clear=True), ): - dapr_pkg._default_grpc_dns_resolver_native() - self.assertEqual(dapr_pkg.os.environ[_ENV], _NATIVE) + _helpers.set_default_grpc_dns_resolver() + self.assertEqual(_helpers.os.environ[_ENV], _NATIVE) def test_preserves_explicit_value_on_darwin(self): with ( - mock.patch.object(dapr_pkg.sys, 'platform', 'darwin'), - mock.patch.dict(dapr_pkg.os.environ, {_ENV: 'ares'}, clear=True), + mock.patch.object(_helpers.sys, 'platform', 'darwin'), + mock.patch.dict(_helpers.os.environ, {_ENV: 'ares'}, clear=True), ): - dapr_pkg._default_grpc_dns_resolver_native() - self.assertEqual(dapr_pkg.os.environ[_ENV], 'ares') + _helpers.set_default_grpc_dns_resolver() + self.assertEqual(_helpers.os.environ[_ENV], 'ares') def test_noop_on_non_darwin(self): with ( - mock.patch.object(dapr_pkg.sys, 'platform', 'linux'), - mock.patch.dict(dapr_pkg.os.environ, {}, clear=True), + mock.patch.object(_helpers.sys, 'platform', 'linux'), + mock.patch.dict(_helpers.os.environ, {}, clear=True), ): - dapr_pkg._default_grpc_dns_resolver_native() - self.assertNotIn(_ENV, dapr_pkg.os.environ) + _helpers.set_default_grpc_dns_resolver() + self.assertNotIn(_ENV, _helpers.os.environ) if __name__ == '__main__':