44import time
55from contextlib import contextmanager
66from pathlib import Path
7- from typing import Any , Generator , Iterator
7+ from typing import Any , Callable , Generator , Iterator , TypeVar
88
9+ import httpx
910import pytest
1011
1112from dapr .clients import DaprClient
1213from dapr .conf import settings
1314
15+ T = TypeVar ('T' )
16+
1417INTEGRATION_DIR = Path (__file__ ).resolve ().parent
1518COMPONENTS_DIR = INTEGRATION_DIR / 'components'
1619APPS_DIR = INTEGRATION_DIR / 'apps'
@@ -38,7 +41,6 @@ def start_sidecar(
3841 app_port : int | None = None ,
3942 app_cmd : str | None = None ,
4043 components : Path | None = None ,
41- wait : int = 5 ,
4244 ) -> DaprClient :
4345 """Start a Dapr sidecar and return a connected DaprClient.
4446
@@ -50,7 +52,6 @@ def start_sidecar(
5052 app_cmd: Shell command to start alongside the sidecar.
5153 components: Path to component YAML directory. Defaults to
5254 ``tests/integration/components/``.
53- wait: Seconds to sleep after launching (before the SDK health check).
5455 """
5556 resources = components or self ._default_components
5657
@@ -82,18 +83,22 @@ def start_sidecar(
8283 )
8384 self ._processes .append (proc )
8485
85- # Give the sidecar a moment to bind its ports before the SDK health
86- # check starts hitting the HTTP endpoint.
87- time .sleep (wait )
88-
8986 # Point the SDK health check at the actual sidecar HTTP port.
9087 # DaprHealth.wait_for_sidecar() reads settings.DAPR_HTTP_PORT, which
9188 # is initialized once at import time and won't reflect a non-default
92- # http_port unless we update it here.
89+ # http_port unless we update it here. The DaprClient constructor
90+ # polls /healthz/outbound on this port, so we don't need to sleep first.
9391 settings .DAPR_HTTP_PORT = http_port
9492
9593 client = DaprClient (address = f'127.0.0.1:{ grpc_port } ' )
9694 self ._clients .append (client )
95+
96+ # /healthz/outbound (polled by DaprClient) only checks sidecar-side
97+ # readiness. When we launched an app alongside the sidecar, also wait
98+ # for /v1.0/healthz so invoke_method et al. don't race the app's server.
99+ if app_cmd is not None :
100+ _wait_for_app_health (http_port )
101+
97102 return client
98103
99104 def cleanup (self ) -> None :
@@ -114,15 +119,68 @@ def cleanup(self) -> None:
114119 self ._log_files .clear ()
115120
116121
122+ def _wait_until (
123+ predicate : Callable [[], T | None ],
124+ timeout : float = 10.0 ,
125+ interval : float = 0.1 ,
126+ ) -> T :
127+ """Poll `predicate` until it returns a truthy value. eaises `TimeoutError` if it never does."""
128+ deadline = time .monotonic () + timeout
129+ while True :
130+ result = predicate ()
131+ if result :
132+ return result
133+ if time .monotonic () >= deadline :
134+ raise TimeoutError (f'wait_until timed out after { timeout } s' )
135+ time .sleep (interval )
136+
137+
138+ def _wait_for_app_health (http_port : int , timeout : float = 30.0 ) -> None :
139+ """Poll Dapr's app-facing /v1.0/healthz endpoint until it returns 2xx.
140+
141+ ``/v1.0/healthz`` requires the app behind the sidecar to be reachable,
142+ unlike ``/v1.0/healthz/outbound`` which only checks sidecar readiness.
143+ """
144+ url = f'http://127.0.0.1:{ http_port } /v1.0/healthz'
145+
146+ def _check () -> bool :
147+ try :
148+ response = httpx .get (url , timeout = 2.0 )
149+ except httpx .HTTPError :
150+ return False
151+ return response .is_success
152+
153+ _wait_until (_check , timeout = timeout , interval = 0.2 )
154+
155+
117156@contextmanager
118- def _preserve_http_port () -> Iterator [None ]:
119- # start_sidecar() mutates settings.DAPR_HTTP_PORT.
120- # This restores the original value so it does not leak across test modules.
121- original = settings .DAPR_HTTP_PORT
157+ def _isolate_dapr_settings () -> Iterator [None ]:
158+ """Pin SDK HTTP settings to the local test sidecar for the duration.
159+
160+ ``DaprHealth.get_api_url()`` consults three settings (see
161+ ``dapr/clients/http/helpers.py``):
162+
163+ - ``DAPR_HTTP_ENDPOINT``, if set, wins and bypasses host/port entirely.
164+ - ``DAPR_RUNTIME_HOST`` is the host component of the fallback URL.
165+ - ``DAPR_HTTP_PORT`` is the port component of the fallback URL.
166+
167+ Any of these may be populated from the developer's environment (the Dapr
168+ CLI sets them); without an override the SDK health check could target the
169+ wrong sidecar. All three are snapshotted and restored so the test's
170+ mutations don't leak across modules either.
171+ """
172+ originals = {
173+ 'DAPR_HTTP_ENDPOINT' : settings .DAPR_HTTP_ENDPOINT ,
174+ 'DAPR_RUNTIME_HOST' : settings .DAPR_RUNTIME_HOST ,
175+ 'DAPR_HTTP_PORT' : settings .DAPR_HTTP_PORT ,
176+ }
177+ settings .DAPR_HTTP_ENDPOINT = None
178+ settings .DAPR_RUNTIME_HOST = '127.0.0.1'
122179 try :
123180 yield
124181 finally :
125- settings .DAPR_HTTP_PORT = original
182+ for name , value in originals .items ():
183+ setattr (settings , name , value )
126184
127185
128186@pytest .fixture (scope = 'module' )
@@ -133,14 +191,20 @@ def dapr_env() -> Generator[DaprTestEnvironment, Any, None]:
133191 avoiding port conflicts from rapid start/stop cycles and cutting total
134192 test time significantly.
135193 """
136- with _preserve_http_port ():
194+ with _isolate_dapr_settings ():
137195 env = DaprTestEnvironment ()
138196 try :
139197 yield env
140198 finally :
141199 env .cleanup ()
142200
143201
202+ @pytest .fixture
203+ def wait_until () -> Callable [..., Any ]:
204+ """Returns the ``_wait_until(predicate, timeout=10, interval=0.1)`` helper."""
205+ return _wait_until
206+
207+
144208@pytest .fixture (scope = 'module' )
145209def apps_dir () -> Path :
146210 return APPS_DIR
0 commit comments