lifx-async 4.3.3__tar.gz → 4.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lifx_async-4.3.3 → lifx_async-4.3.4}/PKG-INFO +1 -1
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/changelog.md +8 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/pyproject.toml +1 -1
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/base.py +30 -6
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/connection.py +22 -11
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_base.py +20 -9
- lifx_async-4.3.4/tests/test_network/test_concurrent_requests.py +395 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/uv.lock +1 -1
- lifx_async-4.3.3/tests/test_network/test_concurrent_requests.py +0 -199
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.claude/settings.json +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/dependabot.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/labeler.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/ci.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/docs.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.github/workflows/pr-automation.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.gitignore +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/.pre-commit-config.yaml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/CLAUDE.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/LICENSE +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/README.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/colors.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/devices.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/effects.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/exceptions.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/high-level.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/index.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/network.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/protocol.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/api/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/architecture/effects-architecture.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/architecture/overview.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/faq.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/effects.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/installation.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/quickstart.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/getting-started/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/index.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/migration/effect-api-changes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/stylesheets/extra.css +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/advanced-usage.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/effects-custom.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/effects-troubleshooting.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/protocol-deep-dive.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/themes.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/docs/user-guide/troubleshooting.md +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/01_simple_discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/02_simple_control.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/03_waveforms.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/04_logging.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/06_pulse_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/07_colorloop_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/08_custom_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/09_background_effect.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/10_find_specific_devices.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/11_matrix_basic.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/12_matrix_effects.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/examples/13_matrix_large.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/mkdocs.yml +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/renovate.json +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/api.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/color.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/const.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/hev.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/infrared.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/light.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/matrix.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/devices/multizone.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/colorloop.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/conductor.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/const.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/pulse.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/effects/state_manager.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/exceptions.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/message.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/network/transport.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/products/registry.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/header.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/packets.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/protocol_types.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/protocol/serializer.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/py.typed +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/canvas.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/generators.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/library.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/src/lifx/theme/theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_apply_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_batch_errors.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_batch_operations.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_discovery.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_api/test_api_organization.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_color.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_hev.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_infrared.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_light.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_mac_address.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_matrix.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_devices/test_multizone.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_base.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_capability_filtering.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_colorloop.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_integration.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_models.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_pulse.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_effects/test_state_manager.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_connection.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_discovery_devices.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_discovery_errors.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_message.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_message_advanced.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_network/test_transport.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_products/test_product_generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_products/test_registry.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_generated.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_header.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_protocol_generator.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_protocol/test_serializer.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/__init__.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/conftest.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_apply_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_canvas.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_generators.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_library.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_theme/test_theme.py +0 -0
- {lifx_async-4.3.3 → lifx_async-4.3.4}/tests/test_utils.py +0 -0
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v4.3.4 (2025-11-22)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **network**: Exclude retry sleep time from timeout budget
|
|
10
|
+
([`312d7a7`](https://github.com/Djelibeybi/lifx-async/commit/312d7a7e2561de7d2bbf142c8a521daca31651bb))
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
## v4.3.3 (2025-11-22)
|
|
6
14
|
|
|
7
15
|
### Bug Fixes
|
|
@@ -252,6 +252,8 @@ class Device:
|
|
|
252
252
|
self.serial = serial_obj.to_string()
|
|
253
253
|
self.ip = ip
|
|
254
254
|
self.port = port
|
|
255
|
+
self._timeout = timeout
|
|
256
|
+
self._max_retries = max_retries
|
|
255
257
|
|
|
256
258
|
# Create lightweight connection handle - connection pooling is internal
|
|
257
259
|
self.connection = DeviceConnection(
|
|
@@ -304,10 +306,16 @@ class Device:
|
|
|
304
306
|
```
|
|
305
307
|
"""
|
|
306
308
|
if serial is None:
|
|
307
|
-
temp_conn = DeviceConnection(
|
|
309
|
+
temp_conn = DeviceConnection(
|
|
310
|
+
serial="000000000000",
|
|
311
|
+
ip=ip,
|
|
312
|
+
port=port,
|
|
313
|
+
timeout=timeout,
|
|
314
|
+
max_retries=max_retries,
|
|
315
|
+
)
|
|
308
316
|
try:
|
|
309
317
|
response = await temp_conn.request(
|
|
310
|
-
packets.Device.GetService(), timeout=
|
|
318
|
+
packets.Device.GetService(), timeout=timeout
|
|
311
319
|
)
|
|
312
320
|
if response and isinstance(response, packets.Device.StateService):
|
|
313
321
|
if temp_conn.serial and temp_conn.serial != "000000000000":
|
|
@@ -890,9 +898,17 @@ class Device:
|
|
|
890
898
|
|
|
891
899
|
try:
|
|
892
900
|
# Check each device for the target label
|
|
893
|
-
async for disc in discover_devices(
|
|
901
|
+
async for disc in discover_devices(
|
|
902
|
+
timeout=discover_timeout,
|
|
903
|
+
device_timeout=self._timeout,
|
|
904
|
+
max_retries=self._max_retries,
|
|
905
|
+
):
|
|
894
906
|
temp_conn = DeviceConnection(
|
|
895
|
-
serial=disc.serial,
|
|
907
|
+
serial=disc.serial,
|
|
908
|
+
ip=disc.ip,
|
|
909
|
+
port=disc.port,
|
|
910
|
+
timeout=self._timeout,
|
|
911
|
+
max_retries=self._max_retries,
|
|
896
912
|
)
|
|
897
913
|
|
|
898
914
|
try:
|
|
@@ -1063,9 +1079,17 @@ class Device:
|
|
|
1063
1079
|
|
|
1064
1080
|
try:
|
|
1065
1081
|
# Check each device for the target label
|
|
1066
|
-
async for disc in discover_devices(
|
|
1082
|
+
async for disc in discover_devices(
|
|
1083
|
+
timeout=discover_timeout,
|
|
1084
|
+
device_timeout=self._timeout,
|
|
1085
|
+
max_retries=self._max_retries,
|
|
1086
|
+
):
|
|
1067
1087
|
temp_conn = DeviceConnection(
|
|
1068
|
-
serial=disc.serial,
|
|
1088
|
+
serial=disc.serial,
|
|
1089
|
+
ip=disc.ip,
|
|
1090
|
+
port=disc.port,
|
|
1091
|
+
timeout=self._timeout,
|
|
1092
|
+
max_retries=self._max_retries,
|
|
1069
1093
|
)
|
|
1070
1094
|
|
|
1071
1095
|
try:
|
|
@@ -467,13 +467,10 @@ class DeviceConnection:
|
|
|
467
467
|
correlation_keys: list[tuple[int, int, str]] = []
|
|
468
468
|
|
|
469
469
|
# Calculate per-attempt timeouts with exponential backoff
|
|
470
|
-
#
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
else:
|
|
475
|
-
# Only one attempt total, use entire timeout
|
|
476
|
-
attempt_timeout = timeout
|
|
470
|
+
# Use proper exponential backoff distribution: timeout / (2^(n+1) - 1)
|
|
471
|
+
# This ensures total of all attempt timeouts equals the overall timeout budget
|
|
472
|
+
total_weight = (2 ** (max_retries + 1)) - 1
|
|
473
|
+
base_timeout = timeout / total_weight
|
|
477
474
|
|
|
478
475
|
# Idle timeout for multi-response protocols
|
|
479
476
|
# Stop streaming if no responses for this long after first response
|
|
@@ -482,14 +479,17 @@ class DeviceConnection:
|
|
|
482
479
|
last_error: Exception | None = None
|
|
483
480
|
has_yielded = False
|
|
484
481
|
overall_start = time.monotonic()
|
|
482
|
+
total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
|
|
485
483
|
|
|
486
484
|
try:
|
|
487
485
|
for attempt in range(max_retries + 1):
|
|
488
486
|
# Calculate current attempt timeout with exponential backoff
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
487
|
+
# Exclude sleep time from elapsed time to preserve timeout budget
|
|
488
|
+
elapsed_response_time = (
|
|
489
|
+
time.monotonic() - overall_start - total_sleep_time
|
|
492
490
|
)
|
|
491
|
+
ideal_timeout = base_timeout * (2**attempt)
|
|
492
|
+
current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
|
|
493
493
|
|
|
494
494
|
# Check if we've exceeded overall timeout budget
|
|
495
495
|
if current_timeout <= 0:
|
|
@@ -616,6 +616,9 @@ class DeviceConnection:
|
|
|
616
616
|
# Sleep with jitter before retry
|
|
617
617
|
sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
|
|
618
618
|
await asyncio.sleep(sleep_time)
|
|
619
|
+
total_sleep_time += (
|
|
620
|
+
sleep_time # Track sleep to exclude from timeout
|
|
621
|
+
)
|
|
619
622
|
continue
|
|
620
623
|
else:
|
|
621
624
|
# All retries exhausted
|
|
@@ -673,9 +676,14 @@ class DeviceConnection:
|
|
|
673
676
|
base_timeout = timeout / total_weight
|
|
674
677
|
|
|
675
678
|
last_error: Exception | None = None
|
|
679
|
+
total_sleep_time = 0.0 # Track sleep time to exclude from timeout budget
|
|
680
|
+
overall_start = time.monotonic()
|
|
676
681
|
|
|
677
682
|
for attempt in range(max_retries + 1):
|
|
678
|
-
|
|
683
|
+
# Calculate timeout with budget remaining after excluding sleep time
|
|
684
|
+
elapsed_response_time = time.monotonic() - overall_start - total_sleep_time
|
|
685
|
+
ideal_timeout = base_timeout * (2**attempt)
|
|
686
|
+
current_timeout = min(ideal_timeout, timeout - elapsed_response_time)
|
|
679
687
|
sequence = attempt
|
|
680
688
|
|
|
681
689
|
# Correlation key: (source, sequence, serial)
|
|
@@ -737,6 +745,9 @@ class DeviceConnection:
|
|
|
737
745
|
# Sleep with jitter before retry
|
|
738
746
|
sleep_time = self._calculate_retry_sleep_with_jitter(attempt)
|
|
739
747
|
await asyncio.sleep(sleep_time)
|
|
748
|
+
total_sleep_time += (
|
|
749
|
+
sleep_time # Track sleep to exclude from timeout
|
|
750
|
+
)
|
|
740
751
|
continue
|
|
741
752
|
else:
|
|
742
753
|
break
|
|
@@ -527,7 +527,7 @@ class TestLocationAndGroupManagement:
|
|
|
527
527
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
|
|
528
528
|
|
|
529
529
|
# Create async generator mock for discover_devices
|
|
530
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
530
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
531
531
|
for disc in discovered_devices:
|
|
532
532
|
yield disc
|
|
533
533
|
|
|
@@ -537,8 +537,10 @@ class TestLocationAndGroupManagement:
|
|
|
537
537
|
),
|
|
538
538
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
539
539
|
):
|
|
540
|
-
#
|
|
541
|
-
mock_conn_class.
|
|
540
|
+
# Only one DeviceConnection created for discovered device
|
|
541
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
542
|
+
# Add async close method to mock
|
|
543
|
+
mock_discovered_conn.close = AsyncMock()
|
|
542
544
|
|
|
543
545
|
await device.set_location(label)
|
|
544
546
|
|
|
@@ -581,7 +583,7 @@ class TestLocationAndGroupManagement:
|
|
|
581
583
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_location)
|
|
582
584
|
|
|
583
585
|
# Create async generator mock for discover_devices
|
|
584
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
586
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
585
587
|
for disc in discovered_devices:
|
|
586
588
|
yield disc
|
|
587
589
|
|
|
@@ -591,7 +593,10 @@ class TestLocationAndGroupManagement:
|
|
|
591
593
|
),
|
|
592
594
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
593
595
|
):
|
|
594
|
-
|
|
596
|
+
# Only one DeviceConnection created for discovered device
|
|
597
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
598
|
+
# Add async close method to mock
|
|
599
|
+
mock_discovered_conn.close = AsyncMock()
|
|
595
600
|
|
|
596
601
|
await device.set_location(label)
|
|
597
602
|
|
|
@@ -629,7 +634,7 @@ class TestLocationAndGroupManagement:
|
|
|
629
634
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
|
|
630
635
|
|
|
631
636
|
# Create async generator mock for discover_devices
|
|
632
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
637
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
633
638
|
for disc in discovered_devices:
|
|
634
639
|
yield disc
|
|
635
640
|
|
|
@@ -639,7 +644,10 @@ class TestLocationAndGroupManagement:
|
|
|
639
644
|
),
|
|
640
645
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
641
646
|
):
|
|
642
|
-
|
|
647
|
+
# Only one DeviceConnection created for discovered device
|
|
648
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
649
|
+
# Add async close method to mock
|
|
650
|
+
mock_discovered_conn.close = AsyncMock()
|
|
643
651
|
|
|
644
652
|
await device.set_group(label)
|
|
645
653
|
|
|
@@ -682,7 +690,7 @@ class TestLocationAndGroupManagement:
|
|
|
682
690
|
mock_discovered_conn.request = AsyncMock(return_value=mock_state_group)
|
|
683
691
|
|
|
684
692
|
# Create async generator mock for discover_devices
|
|
685
|
-
async def mock_discover_gen(timeout: float = 5.0):
|
|
693
|
+
async def mock_discover_gen(timeout: float = 5.0, **kwargs):
|
|
686
694
|
for disc in discovered_devices:
|
|
687
695
|
yield disc
|
|
688
696
|
|
|
@@ -692,7 +700,10 @@ class TestLocationAndGroupManagement:
|
|
|
692
700
|
),
|
|
693
701
|
patch("lifx.devices.base.DeviceConnection") as mock_conn_class,
|
|
694
702
|
):
|
|
695
|
-
|
|
703
|
+
# Only one DeviceConnection created for discovered device
|
|
704
|
+
mock_conn_class.return_value = mock_discovered_conn
|
|
705
|
+
# Add async close method to mock
|
|
706
|
+
mock_discovered_conn.close = AsyncMock()
|
|
696
707
|
|
|
697
708
|
await device.set_group(label)
|
|
698
709
|
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Tests for concurrent request handling with DeviceConnection.
|
|
2
|
+
|
|
3
|
+
This module tests concurrent request/response handling through the
|
|
4
|
+
user-facing DeviceConnection API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from lifx.exceptions import LifxTimeoutError
|
|
14
|
+
from lifx.protocol.packets import Device
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestConcurrentRequests:
|
|
18
|
+
"""Test concurrent request/response handling with DeviceConnection."""
|
|
19
|
+
|
|
20
|
+
async def test_timeout_behavior(self):
|
|
21
|
+
"""Test that timeout raises LifxTimeoutError with no server response."""
|
|
22
|
+
from lifx.network.connection import DeviceConnection
|
|
23
|
+
|
|
24
|
+
conn = DeviceConnection(
|
|
25
|
+
serial="d073d5000001", ip="192.168.1.100", timeout=0.1, max_retries=0
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Request should timeout when no server is available
|
|
29
|
+
with pytest.raises(LifxTimeoutError):
|
|
30
|
+
await conn.request(Device.GetPower(), timeout=0.1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestErrorHandling:
|
|
34
|
+
"""Test error handling in concurrent scenarios using DeviceConnection."""
|
|
35
|
+
|
|
36
|
+
async def test_timeout_when_server_drops_packets(
|
|
37
|
+
self, emulator_server_with_scenarios
|
|
38
|
+
):
|
|
39
|
+
"""Test handling timeout when server drops packets (simulating no response)."""
|
|
40
|
+
# Create a scenario that drops Device.GetPower packets (pkt_type 20)
|
|
41
|
+
server, _device = await emulator_server_with_scenarios(
|
|
42
|
+
device_type="color",
|
|
43
|
+
serial="d073d5000001",
|
|
44
|
+
scenarios={
|
|
45
|
+
"drop_packets": {
|
|
46
|
+
"20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
from lifx.network.connection import DeviceConnection
|
|
52
|
+
|
|
53
|
+
conn = DeviceConnection(
|
|
54
|
+
serial="d073d5000001",
|
|
55
|
+
ip="127.0.0.1",
|
|
56
|
+
port=server.port,
|
|
57
|
+
timeout=0.5,
|
|
58
|
+
max_retries=0, # No retries for faster test
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# This should timeout since server drops all GetPower packets
|
|
62
|
+
with pytest.raises(LifxTimeoutError):
|
|
63
|
+
await conn.request(Device.GetPower(), timeout=0.5)
|
|
64
|
+
|
|
65
|
+
async def test_concurrent_requests_with_one_timing_out(
|
|
66
|
+
self, emulator_server_with_scenarios
|
|
67
|
+
):
|
|
68
|
+
"""Test timeout isolation between concurrent requests."""
|
|
69
|
+
# Create a scenario that drops ONLY GetPower packets
|
|
70
|
+
server, _device = await emulator_server_with_scenarios(
|
|
71
|
+
device_type="color",
|
|
72
|
+
serial="d073d5000001",
|
|
73
|
+
scenarios={
|
|
74
|
+
"drop_packets": {
|
|
75
|
+
"20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
from lifx.network.connection import DeviceConnection
|
|
81
|
+
|
|
82
|
+
conn = DeviceConnection(
|
|
83
|
+
serial="d073d5000001",
|
|
84
|
+
ip="127.0.0.1",
|
|
85
|
+
port=server.port,
|
|
86
|
+
timeout=1.0,
|
|
87
|
+
max_retries=2,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Create multiple concurrent requests where one will timeout
|
|
91
|
+
async def get_power():
|
|
92
|
+
"""This will timeout."""
|
|
93
|
+
try:
|
|
94
|
+
await conn.request(Device.GetPower(), timeout=0.3)
|
|
95
|
+
return "power_success"
|
|
96
|
+
except LifxTimeoutError:
|
|
97
|
+
return "power_timeout"
|
|
98
|
+
|
|
99
|
+
async def get_label():
|
|
100
|
+
"""This should succeed."""
|
|
101
|
+
try:
|
|
102
|
+
await conn.request(Device.GetLabel(), timeout=1.0)
|
|
103
|
+
return "label_success"
|
|
104
|
+
except LifxTimeoutError:
|
|
105
|
+
return "label_timeout"
|
|
106
|
+
|
|
107
|
+
# Run both concurrently
|
|
108
|
+
results = await asyncio.gather(get_power(), get_label())
|
|
109
|
+
|
|
110
|
+
# Power request should timeout, label should succeed
|
|
111
|
+
assert results[0] == "power_timeout"
|
|
112
|
+
assert results[1] == "label_success"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestAsyncGeneratorRequests:
|
|
116
|
+
"""Test async generator-based request streaming."""
|
|
117
|
+
|
|
118
|
+
async def test_request_stream_single_response(self, emulator_server_with_scenarios):
|
|
119
|
+
"""Test request_stream with single response exits immediately after break."""
|
|
120
|
+
server, _device = await emulator_server_with_scenarios(
|
|
121
|
+
device_type="color",
|
|
122
|
+
serial="d073d5000001",
|
|
123
|
+
scenarios={},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
from lifx.network.connection import DeviceConnection
|
|
127
|
+
|
|
128
|
+
conn = DeviceConnection(
|
|
129
|
+
serial="d073d5000001",
|
|
130
|
+
ip="127.0.0.1",
|
|
131
|
+
port=server.port,
|
|
132
|
+
timeout=2.0,
|
|
133
|
+
max_retries=2,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Stream should yield single response
|
|
137
|
+
received = []
|
|
138
|
+
async for response in conn.request_stream(Device.GetLabel()):
|
|
139
|
+
received.append(response)
|
|
140
|
+
break # Exit immediately after first response
|
|
141
|
+
|
|
142
|
+
assert len(received) == 1
|
|
143
|
+
assert hasattr(received[0], "label")
|
|
144
|
+
|
|
145
|
+
async def test_request_stream_convenience_wrapper(
|
|
146
|
+
self, emulator_server_with_scenarios
|
|
147
|
+
):
|
|
148
|
+
"""Test that request() convenience wrapper works correctly."""
|
|
149
|
+
server, _device = await emulator_server_with_scenarios(
|
|
150
|
+
device_type="color",
|
|
151
|
+
serial="d073d5000001",
|
|
152
|
+
scenarios={},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
from lifx.network.connection import DeviceConnection
|
|
156
|
+
|
|
157
|
+
conn = DeviceConnection(
|
|
158
|
+
serial="d073d5000001",
|
|
159
|
+
ip="127.0.0.1",
|
|
160
|
+
port=server.port,
|
|
161
|
+
timeout=2.0,
|
|
162
|
+
max_retries=2,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# request() should return single response directly
|
|
166
|
+
response = await conn.request(Device.GetLabel())
|
|
167
|
+
assert hasattr(response, "label")
|
|
168
|
+
|
|
169
|
+
async def test_early_exit_no_resource_leak(self, emulator_server_with_scenarios):
|
|
170
|
+
"""Test that breaking early doesn't leak resources."""
|
|
171
|
+
server, _device = await emulator_server_with_scenarios(
|
|
172
|
+
device_type="color",
|
|
173
|
+
serial="d073d5000001",
|
|
174
|
+
scenarios={},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
from lifx.network.connection import DeviceConnection
|
|
178
|
+
|
|
179
|
+
conn = DeviceConnection(
|
|
180
|
+
serial="d073d5000001",
|
|
181
|
+
ip="127.0.0.1",
|
|
182
|
+
port=server.port,
|
|
183
|
+
timeout=2.0,
|
|
184
|
+
max_retries=2,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
# Stream and break early
|
|
189
|
+
async for _response in conn.request_stream(Device.GetLabel()):
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
# Verify connection is still functional
|
|
193
|
+
assert conn.is_open
|
|
194
|
+
|
|
195
|
+
# Make another request to verify no leak
|
|
196
|
+
response = await conn.request(Device.GetPower())
|
|
197
|
+
assert hasattr(response, "level")
|
|
198
|
+
finally:
|
|
199
|
+
await conn.close()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestRetryTimeoutBudget:
|
|
203
|
+
"""Test that retry sleep time doesn't consume the timeout budget.
|
|
204
|
+
|
|
205
|
+
This test class verifies the fix for the issue where retry sleep time
|
|
206
|
+
was being counted against the overall timeout budget, causing later
|
|
207
|
+
retry attempts to have insufficient time to wait for responses.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
async def test_retry_sleep_excluded_from_timeout_budget(
|
|
211
|
+
self, emulator_server_with_scenarios
|
|
212
|
+
):
|
|
213
|
+
"""Test that retry sleep time is excluded from timeout budget.
|
|
214
|
+
|
|
215
|
+
This test verifies that when retries occur with exponential backoff sleep,
|
|
216
|
+
the sleep time doesn't consume the overall timeout budget. Each retry
|
|
217
|
+
attempt should get a fair timeout window.
|
|
218
|
+
|
|
219
|
+
Without the fix, this would fail because later attempts would have
|
|
220
|
+
very short timeouts (e.g., 0.613s on attempt 4) due to accumulated sleep time.
|
|
221
|
+
"""
|
|
222
|
+
import time
|
|
223
|
+
|
|
224
|
+
# Create a scenario that drops all packets to force retries
|
|
225
|
+
server, _device = await emulator_server_with_scenarios(
|
|
226
|
+
device_type="color",
|
|
227
|
+
serial="d073d5000001",
|
|
228
|
+
scenarios={
|
|
229
|
+
"drop_packets": {
|
|
230
|
+
"20": 1.0 # Drop 100% of GetPower responses (pkt_type 20)
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
from lifx.network.connection import DeviceConnection
|
|
236
|
+
|
|
237
|
+
# Set up connection with specific timeout and retries
|
|
238
|
+
timeout = 2.0 # 2 second total timeout budget
|
|
239
|
+
max_retries = 3 # 4 total attempts (0, 1, 2, 3)
|
|
240
|
+
|
|
241
|
+
conn = DeviceConnection(
|
|
242
|
+
serial="d073d5000001",
|
|
243
|
+
ip="127.0.0.1",
|
|
244
|
+
port=server.port,
|
|
245
|
+
timeout=timeout,
|
|
246
|
+
max_retries=max_retries,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Calculate expected timeout distribution with exponential backoff
|
|
250
|
+
# total_weight = (2^(n+1) - 1) = (2^4 - 1) = 15
|
|
251
|
+
# base_timeout = 2.0 / 15 = 0.133s
|
|
252
|
+
# Attempt 0: 0.133 * 2^0 = 0.133s
|
|
253
|
+
# Attempt 1: 0.133 * 2^1 = 0.266s
|
|
254
|
+
# Attempt 2: 0.133 * 2^2 = 0.533s
|
|
255
|
+
# Attempt 3: 0.133 * 2^3 = 1.066s
|
|
256
|
+
# Total: 0.133 + 0.266 + 0.533 + 1.066 = ~2.0s
|
|
257
|
+
|
|
258
|
+
start_time = time.monotonic()
|
|
259
|
+
|
|
260
|
+
# This should timeout after all retries are exhausted
|
|
261
|
+
with pytest.raises(LifxTimeoutError) as exc_info:
|
|
262
|
+
await conn.request(Device.GetPower(), timeout=timeout)
|
|
263
|
+
|
|
264
|
+
elapsed = time.monotonic() - start_time
|
|
265
|
+
|
|
266
|
+
# Verify the timeout message
|
|
267
|
+
assert "after 4 attempts" in str(exc_info.value)
|
|
268
|
+
|
|
269
|
+
# The total elapsed time should be:
|
|
270
|
+
# - Timeout budget (2.0s)
|
|
271
|
+
# - Plus sleep time between retries (3 sleeps with exponential backoff)
|
|
272
|
+
# Sleep 0: random(0, 0.1 * 2^0) = random(0, 0.1)
|
|
273
|
+
# Sleep 1: random(0, 0.1 * 2^1) = random(0, 0.2)
|
|
274
|
+
# Sleep 2: random(0, 0.1 * 2^2) = random(0, 0.4)
|
|
275
|
+
# Max total sleep: 0.1 + 0.2 + 0.4 = 0.7s
|
|
276
|
+
# Total expected: 2.0 + 0.7 = 2.7s maximum
|
|
277
|
+
|
|
278
|
+
# Allow some tolerance for timing variations
|
|
279
|
+
assert elapsed >= timeout, "Should use at least the timeout budget"
|
|
280
|
+
assert elapsed < timeout + 1.0, (
|
|
281
|
+
f"Elapsed {elapsed}s should not exceed timeout + max_sleep (3.0s)"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Key assertion: If sleep was counted against timeout budget,
|
|
285
|
+
# the elapsed time would be close to just the timeout (2.0s)
|
|
286
|
+
# because later attempts would fail immediately.
|
|
287
|
+
# With the fix, we should see elapsed > timeout + some sleep time.
|
|
288
|
+
assert elapsed > timeout + 0.1, (
|
|
289
|
+
"Sleep time should be added on top of timeout budget"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def test_retry_timeout_calculation_consistency(
|
|
293
|
+
self, emulator_server_with_scenarios
|
|
294
|
+
):
|
|
295
|
+
"""Test that timeout calculation is consistent between GET and SET requests.
|
|
296
|
+
|
|
297
|
+
Both _request_stream_impl (GET) and _request_ack_stream_impl (SET)
|
|
298
|
+
should use the same timeout calculation formula.
|
|
299
|
+
"""
|
|
300
|
+
import time
|
|
301
|
+
|
|
302
|
+
# Create a scenario that drops packets for both GET and SET
|
|
303
|
+
server, _device = await emulator_server_with_scenarios(
|
|
304
|
+
device_type="color",
|
|
305
|
+
serial="d073d5000001",
|
|
306
|
+
scenarios={
|
|
307
|
+
"drop_packets": {
|
|
308
|
+
"20": 1.0, # Drop GetPower (GET request)
|
|
309
|
+
"21": 1.0, # Drop SetPower (SET request)
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
from lifx.network.connection import DeviceConnection
|
|
315
|
+
|
|
316
|
+
timeout = 1.5
|
|
317
|
+
max_retries = 2 # 3 total attempts
|
|
318
|
+
|
|
319
|
+
conn = DeviceConnection(
|
|
320
|
+
serial="d073d5000001",
|
|
321
|
+
ip="127.0.0.1",
|
|
322
|
+
port=server.port,
|
|
323
|
+
timeout=timeout,
|
|
324
|
+
max_retries=max_retries,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Test GET request (uses _request_stream_impl)
|
|
328
|
+
start_get = time.monotonic()
|
|
329
|
+
with pytest.raises(LifxTimeoutError):
|
|
330
|
+
await conn.request(Device.GetPower(), timeout=timeout)
|
|
331
|
+
elapsed_get = time.monotonic() - start_get
|
|
332
|
+
|
|
333
|
+
# Test SET request (uses _request_ack_stream_impl)
|
|
334
|
+
start_set = time.monotonic()
|
|
335
|
+
with pytest.raises(LifxTimeoutError):
|
|
336
|
+
await conn.request(Device.SetPower(level=65535), timeout=timeout)
|
|
337
|
+
elapsed_set = time.monotonic() - start_set
|
|
338
|
+
|
|
339
|
+
# Both should take approximately the same time (within tolerance)
|
|
340
|
+
# since they use the same timeout calculation and retry logic
|
|
341
|
+
time_diff = abs(elapsed_get - elapsed_set)
|
|
342
|
+
assert time_diff < 0.5, (
|
|
343
|
+
f"GET and SET timeout behavior should be consistent (diff: {time_diff}s)"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Both should respect the timeout budget
|
|
347
|
+
assert elapsed_get >= timeout
|
|
348
|
+
assert elapsed_set >= timeout
|
|
349
|
+
|
|
350
|
+
async def test_retry_all_attempts_get_fair_timeout(
|
|
351
|
+
self, emulator_server_with_scenarios
|
|
352
|
+
):
|
|
353
|
+
"""Test that all retry attempts get adequate timeout windows.
|
|
354
|
+
|
|
355
|
+
This verifies that later retry attempts aren't starved of timeout
|
|
356
|
+
due to accumulated sleep time from earlier attempts.
|
|
357
|
+
"""
|
|
358
|
+
# Create a scenario that drops packets to force retries
|
|
359
|
+
server, _device = await emulator_server_with_scenarios(
|
|
360
|
+
device_type="color",
|
|
361
|
+
serial="d073d5000001",
|
|
362
|
+
scenarios={
|
|
363
|
+
"drop_packets": {
|
|
364
|
+
"20": 1.0 # Drop all GetPower responses
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
from lifx.network.connection import DeviceConnection
|
|
370
|
+
|
|
371
|
+
# Use settings similar to real-world usage
|
|
372
|
+
timeout = 8.0 # Default timeout
|
|
373
|
+
max_retries = 4 # 5 total attempts (like in the error log)
|
|
374
|
+
|
|
375
|
+
conn = DeviceConnection(
|
|
376
|
+
serial="d073d5000001",
|
|
377
|
+
ip="127.0.0.1",
|
|
378
|
+
port=server.port,
|
|
379
|
+
timeout=timeout,
|
|
380
|
+
max_retries=max_retries,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# This should timeout after all retries
|
|
384
|
+
with pytest.raises(LifxTimeoutError) as exc_info:
|
|
385
|
+
await conn.request(Device.GetPower(), timeout=timeout)
|
|
386
|
+
|
|
387
|
+
# Verify all attempts were made
|
|
388
|
+
assert "after 5 attempts" in str(exc_info.value)
|
|
389
|
+
|
|
390
|
+
# The error message should NOT show a very short timeout on later attempts
|
|
391
|
+
# (like "No response within 0.613s" which would indicate the bug)
|
|
392
|
+
error_msg = str(exc_info.value)
|
|
393
|
+
|
|
394
|
+
# The error should be about exhausting all attempts, not a premature timeout
|
|
395
|
+
assert "No response from" in error_msg
|