Skip to content

Commit 9a09ef2

Browse files
committed
Replace sleep() with polls when possible
Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com>
1 parent 1430a0d commit 9a09ef2

4 files changed

Lines changed: 86 additions & 24 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@AGENTS.md
22

33
Use pathlib instead of os.path.
4+
Use httpx instead of urllib.
45
Use modern Python (3.10+) features.
56
Make all code strongly typed.
67
Keep conditional nesting to a minimum, and use guard clauses when possible.

tests/integration/conftest.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import time
55
from contextlib import contextmanager
66
from pathlib import Path
7-
from typing import Any, Generator, Iterator
7+
from typing import Any, Callable, Generator, Iterator, TypeVar
88

9+
import httpx
910
import pytest
1011

1112
from dapr.clients import DaprClient
1213
from dapr.conf import settings
1314

15+
T = TypeVar('T')
16+
1417
INTEGRATION_DIR = Path(__file__).resolve().parent
1518
COMPONENTS_DIR = INTEGRATION_DIR / 'components'
1619
APPS_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')
145209
def apps_dir() -> Path:
146210
return APPS_DIR

tests/integration/test_configuration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,5 @@ def test_unsubscribe_returns_true(self, client):
8686
keys=['cfg-unsub-key'],
8787
handler=lambda _id, _resp: None,
8888
)
89-
time.sleep(1)
9089
ok = client.unsubscribe_configuration(store_name=STORE, id=sub_id)
9190
assert ok

tests/integration/test_pubsub.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import json
22
import subprocess
3-
import time
43
import uuid
54

65
import pytest
@@ -35,11 +34,10 @@ def client(dapr_env, apps_dir):
3534
grpc_port=50001,
3635
app_port=50051,
3736
app_cmd=f'python3 {apps_dir / "pubsub_subscriber.py"}',
38-
wait=10,
3937
)
4038

4139

42-
def test_published_messages_are_received_by_subscriber(client):
40+
def test_published_messages_are_received_by_subscriber(client, wait_until):
4341
run_id = uuid.uuid4().hex
4442
for n in range(1, 4):
4543
client.publish_event(
@@ -48,14 +46,14 @@ def test_published_messages_are_received_by_subscriber(client):
4846
data=json.dumps({'run_id': run_id, 'id': n, 'message': 'hello world'}),
4947
data_content_type='application/json',
5048
)
51-
time.sleep(1)
52-
53-
time.sleep(3)
5449

5550
for n in range(1, 4):
56-
state = client.get_state(store_name=STORE, key=f'received-{run_id}-{n}')
57-
assert state.data != b'', f'Subscriber did not receive message {n}'
58-
msg = json.loads(state.data)
51+
key = f'received-{run_id}-{n}'
52+
data = wait_until(
53+
lambda k=key: client.get_state(store_name=STORE, key=k).data or None,
54+
timeout=10,
55+
)
56+
msg = json.loads(data)
5957
assert msg['id'] == n
6058
assert msg['message'] == 'hello world'
6159

0 commit comments

Comments
 (0)