violet-poolController-api 0.0.32__tar.gz → 0.0.34__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.
- {violet_poolcontroller_api-0.0.32/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.34}/PKG-INFO +2 -2
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/pyproject.toml +8 -2
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_api.py +6 -2
- violet_poolcontroller_api-0.0.34/tests/test_circuit_breaker.py +138 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34/violet_poolController_api.egg-info}/PKG-INFO +2 -2
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolController_api.egg-info/SOURCES.txt +1 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolController_api.egg-info/requires.txt +1 -1
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/api.py +72 -23
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/circuit_breaker.py +11 -8
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/parsers.py +8 -2
- violet_poolcontroller_api-0.0.34/violet_poolcontroller_api/py.typed +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/readings.py +20 -10
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/utils_rate_limiter.py +6 -4
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/utils_sanitizer.py +13 -12
- violet_poolcontroller_api-0.0.32/tests/test_circuit_breaker.py +0 -204
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/README.md +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_parsers.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_readings.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/__init__.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/const_api.py +0 -0
- {violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/violet_poolcontroller_api/const_devices.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.34
|
|
4
4
|
Summary: Asynchronous Python client for the Violet Pool Controller.
|
|
5
5
|
Author-email: "Basti (Xerolux)" <git@xerolux.de>
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aiohttp<3.
|
|
23
|
+
Requires-Dist: aiohttp<3.15,>=3.11.0
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: aioresponses>=0.7.8; extra == "test"
|
|
26
26
|
Requires-Dist: pytest>=8.3; extra == "test"
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
|
+
[tool.setuptools.packages.find]
|
|
6
|
+
include = ["violet_poolcontroller_api*"]
|
|
7
|
+
|
|
8
|
+
[tool.setuptools.package-data]
|
|
9
|
+
violet_poolcontroller_api = ["py.typed"]
|
|
10
|
+
|
|
5
11
|
[project]
|
|
6
12
|
name = "violet-poolController-api"
|
|
7
|
-
version = "0.0.
|
|
13
|
+
version = "0.0.34"
|
|
8
14
|
authors = [
|
|
9
15
|
{ name="Basti (Xerolux)", email="git@xerolux.de" },
|
|
10
16
|
]
|
|
@@ -24,7 +30,7 @@ classifiers = [
|
|
|
24
30
|
"Topic :: Home Automation",
|
|
25
31
|
]
|
|
26
32
|
dependencies = [
|
|
27
|
-
"aiohttp>=3.11.0,<3.
|
|
33
|
+
"aiohttp>=3.11.0,<3.15",
|
|
28
34
|
]
|
|
29
35
|
|
|
30
36
|
[project.optional-dependencies]
|
|
@@ -1683,8 +1683,10 @@ async def test_client_error_does_not_trip_circuit_breaker(
|
|
|
1683
1683
|
await api_client.get_readings()
|
|
1684
1684
|
|
|
1685
1685
|
stats = api_client._circuit_breaker.get_stats()
|
|
1686
|
+
if "note" in stats:
|
|
1687
|
+
pytest.skip("Lock held during stats collection")
|
|
1686
1688
|
assert stats["failure_count"] == 0
|
|
1687
|
-
assert stats["state"] == "CLOSED"
|
|
1689
|
+
assert stats["state"].name == "CLOSED"
|
|
1688
1690
|
|
|
1689
1691
|
|
|
1690
1692
|
@pytest.mark.asyncio
|
|
@@ -1694,12 +1696,14 @@ async def test_server_error_still_counts_for_circuit_breaker(
|
|
|
1694
1696
|
) -> None:
|
|
1695
1697
|
"""5xx errors keep counting as circuit breaker failures."""
|
|
1696
1698
|
url = "http://192.168.1.100/getReadings?ALL"
|
|
1697
|
-
mock_aioresponse.get(url, status=500, body="boom")
|
|
1699
|
+
mock_aioresponse.get(url, status=500, body="boom", repeat=True)
|
|
1698
1700
|
|
|
1699
1701
|
with pytest.raises(VioletPoolAPIError):
|
|
1700
1702
|
await api_client.get_readings()
|
|
1701
1703
|
|
|
1702
1704
|
stats = api_client._circuit_breaker.get_stats()
|
|
1705
|
+
if "note" in stats:
|
|
1706
|
+
pytest.skip("Lock held during stats collection")
|
|
1703
1707
|
assert stats["failure_count"] == 1
|
|
1704
1708
|
|
|
1705
1709
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for violet_poolcontroller_api.circuit_breaker module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from violet_poolcontroller_api.circuit_breaker import (
|
|
6
|
+
CircuitBreaker,
|
|
7
|
+
CircuitBreakerOpenError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestCircuitBreakerStates:
|
|
12
|
+
"""Test circuit breaker state transitions."""
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def circuit_breaker(self):
|
|
16
|
+
"""Create circuit breaker instance."""
|
|
17
|
+
return CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
18
|
+
|
|
19
|
+
async def test_initial_state_closed(self, circuit_breaker):
|
|
20
|
+
"""Circuit breaker starts in CLOSED state."""
|
|
21
|
+
assert circuit_breaker.state.name == "CLOSED"
|
|
22
|
+
|
|
23
|
+
async def test_state_transitions_closed_to_open(self, circuit_breaker):
|
|
24
|
+
"""CLOSED → OPEN after threshold failures."""
|
|
25
|
+
async def failing():
|
|
26
|
+
raise ValueError("simulated failure")
|
|
27
|
+
|
|
28
|
+
for _ in range(3):
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
await circuit_breaker.call(failing)
|
|
31
|
+
|
|
32
|
+
assert circuit_breaker.state.name == "OPEN"
|
|
33
|
+
assert circuit_breaker.failure_count == 3
|
|
34
|
+
|
|
35
|
+
async def test_circuit_open_rejects_calls(self, circuit_breaker):
|
|
36
|
+
"""OPEN circuit raises CircuitBreakerOpenError."""
|
|
37
|
+
async def failing():
|
|
38
|
+
raise ValueError("fail")
|
|
39
|
+
|
|
40
|
+
for _ in range(3):
|
|
41
|
+
with pytest.raises(ValueError):
|
|
42
|
+
await circuit_breaker.call(failing)
|
|
43
|
+
|
|
44
|
+
with pytest.raises(CircuitBreakerOpenError):
|
|
45
|
+
await circuit_breaker.call(failing)
|
|
46
|
+
|
|
47
|
+
async def test_ignored_exceptions_dont_count(self):
|
|
48
|
+
"""Ignored exceptions pass through without incrementing failure count."""
|
|
49
|
+
cb = CircuitBreaker(
|
|
50
|
+
failure_threshold=2,
|
|
51
|
+
recovery_timeout=1,
|
|
52
|
+
ignored_exceptions=(ValueError,),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def value_err():
|
|
56
|
+
raise ValueError("should be ignored")
|
|
57
|
+
|
|
58
|
+
for _ in range(5):
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
await cb.call(value_err)
|
|
61
|
+
|
|
62
|
+
assert cb.failure_count == 0
|
|
63
|
+
assert cb.state.name == "CLOSED"
|
|
64
|
+
|
|
65
|
+
async def test_success_resets_failure_count(self, circuit_breaker):
|
|
66
|
+
"""Success resets failure count."""
|
|
67
|
+
async def failing():
|
|
68
|
+
raise ValueError("fail")
|
|
69
|
+
|
|
70
|
+
async def succeeding():
|
|
71
|
+
return "ok"
|
|
72
|
+
|
|
73
|
+
# Trigger one failure
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
await circuit_breaker.call(failing)
|
|
76
|
+
assert circuit_breaker.failure_count == 1
|
|
77
|
+
|
|
78
|
+
# Success
|
|
79
|
+
result = await circuit_breaker.call(succeeding)
|
|
80
|
+
assert result == "ok"
|
|
81
|
+
assert circuit_breaker.failure_count == 0
|
|
82
|
+
assert circuit_breaker.state.name == "CLOSED"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestCircuitBreakerConfiguration:
|
|
86
|
+
"""Test circuit breaker configuration."""
|
|
87
|
+
|
|
88
|
+
def test_custom_failure_threshold(self):
|
|
89
|
+
"""Set custom failure threshold."""
|
|
90
|
+
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
|
|
91
|
+
assert cb.failure_threshold == 5
|
|
92
|
+
|
|
93
|
+
def test_zero_failure_threshold(self):
|
|
94
|
+
"""Zero failure threshold opens on first failure."""
|
|
95
|
+
cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
|
|
96
|
+
|
|
97
|
+
async def failing():
|
|
98
|
+
raise ValueError("fail")
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
cb.call(failing) # no await needed — fails before reaching callable
|
|
102
|
+
|
|
103
|
+
def test_negative_recovery_timeout(self):
|
|
104
|
+
"""Negative timeout handled gracefully."""
|
|
105
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=-1)
|
|
106
|
+
assert cb is not None
|
|
107
|
+
|
|
108
|
+
def test_custom_recovery_timeout(self):
|
|
109
|
+
"""Set custom recovery timeout."""
|
|
110
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
|
|
111
|
+
assert cb.recovery_timeout == 5
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestCircuitBreakerMetrics:
|
|
115
|
+
"""Test circuit breaker metrics."""
|
|
116
|
+
|
|
117
|
+
async def test_get_stats_returns_state_info(self):
|
|
118
|
+
"""get_stats returns current state info."""
|
|
119
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
120
|
+
stats = cb.get_stats()
|
|
121
|
+
assert stats["state"].name == "CLOSED"
|
|
122
|
+
assert stats["failure_count"] == 0
|
|
123
|
+
|
|
124
|
+
async def test_reset_clears_state(self):
|
|
125
|
+
"""reset restores CLOSED state and clears counters."""
|
|
126
|
+
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
127
|
+
|
|
128
|
+
async def failing():
|
|
129
|
+
raise ValueError("fail")
|
|
130
|
+
|
|
131
|
+
for _ in range(2):
|
|
132
|
+
with pytest.raises(ValueError):
|
|
133
|
+
await cb.call(failing)
|
|
134
|
+
|
|
135
|
+
assert cb.state.name == "OPEN"
|
|
136
|
+
cb.reset()
|
|
137
|
+
assert cb.state.name == "CLOSED"
|
|
138
|
+
assert cb.failure_count == 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: violet-poolController-api
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.34
|
|
4
4
|
Summary: Asynchronous Python client for the Violet Pool Controller.
|
|
5
5
|
Author-email: "Basti (Xerolux)" <git@xerolux.de>
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Home Automation
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: aiohttp<3.
|
|
23
|
+
Requires-Dist: aiohttp<3.15,>=3.11.0
|
|
24
24
|
Provides-Extra: test
|
|
25
25
|
Requires-Dist: aioresponses>=0.7.8; extra == "test"
|
|
26
26
|
Requires-Dist: pytest>=8.3; extra == "test"
|
|
@@ -18,6 +18,7 @@ violet_poolcontroller_api/circuit_breaker.py
|
|
|
18
18
|
violet_poolcontroller_api/const_api.py
|
|
19
19
|
violet_poolcontroller_api/const_devices.py
|
|
20
20
|
violet_poolcontroller_api/parsers.py
|
|
21
|
+
violet_poolcontroller_api/py.typed
|
|
21
22
|
violet_poolcontroller_api/readings.py
|
|
22
23
|
violet_poolcontroller_api/utils_rate_limiter.py
|
|
23
24
|
violet_poolcontroller_api/utils_sanitizer.py
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
|
+
import ipaddress
|
|
22
23
|
import json
|
|
23
24
|
import logging
|
|
24
25
|
import math
|
|
@@ -331,18 +332,63 @@ class VioletPoolAPI:
|
|
|
331
332
|
parsed = urlparse(host)
|
|
332
333
|
host = parsed.netloc
|
|
333
334
|
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
try:
|
|
336
|
+
literal_ip = ipaddress.ip_address(host)
|
|
337
|
+
except ValueError:
|
|
338
|
+
literal_ip = None
|
|
339
|
+
if literal_ip is not None and literal_ip.version == 6:
|
|
340
|
+
protocol = "https" if use_ssl else "http"
|
|
341
|
+
return urlunparse((protocol, f"[{host}]", "", "", "", ""))
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
parsed_host = urlparse(f"//{host}")
|
|
345
|
+
except ValueError as err:
|
|
346
|
+
msg = f"Invalid hostname format: {host}"
|
|
347
|
+
raise ValueError(msg) from err
|
|
348
|
+
|
|
349
|
+
if parsed_host.username or parsed_host.password:
|
|
350
|
+
msg = f"Invalid hostname format: {host}"
|
|
351
|
+
raise ValueError(msg)
|
|
352
|
+
if parsed_host.path or parsed_host.query or parsed_host.fragment:
|
|
353
|
+
msg = f"Invalid hostname format: {host}"
|
|
354
|
+
raise ValueError(msg)
|
|
355
|
+
|
|
356
|
+
hostname = parsed_host.hostname
|
|
357
|
+
if not hostname:
|
|
358
|
+
msg = f"Invalid hostname format: {host}"
|
|
359
|
+
raise ValueError(msg)
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
port = parsed_host.port
|
|
363
|
+
except ValueError as err:
|
|
364
|
+
msg = f"Invalid port in hostname: {host}"
|
|
365
|
+
raise ValueError(msg) from err
|
|
366
|
+
|
|
367
|
+
if port is not None and not 1 <= port <= 65535:
|
|
368
|
+
msg = f"Invalid port in hostname: {host}"
|
|
369
|
+
raise ValueError(msg)
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
ipaddress.ip_address(hostname)
|
|
373
|
+
is_ip_literal = True
|
|
374
|
+
except ValueError:
|
|
375
|
+
is_ip_literal = False
|
|
376
|
+
|
|
377
|
+
if not is_ip_literal and not re.match(r"^[a-zA-Z0-9.-]+$", hostname):
|
|
336
378
|
msg = f"Invalid hostname format: {host}"
|
|
337
379
|
raise ValueError(msg)
|
|
338
380
|
|
|
339
381
|
# Additional validation
|
|
340
|
-
if len(
|
|
382
|
+
if len(hostname) > _MAX_HOSTNAME_LENGTH or ".." in hostname or "//" in host:
|
|
341
383
|
msg = f"Invalid hostname: {host}"
|
|
342
384
|
raise ValueError(msg)
|
|
343
385
|
|
|
386
|
+
netloc = f"[{hostname}]" if ":" in hostname else hostname
|
|
387
|
+
if port is not None:
|
|
388
|
+
netloc = f"{netloc}:{port}"
|
|
389
|
+
|
|
344
390
|
protocol = "https" if use_ssl else "http"
|
|
345
|
-
return urlunparse((protocol,
|
|
391
|
+
return urlunparse((protocol, netloc, "", "", "", ""))
|
|
346
392
|
|
|
347
393
|
def _build_url(self, endpoint: str) -> str:
|
|
348
394
|
"""Construct the full URL for a given endpoint.
|
|
@@ -400,10 +446,11 @@ class VioletPoolAPI:
|
|
|
400
446
|
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
401
447
|
except TimeoutError:
|
|
402
448
|
_LOGGER.warning(
|
|
403
|
-
"Rate limiter timeout for %s (priority: %d) -
|
|
449
|
+
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
404
450
|
endpoint,
|
|
405
451
|
priority,
|
|
406
452
|
)
|
|
453
|
+
await asyncio.sleep(1.0)
|
|
407
454
|
|
|
408
455
|
url = self._build_url(endpoint)
|
|
409
456
|
if query:
|
|
@@ -435,8 +482,6 @@ class VioletPoolAPI:
|
|
|
435
482
|
response.status >= _HTTP_CLIENT_ERROR
|
|
436
483
|
and response.status < _HTTP_SERVER_ERROR
|
|
437
484
|
):
|
|
438
|
-
# Client errors (4xx, except 429 handled above) are
|
|
439
|
-
# deterministic - fail fast instead of retrying.
|
|
440
485
|
body = await response.text()
|
|
441
486
|
msg = f"HTTP {response.status} for {endpoint}: {body.strip()}"
|
|
442
487
|
is_auth = response.status in (
|
|
@@ -446,19 +491,18 @@ class VioletPoolAPI:
|
|
|
446
491
|
raise _DeterministicClientError(msg, is_auth=is_auth)
|
|
447
492
|
|
|
448
493
|
if expect_json:
|
|
494
|
+
body_text = await response.text()
|
|
449
495
|
try:
|
|
450
|
-
return
|
|
496
|
+
return json.loads(body_text)
|
|
451
497
|
except (
|
|
452
|
-
aiohttp.ContentTypeError,
|
|
453
498
|
json.JSONDecodeError,
|
|
454
499
|
) as err:
|
|
455
|
-
|
|
456
|
-
msg = f"Invalid JSON payload for {endpoint}: {body.strip()}"
|
|
500
|
+
msg = f"Invalid JSON payload for {endpoint}: {body_text.strip()}"
|
|
457
501
|
raise VioletPayloadError(msg) from err
|
|
458
502
|
|
|
459
503
|
return await response.text()
|
|
460
504
|
|
|
461
|
-
except (TimeoutError,
|
|
505
|
+
except (TimeoutError,) as err:
|
|
462
506
|
last_error = VioletTimeoutError(
|
|
463
507
|
f"Request to {endpoint} timed out: {err}",
|
|
464
508
|
)
|
|
@@ -470,8 +514,8 @@ class VioletPoolAPI:
|
|
|
470
514
|
)
|
|
471
515
|
if attempt == self._max_retries:
|
|
472
516
|
raise last_error from None
|
|
473
|
-
delay = min(
|
|
474
|
-
jitter = random.uniform(0, delay * 0.1)
|
|
517
|
+
delay = min(300.0, 10.0 * (2 ** (attempt - 1)))
|
|
518
|
+
jitter = random.uniform(0, delay * 0.1)
|
|
475
519
|
await asyncio.sleep(delay + jitter)
|
|
476
520
|
except aiohttp.ClientError as err:
|
|
477
521
|
last_error = VioletPoolAPIError(
|
|
@@ -485,9 +529,8 @@ class VioletPoolAPI:
|
|
|
485
529
|
)
|
|
486
530
|
if attempt == self._max_retries:
|
|
487
531
|
raise last_error from None
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
jitter = random.uniform(0, delay * 0.1) # Add 0-10% jitter
|
|
532
|
+
delay = min(300.0, 10.0 * (2 ** (attempt - 1)))
|
|
533
|
+
jitter = random.uniform(0, delay * 0.1)
|
|
491
534
|
await asyncio.sleep(delay + jitter)
|
|
492
535
|
|
|
493
536
|
msg = "All retry attempts exhausted"
|
|
@@ -536,10 +579,10 @@ class VioletPoolAPI:
|
|
|
536
579
|
# a substring check when the first line is neither marker.
|
|
537
580
|
if first_line.startswith("ERROR"):
|
|
538
581
|
success = False
|
|
539
|
-
elif first_line == "OK":
|
|
582
|
+
elif first_line == "OK" or first_line.startswith("MANDOS_"):
|
|
540
583
|
success = True
|
|
541
584
|
else:
|
|
542
|
-
success =
|
|
585
|
+
success = False
|
|
543
586
|
|
|
544
587
|
result: dict[str, Any] = {"success": success, "response": text}
|
|
545
588
|
if len(lines) >= 2:
|
|
@@ -647,7 +690,7 @@ class VioletPoolAPI:
|
|
|
647
690
|
|
|
648
691
|
sanitized_config[sanitized_key] = sanitized_value
|
|
649
692
|
except ValueError as err:
|
|
650
|
-
_LOGGER.
|
|
693
|
+
_LOGGER.warning("Invalid config parameter %s", key)
|
|
651
694
|
msg = f"Invalid configuration parameter: {key}"
|
|
652
695
|
raise VioletPoolAPIError(
|
|
653
696
|
msg,
|
|
@@ -711,7 +754,7 @@ class VioletPoolAPI:
|
|
|
711
754
|
for item in readings:
|
|
712
755
|
if isinstance(item, dict) and item.get("VALUE NAME"):
|
|
713
756
|
key = str(item["VALUE NAME"]).strip().strip('"')
|
|
714
|
-
val = item.get("VALUE", item.get("VALUE ", item.get("value")))
|
|
757
|
+
val = item.get("VALUE", item.get("VALUE ", item.get("value"))) # "VALUE " fallback for firmware <1.0.9 trailing-space bug
|
|
715
758
|
flat_dict[key] = val
|
|
716
759
|
return flat_dict
|
|
717
760
|
|
|
@@ -1620,7 +1663,10 @@ class VioletPoolAPI:
|
|
|
1620
1663
|
is_info, is_reminder.
|
|
1621
1664
|
|
|
1622
1665
|
"""
|
|
1623
|
-
code = str(error_code).strip()
|
|
1666
|
+
code = str(error_code).strip()
|
|
1667
|
+
if not code:
|
|
1668
|
+
return {"code": "0000", "severity": "UNKNOWN", "message": "Invalid empty error code"}
|
|
1669
|
+
code = code.zfill(4)
|
|
1624
1670
|
info = ERROR_CODES.get(code)
|
|
1625
1671
|
|
|
1626
1672
|
if info:
|
|
@@ -2085,7 +2131,9 @@ class VioletPoolAPI:
|
|
|
2085
2131
|
header = lines[0].split(";")
|
|
2086
2132
|
values = lines[2].split(";")
|
|
2087
2133
|
result: dict[str, str] = {}
|
|
2088
|
-
|
|
2134
|
+
if len(header) != len(values):
|
|
2135
|
+
_LOGGER.warning("Live trace header/value length mismatch: %d vs %d", len(header), len(values))
|
|
2136
|
+
for key, raw_value in zip(header, values):
|
|
2089
2137
|
key = key.strip()
|
|
2090
2138
|
if not key:
|
|
2091
2139
|
continue
|
|
@@ -2175,4 +2223,5 @@ class VioletPoolAPI:
|
|
|
2175
2223
|
)
|
|
2176
2224
|
if isinstance(resp, dict):
|
|
2177
2225
|
return resp
|
|
2226
|
+
_LOGGER.warning("Unexpected non-dict response for get_output_runtimes: %s", type(resp).__name__)
|
|
2178
2227
|
return {}
|
|
@@ -26,7 +26,7 @@ from enum import StrEnum
|
|
|
26
26
|
from typing import TYPE_CHECKING, Any
|
|
27
27
|
|
|
28
28
|
if TYPE_CHECKING:
|
|
29
|
-
from collections.abc import Callable
|
|
29
|
+
from collections.abc import Awaitable, Callable
|
|
30
30
|
|
|
31
31
|
_LOGGER = logging.getLogger(__name__)
|
|
32
32
|
|
|
@@ -86,7 +86,7 @@ class CircuitBreaker:
|
|
|
86
86
|
recovery_timeout,
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
-
async def call(self, func: Callable, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
89
|
+
async def call(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
90
90
|
"""Execute function with circuit breaker protection.
|
|
91
91
|
|
|
92
92
|
Args:
|
|
@@ -116,14 +116,15 @@ class CircuitBreaker:
|
|
|
116
116
|
"Circuit breaker entering HALF_OPEN state for recovery test",
|
|
117
117
|
)
|
|
118
118
|
|
|
119
|
-
# Check if half-open timeout exceeded
|
|
119
|
+
# Check if half-open timeout exceeded — but ONLY transition to CLOSED
|
|
120
|
+
# when a call actually succeeds (see the try block below).
|
|
120
121
|
if (
|
|
121
122
|
self.state == CircuitBreakerState.HALF_OPEN
|
|
122
123
|
and current_time - self.half_open_start_time > self.recovery_timeout
|
|
123
124
|
):
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
_LOGGER.info(
|
|
126
|
+
"Circuit breaker HALF_OPEN timeout — will close on next success"
|
|
127
|
+
)
|
|
127
128
|
|
|
128
129
|
# Fail fast if circuit is open
|
|
129
130
|
if self.state == CircuitBreakerState.OPEN:
|
|
@@ -164,7 +165,7 @@ class CircuitBreaker:
|
|
|
164
165
|
|
|
165
166
|
except Exception:
|
|
166
167
|
# Unexpected exception - don't count for circuit breaker
|
|
167
|
-
_LOGGER.exception("
|
|
168
|
+
_LOGGER.exception("Unhandled exception escaped circuit breaker")
|
|
168
169
|
raise
|
|
169
170
|
|
|
170
171
|
else:
|
|
@@ -180,7 +181,9 @@ class CircuitBreaker:
|
|
|
180
181
|
return result
|
|
181
182
|
|
|
182
183
|
def get_stats(self) -> dict[str, Any]:
|
|
183
|
-
"""Get circuit breaker statistics."""
|
|
184
|
+
"""Get circuit breaker statistics (thread-safe snapshot)."""
|
|
185
|
+
if self._lock.locked():
|
|
186
|
+
return {"state": self.state, "note": "lock held, stats may be stale"}
|
|
184
187
|
return {
|
|
185
188
|
"state": self.state,
|
|
186
189
|
"failure_count": self.failure_count,
|
|
@@ -135,7 +135,10 @@ def parse_epoch_seconds(value: int | float | str) -> datetime | None:
|
|
|
135
135
|
return None
|
|
136
136
|
if not math.isfinite(ts) or ts == 0:
|
|
137
137
|
return None
|
|
138
|
-
|
|
138
|
+
try:
|
|
139
|
+
return datetime.fromtimestamp(ts, tz=UTC)
|
|
140
|
+
except (OSError, OverflowError):
|
|
141
|
+
return None
|
|
139
142
|
|
|
140
143
|
|
|
141
144
|
def parse_epoch_milliseconds(value: int | float | str) -> datetime | None:
|
|
@@ -157,7 +160,10 @@ def parse_epoch_milliseconds(value: int | float | str) -> datetime | None:
|
|
|
157
160
|
return None
|
|
158
161
|
if not math.isfinite(ts_ms) or ts_ms == 0:
|
|
159
162
|
return None
|
|
160
|
-
|
|
163
|
+
try:
|
|
164
|
+
return datetime.fromtimestamp(ts_ms / 1000.0, tz=UTC)
|
|
165
|
+
except (OSError, OverflowError):
|
|
166
|
+
return None
|
|
161
167
|
|
|
162
168
|
|
|
163
169
|
def parse_optional_seconds(value: str | float | int) -> timedelta | None:
|
|
File without changes
|
|
@@ -71,7 +71,7 @@ def _opt_float(raw: Any) -> float | None: # noqa: ANN401
|
|
|
71
71
|
return None
|
|
72
72
|
try:
|
|
73
73
|
return float(raw)
|
|
74
|
-
except (ValueError, TypeError):
|
|
74
|
+
except (ValueError, TypeError, OverflowError):
|
|
75
75
|
return None
|
|
76
76
|
|
|
77
77
|
|
|
@@ -410,7 +410,14 @@ class VioletReadings(Mapping[str, Any]):
|
|
|
410
410
|
``True`` means the input is closed (logic 1), ``False`` means open (0).
|
|
411
411
|
Absent inputs default to ``False``.
|
|
412
412
|
"""
|
|
413
|
-
|
|
413
|
+
result: dict[int, bool] = {}
|
|
414
|
+
for i in range(1, 13):
|
|
415
|
+
raw = self._raw.get(f"INPUT{i}", 0)
|
|
416
|
+
try:
|
|
417
|
+
result[i] = bool(int(raw or 0))
|
|
418
|
+
except (ValueError, TypeError):
|
|
419
|
+
result[i] = False
|
|
420
|
+
return result
|
|
414
421
|
|
|
415
422
|
# ------------------------------------------------------------------
|
|
416
423
|
# Dosing channel output states
|
|
@@ -428,14 +435,17 @@ class VioletReadings(Mapping[str, Any]):
|
|
|
428
435
|
@cached_property
|
|
429
436
|
def dosing_daily_amounts_ml(self) -> dict[str, int | None]:
|
|
430
437
|
"""Today's dosing amounts in mL for each dosing channel."""
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
438
|
+
result: dict[str, int | None] = {}
|
|
439
|
+
for key in _DOSING_KEYS:
|
|
440
|
+
v = self._raw.get(f"{key}_DAILY_DOSING_AMOUNT_ML")
|
|
441
|
+
if v is None:
|
|
442
|
+
result[key] = None
|
|
443
|
+
else:
|
|
444
|
+
try:
|
|
445
|
+
result[key] = int(v)
|
|
446
|
+
except (ValueError, TypeError):
|
|
447
|
+
result[key] = None
|
|
448
|
+
return result
|
|
439
449
|
|
|
440
450
|
# ------------------------------------------------------------------
|
|
441
451
|
# DMX light scenes (1-12)
|
|
@@ -65,9 +65,10 @@ class RateLimiter:
|
|
|
65
65
|
self.last_refill = time.monotonic()
|
|
66
66
|
|
|
67
67
|
# Optimized request history with size and time limits
|
|
68
|
-
self.request_history: deque = deque(maxlen=500)
|
|
68
|
+
self.request_history: deque = deque(maxlen=500)
|
|
69
69
|
self.blocked_requests = 0
|
|
70
70
|
self.total_requests = 0
|
|
71
|
+
self._last_known_tokens = 0.0
|
|
71
72
|
self.history_cleanup_interval = 300 # 5 minutes
|
|
72
73
|
self.last_cleanup_time = time.monotonic()
|
|
73
74
|
|
|
@@ -126,6 +127,7 @@ class RateLimiter:
|
|
|
126
127
|
# Track failures efficiently
|
|
127
128
|
self.blocked_requests += 1
|
|
128
129
|
self._recent_stats["blocked_last_minute"] += 1
|
|
130
|
+
self._last_known_tokens = self.tokens
|
|
129
131
|
return False
|
|
130
132
|
|
|
131
133
|
def _cleanup_history(self, current_time: float) -> None:
|
|
@@ -171,10 +173,8 @@ class RateLimiter:
|
|
|
171
173
|
msg = f"Rate Limiter timeout nach {elapsed:.1f}s"
|
|
172
174
|
raise TimeoutError(msg)
|
|
173
175
|
|
|
174
|
-
# Adaptive Wartezeit: Berechne basierend auf Refill-Rate
|
|
175
|
-
# Statt fest retry_after zu warten, berechne optimale Wartezeit
|
|
176
176
|
refill_rate = self.max_requests / self.time_window
|
|
177
|
-
needed_tokens = 1.0 - self.
|
|
177
|
+
needed_tokens = 1.0 - self._last_known_tokens
|
|
178
178
|
if refill_rate > 0:
|
|
179
179
|
optimal_wait = needed_tokens / refill_rate
|
|
180
180
|
wait_time = min(optimal_wait, self.retry_after)
|
|
@@ -220,6 +220,8 @@ class RateLimiter:
|
|
|
220
220
|
self.blocked_requests = 0
|
|
221
221
|
self.total_requests = 0
|
|
222
222
|
self.request_history.clear()
|
|
223
|
+
self._recent_stats["requests_last_minute"] = 0
|
|
224
|
+
self._recent_stats["blocked_last_minute"] = 0
|
|
223
225
|
_LOGGER.debug("Rate Limiter zurückgesetzt")
|
|
224
226
|
|
|
225
227
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import logging
|
|
23
|
+
import math
|
|
23
24
|
import re
|
|
24
25
|
import unicodedata
|
|
25
26
|
from html import escape
|
|
@@ -98,20 +99,16 @@ class InputSanitizer:
|
|
|
98
99
|
)
|
|
99
100
|
str_value = str_value[:max_length]
|
|
100
101
|
|
|
101
|
-
# Wenn keine Sonderzeichen erlaubt sind, entfernen wir sie ZUERST.
|
|
102
102
|
if not allow_special_chars:
|
|
103
|
-
# Entferne gefährliche Zeichen
|
|
104
103
|
original = str_value
|
|
105
|
-
str_value = re.sub(r"[^a-zA-Z0-
|
|
104
|
+
str_value = re.sub(r"[^a-zA-Z0-9 _-]", "", str_value)
|
|
106
105
|
if str_value != original:
|
|
107
106
|
_LOGGER.warning(
|
|
108
107
|
"Gefährliche Zeichen entfernt: '%s' → '%s'",
|
|
109
108
|
original,
|
|
110
109
|
str_value,
|
|
111
110
|
)
|
|
112
|
-
|
|
113
|
-
# (sonst sind < und > bereits durch Regex entfernt)
|
|
114
|
-
elif escape_html:
|
|
111
|
+
if escape_html:
|
|
115
112
|
str_value = escape(str_value)
|
|
116
113
|
|
|
117
114
|
return str_value
|
|
@@ -129,18 +126,22 @@ class InputSanitizer:
|
|
|
129
126
|
"""
|
|
130
127
|
try:
|
|
131
128
|
if isinstance(value, (int, float)):
|
|
129
|
+
if not math.isfinite(float(value)):
|
|
130
|
+
_LOGGER.warning("Unendlicher/NaN-Wert: %s", value)
|
|
131
|
+
return 0.0
|
|
132
132
|
return float(value)
|
|
133
133
|
|
|
134
|
-
# Konvertiere String zu Zahl
|
|
135
134
|
str_value = str(value).strip()
|
|
136
|
-
|
|
137
|
-
cleaned = re.sub(r"[^0-9
|
|
135
|
+
has_minus = str_value.startswith("-")
|
|
136
|
+
cleaned = re.sub(r"[^0-9.]", "", str_value)
|
|
137
|
+
if has_minus and cleaned:
|
|
138
|
+
cleaned = "-" + cleaned
|
|
138
139
|
|
|
139
140
|
if not cleaned:
|
|
140
141
|
return 0.0
|
|
141
142
|
|
|
142
143
|
return float(cleaned)
|
|
143
|
-
except (ValueError, TypeError):
|
|
144
|
+
except (ValueError, TypeError, OverflowError):
|
|
144
145
|
_LOGGER.warning("Ungültiger numerischer Wert: %s", value)
|
|
145
146
|
return 0.0
|
|
146
147
|
|
|
@@ -166,7 +167,7 @@ class InputSanitizer:
|
|
|
166
167
|
try:
|
|
167
168
|
try:
|
|
168
169
|
int_value = int(value)
|
|
169
|
-
except (ValueError, TypeError):
|
|
170
|
+
except (ValueError, TypeError, OverflowError):
|
|
170
171
|
int_value = int(float(value))
|
|
171
172
|
|
|
172
173
|
# Range-Validierung
|
|
@@ -466,7 +467,7 @@ class InputSanitizer:
|
|
|
466
467
|
"""
|
|
467
468
|
return InputSanitizer.sanitize_integer(
|
|
468
469
|
orp,
|
|
469
|
-
min_value=
|
|
470
|
+
min_value=500,
|
|
470
471
|
max_value=900,
|
|
471
472
|
default=700,
|
|
472
473
|
)
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
"""Tests for violet_poolcontroller_api.circuit_breaker module."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from violet_poolcontroller_api.circuit_breaker import CircuitBreaker
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestCircuitBreakerStates:
|
|
9
|
-
"""Test circuit breaker state transitions."""
|
|
10
|
-
|
|
11
|
-
@pytest.fixture
|
|
12
|
-
def circuit_breaker(self):
|
|
13
|
-
"""Create circuit breaker instance."""
|
|
14
|
-
return CircuitBreaker(
|
|
15
|
-
failure_threshold=3,
|
|
16
|
-
recovery_timeout=1
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
def test_initial_state_closed(self, circuit_breaker):
|
|
20
|
-
"""Circuit breaker starts in CLOSED state."""
|
|
21
|
-
assert circuit_breaker.state == "CLOSED" or circuit_breaker.state.name == "CLOSED"
|
|
22
|
-
|
|
23
|
-
def test_closed_to_open_transition(self, circuit_breaker):
|
|
24
|
-
"""Transition from CLOSED to OPEN on failures."""
|
|
25
|
-
# Simulate failures
|
|
26
|
-
for _ in range(3):
|
|
27
|
-
circuit_breaker.record_failure()
|
|
28
|
-
# Should be OPEN after threshold
|
|
29
|
-
assert circuit_breaker.state in ["OPEN", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
30
|
-
|
|
31
|
-
def test_open_to_half_open_transition(self, circuit_breaker):
|
|
32
|
-
"""Transition from OPEN to HALF_OPEN after timeout."""
|
|
33
|
-
# Trigger OPEN state
|
|
34
|
-
for _ in range(3):
|
|
35
|
-
circuit_breaker.record_failure()
|
|
36
|
-
|
|
37
|
-
# Wait for recovery timeout (mocked)
|
|
38
|
-
circuit_breaker.last_failure_time = 0 # Simulate timeout
|
|
39
|
-
|
|
40
|
-
# Check if can transition to HALF_OPEN
|
|
41
|
-
result = circuit_breaker.can_attempt()
|
|
42
|
-
assert result is True or result is False
|
|
43
|
-
|
|
44
|
-
def test_half_open_to_closed_on_success(self, circuit_breaker):
|
|
45
|
-
"""Transition from HALF_OPEN to CLOSED on success."""
|
|
46
|
-
# Get to HALF_OPEN state
|
|
47
|
-
for _ in range(3):
|
|
48
|
-
circuit_breaker.record_failure()
|
|
49
|
-
circuit_breaker.last_failure_time = 0
|
|
50
|
-
|
|
51
|
-
# Record success
|
|
52
|
-
circuit_breaker.record_success()
|
|
53
|
-
|
|
54
|
-
# Should be CLOSED
|
|
55
|
-
assert circuit_breaker.state in ["CLOSED", "HALF_OPEN"] or hasattr(circuit_breaker.state, 'name')
|
|
56
|
-
|
|
57
|
-
def test_half_open_to_open_on_failure(self, circuit_breaker):
|
|
58
|
-
"""Transition from HALF_OPEN back to OPEN on failure."""
|
|
59
|
-
# Get to HALF_OPEN state
|
|
60
|
-
for _ in range(3):
|
|
61
|
-
circuit_breaker.record_failure()
|
|
62
|
-
circuit_breaker.last_failure_time = 0
|
|
63
|
-
|
|
64
|
-
# Record another failure in HALF_OPEN
|
|
65
|
-
circuit_breaker.record_failure()
|
|
66
|
-
|
|
67
|
-
# Should be OPEN
|
|
68
|
-
state = circuit_breaker.state
|
|
69
|
-
assert state in ["OPEN", "HALF_OPEN"] or hasattr(state, 'name')
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class TestCircuitBreakerBehavior:
|
|
73
|
-
"""Test circuit breaker behavior."""
|
|
74
|
-
|
|
75
|
-
@pytest.fixture
|
|
76
|
-
def breaker(self):
|
|
77
|
-
"""Circuit breaker for testing."""
|
|
78
|
-
return CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
79
|
-
|
|
80
|
-
def test_can_attempt_when_closed(self, breaker):
|
|
81
|
-
"""Allow attempts when CLOSED."""
|
|
82
|
-
assert breaker.can_attempt() is True
|
|
83
|
-
|
|
84
|
-
def test_cannot_attempt_when_open(self, breaker):
|
|
85
|
-
"""Reject attempts when OPEN."""
|
|
86
|
-
# Trigger OPEN
|
|
87
|
-
for _ in range(2):
|
|
88
|
-
breaker.record_failure()
|
|
89
|
-
|
|
90
|
-
# Should not allow attempts
|
|
91
|
-
result = breaker.can_attempt()
|
|
92
|
-
# Result should indicate OPEN state (False or exception)
|
|
93
|
-
assert result is False or isinstance(result, bool)
|
|
94
|
-
|
|
95
|
-
def test_failure_count_tracking(self, breaker):
|
|
96
|
-
"""Track failure count accurately."""
|
|
97
|
-
breaker.record_failure()
|
|
98
|
-
# Failure count should increase (or state should reflect it)
|
|
99
|
-
assert breaker.state is not None
|
|
100
|
-
|
|
101
|
-
def test_success_resets_failure_count(self, breaker):
|
|
102
|
-
"""Success resets failure count."""
|
|
103
|
-
breaker.record_failure()
|
|
104
|
-
breaker.record_success()
|
|
105
|
-
# Failure count should be reset (or state should be CLOSED)
|
|
106
|
-
assert breaker.state == "CLOSED" or breaker.state.name == "CLOSED"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class TestCircuitBreakerConfiguration:
|
|
110
|
-
"""Test circuit breaker configuration."""
|
|
111
|
-
|
|
112
|
-
def test_custom_failure_threshold(self):
|
|
113
|
-
"""Set custom failure threshold."""
|
|
114
|
-
cb = CircuitBreaker(failure_threshold=5, recovery_timeout=2)
|
|
115
|
-
assert cb is not None
|
|
116
|
-
# Should accept configuration
|
|
117
|
-
for _ in range(5):
|
|
118
|
-
cb.record_failure()
|
|
119
|
-
|
|
120
|
-
def test_custom_recovery_timeout(self):
|
|
121
|
-
"""Set custom recovery timeout."""
|
|
122
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5)
|
|
123
|
-
# Timeout should be honored
|
|
124
|
-
for _ in range(2):
|
|
125
|
-
cb.record_failure()
|
|
126
|
-
|
|
127
|
-
def test_custom_circuit_breaker(self):
|
|
128
|
-
"""Create custom circuit breaker."""
|
|
129
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
130
|
-
assert cb is not None
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class TestCircuitBreakerEdgeCases:
|
|
134
|
-
"""Test edge cases."""
|
|
135
|
-
|
|
136
|
-
def test_zero_failure_threshold(self):
|
|
137
|
-
"""Handle zero failure threshold."""
|
|
138
|
-
cb = CircuitBreaker(failure_threshold=0, recovery_timeout=1)
|
|
139
|
-
assert cb is not None
|
|
140
|
-
|
|
141
|
-
def test_negative_recovery_timeout(self):
|
|
142
|
-
"""Handle edge case timeouts."""
|
|
143
|
-
# Breaker should handle gracefully
|
|
144
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0)
|
|
145
|
-
assert cb is not None
|
|
146
|
-
|
|
147
|
-
def test_rapid_failures(self):
|
|
148
|
-
"""Handle rapid consecutive failures."""
|
|
149
|
-
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
150
|
-
for _ in range(10):
|
|
151
|
-
cb.record_failure()
|
|
152
|
-
# Should be OPEN
|
|
153
|
-
assert cb.state in ["OPEN", "HALF_OPEN"] or hasattr(cb.state, 'name')
|
|
154
|
-
|
|
155
|
-
def test_alternating_success_failure(self):
|
|
156
|
-
"""Handle alternating success/failure."""
|
|
157
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
158
|
-
for i in range(5):
|
|
159
|
-
if i % 2 == 0:
|
|
160
|
-
cb.record_failure()
|
|
161
|
-
else:
|
|
162
|
-
cb.record_success()
|
|
163
|
-
# State should reflect pattern
|
|
164
|
-
assert cb.state is not None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
class TestCircuitBreakerThreadSafety:
|
|
168
|
-
"""Test thread-safety (basic)."""
|
|
169
|
-
|
|
170
|
-
def test_concurrent_access_safety(self):
|
|
171
|
-
"""Circuit breaker handles concurrent access."""
|
|
172
|
-
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=1)
|
|
173
|
-
# Should not crash with multiple calls
|
|
174
|
-
cb.record_failure()
|
|
175
|
-
cb.can_attempt()
|
|
176
|
-
cb.record_success()
|
|
177
|
-
assert cb.state is not None
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
class TestCircuitBreakerMetrics:
|
|
181
|
-
"""Test circuit breaker metrics."""
|
|
182
|
-
|
|
183
|
-
def test_tracks_failures(self):
|
|
184
|
-
"""Track failure count."""
|
|
185
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
186
|
-
cb.record_failure()
|
|
187
|
-
cb.record_failure()
|
|
188
|
-
# Should have recorded failures
|
|
189
|
-
assert cb.state is not None
|
|
190
|
-
|
|
191
|
-
def test_tracks_success_count(self):
|
|
192
|
-
"""Track success count."""
|
|
193
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
194
|
-
cb.record_success()
|
|
195
|
-
cb.record_success()
|
|
196
|
-
# Should track successes
|
|
197
|
-
assert cb.state == "CLOSED" or cb.state.name == "CLOSED"
|
|
198
|
-
|
|
199
|
-
def test_last_failure_time(self):
|
|
200
|
-
"""Track last failure time."""
|
|
201
|
-
cb = CircuitBreaker(failure_threshold=2, recovery_timeout=1)
|
|
202
|
-
cb.record_failure()
|
|
203
|
-
# Should have last_failure_time set
|
|
204
|
-
assert hasattr(cb, 'last_failure_time') or cb.state is not None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_api_smoke.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_mock_server.py
RENAMED
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.32 → violet_poolcontroller_api-0.0.34}/tests/test_readings.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|