pySmartHashtag 0.8.2__tar.gz → 0.9.0__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.
Files changed (80) hide show
  1. pysmarthashtag-0.9.0/.devcontainer.json +27 -0
  2. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/workflows/python-package.yml +2 -1
  3. {pysmarthashtag-0.8.2/pySmartHashtag.egg-info → pysmarthashtag-0.9.0}/PKG-INFO +3 -3
  4. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/README.md +1 -1
  5. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0/pySmartHashtag.egg-info}/PKG-INFO +3 -3
  6. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/SOURCES.txt +2 -0
  7. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pyproject.toml +1 -1
  8. pysmarthashtag-0.9.0/pysmarthashtag/__init__.py +9 -0
  9. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/authentication.py +129 -6
  10. pysmarthashtag-0.9.0/pysmarthashtag/tests/test_authentication_backoff.py +193 -0
  11. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements-test.txt +1 -1
  12. pysmarthashtag-0.8.2/pysmarthashtag/__init__.py +0 -5
  13. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/copilot-instructions.md +0 -0
  14. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/dependabot.yml +0 -0
  15. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.github/workflows/python-publish.yml +0 -0
  16. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.gitignore +0 -0
  17. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.pre-commit-config.yaml +0 -0
  18. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.vscode/launch.json +0 -0
  19. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/.vscode/settings.json +0 -0
  20. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/AUTHORS +0 -0
  21. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  22. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/CONTRIBUTING.md +0 -0
  23. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/ChangeLog +0 -0
  24. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/LICENSE +0 -0
  25. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/dependency_links.txt +0 -0
  26. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/requires.txt +0 -0
  27. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pySmartHashtag.egg-info/top_level.txt +0 -0
  28. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/account.py +0 -0
  29. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/__init__.py +0 -0
  30. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/client.py +0 -0
  31. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/log_sanitizer.py +0 -0
  32. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/ssl_context.py +0 -0
  33. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/api/utils.py +0 -0
  34. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/cli.py +0 -0
  35. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/const.py +0 -0
  36. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/control/charging.py +0 -0
  37. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/control/climate.py +0 -0
  38. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/models.py +0 -0
  39. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/__init__.py +0 -0
  40. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/common.py +0 -0
  41. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/conftest.py +0 -0
  42. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/Human_and_vehicle_relationship_does_not_exist.json +0 -0
  43. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/api_access.json +0 -0
  44. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_context.url +0 -0
  45. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_intermediate.url +0 -0
  46. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/auth_result.url +0 -0
  47. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/charging_success.json +0 -0
  48. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/climate_success.json +0 -0
  49. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/login_result.json +0 -0
  50. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/ota_response.json +0 -0
  51. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/soc_80.json +0 -0
  52. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/soc_90.json +0 -0
  53. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/token_expired.json +0 -0
  54. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info.json +0 -0
  55. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info2.json +0 -0
  56. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_info_dc_charging.json +0 -0
  57. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_response.json +0 -0
  58. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/replys/vehicle_result.json +0 -0
  59. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_account.py +0 -0
  60. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_actions.py +0 -0
  61. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_charging.py +0 -0
  62. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_dc_charging.py +0 -0
  63. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_endpoint_urls.py +0 -0
  64. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_log_sanitizer.py +0 -0
  65. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_missing_fields.py +0 -0
  66. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/tests/test_ssl_context.py +0 -0
  67. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/__init__.py +0 -0
  68. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/battery.py +0 -0
  69. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/climate.py +0 -0
  70. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/maintenance.py +0 -0
  71. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/position.py +0 -0
  72. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/running.py +0 -0
  73. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/safety.py +0 -0
  74. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/tires.py +0 -0
  75. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/pysmarthashtag/vehicle/vehicle.py +0 -0
  76. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements-cli.txt +0 -0
  77. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/requirements.txt +0 -0
  78. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/run.sh +0 -0
  79. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/setup.cfg +0 -0
  80. {pysmarthashtag-0.8.2 → pysmarthashtag-0.9.0}/setup.py +0 -0
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pySmartHashtag",
3
+ "image": "mcr.microsoft.com/devcontainers/python:dev-3.14-bookworm",
4
+ "postCreateCommand": "pip install -r requirements.txt && pip install -r requirements-test.txt && pip install -e .",
5
+ "customizations": {
6
+ "vscode": {
7
+ "extensions": [
8
+ "ms-python.python",
9
+ "charliermarsh.ruff",
10
+ "github.vscode-pull-request-github",
11
+ "ryanluker.vscode-coverage-gutters",
12
+ "ms-python.vscode-pylance"
13
+ ],
14
+ "settings": {
15
+ "files.eol": "\n",
16
+ "editor.tabSize": 4,
17
+ "python.analysis.autoSearchPaths": false,
18
+ "editor.defaultFormatter": "charliermarsh.ruff",
19
+ "editor.formatOnPaste": false,
20
+ "editor.formatOnSave": true,
21
+ "editor.formatOnType": true,
22
+ "files.trimTrailingWhitespace": true
23
+ }
24
+ }
25
+ },
26
+ "remoteUser": "vscode"
27
+ }
@@ -20,7 +20,7 @@ jobs:
20
20
  strategy:
21
21
  fail-fast: false
22
22
  matrix:
23
- python-version: ["3.9", "3.10", "3.11", "3.12"]
23
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
24
24
 
25
25
  steps:
26
26
  - uses: actions/checkout@v4.1.1
@@ -47,6 +47,7 @@ jobs:
47
47
  pytest --cov=pysmarthashtag --cov-report=xml --cov-report=term-missing --cov-fail-under=80 pysmarthashtag/tests/
48
48
  - name: Pytest coverage comment
49
49
  id: coverageComment
50
+ if: github.event_name != 'pull_request'
50
51
  uses: MishaKav/pytest-coverage-comment@main
51
52
  with:
52
53
  pytest-xml-coverage-path: ./coverage.xml
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pySmartHashtag
3
- Version: 0.8.2
4
- Summary: A python library to get information from Smart #1 and #3 web API
3
+ Version: 0.9.0
4
+ Summary: A python library to get information from Smart #1, #3 and #5 web API
5
5
  Home-page: https://github.com/dasBasti/pySmartHashtag
6
6
  Author: dasBasti
7
7
  Author-email: Bastian Neumann <neumann.bastian@gmail.com>
@@ -52,7 +52,7 @@ Dynamic: license-file
52
52
  [![CodeQL Validation][codeql-shield]][codeql]
53
53
  [![Dependency Validation][tests-shield]][tests]
54
54
 
55
- API wrapper for Smart #1 and #3 Cloud Service
55
+ API wrapper for Smart #1, #3 and #5 Cloud Service
56
56
 
57
57
  Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
58
58
 
@@ -5,7 +5,7 @@
5
5
  [![CodeQL Validation][codeql-shield]][codeql]
6
6
  [![Dependency Validation][tests-shield]][tests]
7
7
 
8
- API wrapper for Smart #1 and #3 Cloud Service
8
+ API wrapper for Smart #1, #3 and #5 Cloud Service
9
9
 
10
10
  Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
11
11
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pySmartHashtag
3
- Version: 0.8.2
4
- Summary: A python library to get information from Smart #1 and #3 web API
3
+ Version: 0.9.0
4
+ Summary: A python library to get information from Smart #1, #3 and #5 web API
5
5
  Home-page: https://github.com/dasBasti/pySmartHashtag
6
6
  Author: dasBasti
7
7
  Author-email: Bastian Neumann <neumann.bastian@gmail.com>
@@ -52,7 +52,7 @@ Dynamic: license-file
52
52
  [![CodeQL Validation][codeql-shield]][codeql]
53
53
  [![Dependency Validation][tests-shield]][tests]
54
54
 
55
- API wrapper for Smart #1 and #3 Cloud Service
55
+ API wrapper for Smart #1, #3 and #5 Cloud Service
56
56
 
57
57
  Regard this to be kind of stable. This library is used in custom [Homeassistant](https://homeassistant.io) component [Smart Hashtag](https://github.com/DasBasti/SmartHashtag)
58
58
 
@@ -1,3 +1,4 @@
1
+ .devcontainer.json
1
2
  .gitignore
2
3
  .pre-commit-config.yaml
3
4
  AUTHORS
@@ -42,6 +43,7 @@ pysmarthashtag/tests/common.py
42
43
  pysmarthashtag/tests/conftest.py
43
44
  pysmarthashtag/tests/test_account.py
44
45
  pysmarthashtag/tests/test_actions.py
46
+ pysmarthashtag/tests/test_authentication_backoff.py
45
47
  pysmarthashtag/tests/test_charging.py
46
48
  pysmarthashtag/tests/test_dc_charging.py
47
49
  pysmarthashtag/tests/test_endpoint_urls.py
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  authors = [
9
9
  {"name" = "Bastian Neumann", "email" = "neumann.bastian@gmail.com"},
10
10
  ]
11
- description = "A python library to get information from Smart #1 and #3 web API"
11
+ description = "A python library to get information from Smart #1, #3 and #5 web API"
12
12
  license = {file = "LICENSE"}
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.9"
@@ -0,0 +1,9 @@
1
+ """Library to read data from the Smart API."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+
6
+ try:
7
+ __version__ = version("pySmartHashtag")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0"
@@ -28,9 +28,34 @@ EXPIRES_AT_OFFSET = datetime.timedelta(seconds=HTTPX_TIMEOUT * 2)
28
28
  _LOGGER = logging.getLogger(__name__)
29
29
 
30
30
 
31
+ # PATCH: shared breaker state so HA's config_entries retry storm (which
32
+ # creates fresh SmartAuthentication instances) cannot bypass the quiet
33
+ # window. Keyed by username — state persists for the lifetime of the
34
+ # Python process.
35
+ class _BackoffState:
36
+ __slots__ = ("backoff", "quiet_until")
37
+
38
+ def __init__(self, backoff: datetime.timedelta) -> None:
39
+ self.backoff: datetime.timedelta = backoff
40
+ self.quiet_until: Optional[datetime.datetime] = None
41
+
42
+
43
+ _BACKOFF_REGISTRY: dict[str, _BackoffState] = {}
44
+
45
+
31
46
  class SmartAuthentication(httpx.Auth):
32
47
  """Authentication and Retry Handler for the Smart API."""
33
48
 
49
+ # PATCH: Adaptive rate-limit backoff (AIMD).
50
+ # On rate-limit failure (HTTP 403/429), multiply current backoff by GROW (capped at CAP).
51
+ # On success, subtract SHRINK (floored at FLOOR). On non-rate-limit failure,
52
+ # use a fixed short backoff so transient blips don't grow the window.
53
+ _BACKOFF_FLOOR = datetime.timedelta(minutes=2)
54
+ _BACKOFF_CAP = datetime.timedelta(minutes=90)
55
+ _BACKOFF_GROW = 1.5
56
+ _BACKOFF_SHRINK = datetime.timedelta(minutes=1)
57
+ _OTHER_FAILURE_BACKOFF = datetime.timedelta(seconds=60)
58
+
34
59
  def __init__(
35
60
  self,
36
61
  username: str,
@@ -53,6 +78,13 @@ class SmartAuthentication(httpx.Auth):
53
78
  self.api_user_id: Optional[str] = None
54
79
  self.ssl_context: Optional[ssl.SSLContext] = ssl_context
55
80
  self.endpoint_urls: EndpointUrls = endpoint_urls if endpoint_urls is not None else EndpointUrls()
81
+ # PATCH: shared adaptive-backoff state (per username, module-scoped).
82
+ # Sharing across instances is essential because HA's config_entries
83
+ # retry mechanism creates fresh SmartAuthentication instances on
84
+ # every retry — per-instance state would never accumulate.
85
+ if username not in _BACKOFF_REGISTRY:
86
+ _BACKOFF_REGISTRY[username] = _BackoffState(self._BACKOFF_FLOOR)
87
+ self._state: _BackoffState = _BACKOFF_REGISTRY[username]
56
88
  _LOGGER.debug("Device ID initialized")
57
89
 
58
90
  async def get_ssl_context(self) -> ssl.SSLContext:
@@ -167,8 +199,87 @@ class SmartAuthentication(httpx.Auth):
167
199
  _LOGGER.debug("Refreshing access token failed. Logging in again")
168
200
  return {}
169
201
 
170
- async def _login(self):
171
- """Login to Smart web services."""
202
+ async def _login(self) -> dict:
203
+ """Login to Smart web services with adaptive rate-limit backoff (PATCH).
204
+
205
+ Wraps the original login flow (now ``_do_login``) with:
206
+ * an in-memory circuit breaker (shared per-username) that suppresses
207
+ calls while inside an adaptive quiet window, and
208
+ * AIMD backoff: rate-limit failures grow the window geometrically,
209
+ successes shrink it additively.
210
+ """
211
+ now = datetime.datetime.now(datetime.timezone.utc)
212
+ if self._state.quiet_until is not None and now < self._state.quiet_until:
213
+ wait_s = int((self._state.quiet_until - now).total_seconds())
214
+ raise SmartAPIError(
215
+ "Smart API in adaptive quiet window for another "
216
+ f"{wait_s}s (until "
217
+ f"{self._state.quiet_until.isoformat(timespec='seconds')}, "
218
+ f"backoff={self._state.backoff})"
219
+ )
220
+ try:
221
+ result = await self._do_login()
222
+ except (SmartAPIError, httpx.HTTPStatusError) as exc:
223
+ self._on_login_failure(exc)
224
+ raise
225
+ else:
226
+ self._on_login_success()
227
+ return result
228
+
229
+ def _on_login_failure(self, exc: Exception) -> None:
230
+ """Update backoff state after a failed login attempt (PATCH)."""
231
+ now = datetime.datetime.now(datetime.timezone.utc)
232
+ if self._is_rate_limit_error(exc):
233
+ new_backoff = min(
234
+ self._state.backoff * self._BACKOFF_GROW, self._BACKOFF_CAP
235
+ )
236
+ self._state.backoff = new_backoff
237
+ self._state.quiet_until = now + new_backoff
238
+ _LOGGER.warning(
239
+ "Smart API rate-limit detected (%s). Adaptive quiet window: "
240
+ "%s, until %s",
241
+ exc, new_backoff,
242
+ self._state.quiet_until.isoformat(timespec='seconds'),
243
+ )
244
+ else:
245
+ self._state.quiet_until = now + self._OTHER_FAILURE_BACKOFF
246
+ _LOGGER.info(
247
+ "Smart API login failed (%s). Short retry-suppress until %s",
248
+ exc, self._state.quiet_until.isoformat(timespec='seconds'),
249
+ )
250
+
251
+ def _on_login_success(self) -> None:
252
+ """Update backoff state after a successful login (PATCH)."""
253
+ prev = self._state.backoff
254
+ new_backoff = max(
255
+ self._state.backoff - self._BACKOFF_SHRINK, self._BACKOFF_FLOOR
256
+ )
257
+ self._state.backoff = new_backoff
258
+ self._state.quiet_until = None
259
+ if prev > self._BACKOFF_FLOOR:
260
+ _LOGGER.info(
261
+ "Smart API login succeeded; backoff %s -> %s", prev, new_backoff
262
+ )
263
+
264
+ @staticmethod
265
+ def _is_rate_limit_error(exc: Exception) -> bool:
266
+ """Heuristic: was *exc* caused by Smart's WAF / rate-limiter."""
267
+ if isinstance(exc, httpx.HTTPStatusError):
268
+ return exc.response.status_code in (403, 429)
269
+ text = str(exc).lower()
270
+ # Match the embellished error messages from _do_login below, plus
271
+ # explicit Smart strings ("Api Rate limit exceeded").
272
+ return (
273
+ "rate limit" in text
274
+ or "rate-limit" in text
275
+ or "quota" in text
276
+ or "http 403" in text
277
+ or "http 429" in text
278
+ or "forbidden" in text
279
+ )
280
+
281
+ async def _do_login(self):
282
+ """Original login flow (extracted by PATCH)."""
172
283
  ssl_ctx = await self.get_ssl_context()
173
284
  async with SmartLoginClient(ssl_context=ssl_ctx) as client:
174
285
  _LOGGER.info("Acquiring access token.")
@@ -189,8 +300,14 @@ class SmartAuthentication(httpx.Auth):
189
300
  try:
190
301
  context = r_context.url.params["context"]
191
302
  _LOGGER.debug("Context: %s", context)
192
- except KeyError:
193
- raise SmartAPIError("Could not get context from login page")
303
+ except KeyError as err:
304
+ # PATCH: include status + body so the breaker can classify (rate-limit vs other).
305
+ # Body is run through sanitize_log_data() so any tokens/PII are masked.
306
+ raise SmartAPIError(
307
+ "Could not get context from login page "
308
+ f"(HTTP {r_context.status_code}, "
309
+ f"body={sanitize_log_data(r_context.text[:200])!r})"
310
+ ) from err
194
311
 
195
312
  # Get login token from Smart API
196
313
  r_login = await client.post(
@@ -228,8 +345,14 @@ class SmartAuthentication(httpx.Auth):
228
345
  expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
229
346
  seconds=int(login_result["sessionInfo"]["expires_in"])
230
347
  )
231
- except (KeyError, ValueError):
232
- raise SmartAPIError("Could not get login token from login page")
348
+ except (KeyError, ValueError) as err:
349
+ # PATCH: include status + body so the breaker can classify.
350
+ # Body is run through sanitize_log_data() so any tokens/PII are masked.
351
+ raise SmartAPIError(
352
+ "Could not get login token from login page "
353
+ f"(HTTP {r_login.status_code}, "
354
+ f"body={sanitize_log_data(r_login.text[:200])!r})"
355
+ ) from err
233
356
 
234
357
  auth_url = self.endpoint_urls.get_auth_url() + "?context=" + context + "&login_token=" + login_token
235
358
  cookie = f"gmid=gmid.ver4.AcbHPqUK5Q.xOaWPhRTb7gy-6-GUW6cxQVf_t7LhbmeabBNXqqqsT6dpLJLOWCGWZM07EkmfM4j.u2AMsCQ9ZsKc6ugOIoVwCgryB2KJNCnbBrlY6pq0W2Ww7sxSkUa9_WTPBIwAufhCQYkb7gA2eUbb6EIZjrl5mQ.sc3; ucid=hPzasmkDyTeHN0DinLRGvw; hasGmid=ver4; gig_bootstrap_3_L94eyQ-wvJhWm7Afp1oBhfTGXZArUfSHHW9p9Pncg513hZELXsxCfMWHrF8f5P5a=auth_ver4; glt_3_L94eyQ-wvJhWm7Afp1oBhfTGXZArUfSHHW9p9Pncg513hZELXsxCfMWHrF8f5P5a={login_token}" # noqa: E501
@@ -0,0 +1,193 @@
1
+ """Tests for the adaptive rate-limit backoff in ``SmartAuthentication``.
2
+
3
+ These tests stub ``_do_login`` so no network calls happen and exercise:
4
+
5
+ * AIMD growth on rate-limit failures (HTTP 403/429)
6
+ * Circuit-breaker suppression while the quiet window is active
7
+ * Multiplicative shrink on successful login
8
+ * Fixed short suppress (no growth) on non-rate-limit failures
9
+ * Floor invariant after many successes
10
+ * Module-level state shared across ``SmartAuthentication`` instances
11
+ for the same username
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime
17
+ from unittest.mock import patch
18
+
19
+ import httpx
20
+ import pytest
21
+
22
+ from pysmarthashtag.api.authentication import (
23
+ _BACKOFF_REGISTRY,
24
+ SmartAPIError,
25
+ SmartAuthentication,
26
+ )
27
+
28
+
29
+ def _make_auth(username: str = "user@example.com") -> SmartAuthentication:
30
+ """Reset shared backoff state for ``username`` and return a fresh auth."""
31
+ _BACKOFF_REGISTRY.pop(username, None)
32
+ return SmartAuthentication(username=username, password="p")
33
+
34
+
35
+ def _http_403() -> httpx.HTTPStatusError:
36
+ req = httpx.Request("GET", "https://example/")
37
+ resp = httpx.Response(403, request=req, content=b'{"message":"Forbidden"}')
38
+ return httpx.HTTPStatusError("forbidden", request=req, response=resp)
39
+
40
+
41
+ def _login_ok() -> dict:
42
+ return {
43
+ "access_token": "a",
44
+ "refresh_token": "r",
45
+ "api_access_token": "x",
46
+ "api_refresh_token": "y",
47
+ "api_user_id": "z",
48
+ "expires_at": datetime.datetime.now(datetime.timezone.utc)
49
+ + datetime.timedelta(hours=1),
50
+ }
51
+
52
+
53
+ class TestAdaptiveBackoff:
54
+ """Adaptive AIMD rate-limit backoff."""
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_grow_on_rate_limit(self):
58
+ """Each rate-limit hit grows the backoff up to ``_BACKOFF_CAP``."""
59
+ auth = _make_auth()
60
+ floor_min = auth._BACKOFF_FLOOR.total_seconds() / 60
61
+ cap_min = auth._BACKOFF_CAP.total_seconds() / 60
62
+ grow = auth._BACKOFF_GROW
63
+
64
+ expected: list[float] = []
65
+ v = floor_min
66
+ for _ in range(11):
67
+ v = min(v * grow, cap_min)
68
+ expected.append(round(v, 6))
69
+
70
+ async def boom():
71
+ raise _http_403()
72
+
73
+ seen: list[float] = []
74
+ with patch.object(auth, "_do_login", boom):
75
+ for _ in expected:
76
+ # Pretend the previous quiet window has just elapsed so
77
+ # this probe is allowed through to ``_do_login``.
78
+ auth._state.quiet_until = None
79
+ with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
80
+ await auth._login()
81
+ seen.append(round(auth._state.backoff.total_seconds() / 60, 6))
82
+
83
+ assert seen == expected
84
+ assert auth._state.backoff <= auth._BACKOFF_CAP
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_breaker_blocks_during_quiet_window(self):
88
+ """During the quiet window the breaker must skip ``_do_login``."""
89
+ auth = _make_auth()
90
+
91
+ async def boom():
92
+ raise _http_403()
93
+
94
+ with patch.object(auth, "_do_login", boom):
95
+ with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
96
+ await auth._login()
97
+
98
+ calls = {"n": 0}
99
+
100
+ async def count_calls():
101
+ calls["n"] += 1
102
+ raise _http_403()
103
+
104
+ with patch.object(auth, "_do_login", count_calls):
105
+ with pytest.raises(SmartAPIError) as excinfo:
106
+ await auth._login()
107
+
108
+ assert calls["n"] == 0, "breaker did not suppress the second probe"
109
+ assert "quiet window" in str(excinfo.value)
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_shrink_on_success(self):
113
+ """Success shrinks the backoff and clears the quiet window."""
114
+ auth = _make_auth()
115
+ auth._state.backoff = datetime.timedelta(minutes=30)
116
+ auth._state.quiet_until = None
117
+
118
+ async def ok():
119
+ return _login_ok()
120
+
121
+ with patch.object(auth, "_do_login", ok):
122
+ await auth._login()
123
+
124
+ assert auth._state.backoff == datetime.timedelta(minutes=29)
125
+ assert auth._state.quiet_until is None
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_other_failure_uses_fixed_suppress_no_grow(self):
129
+ """Non-rate-limit failures only set a short suppress, no grow."""
130
+ auth = _make_auth()
131
+ before = auth._state.backoff
132
+
133
+ async def boom():
134
+ raise SmartAPIError("Could not get access token from auth page")
135
+
136
+ with patch.object(auth, "_do_login", boom):
137
+ with pytest.raises(SmartAPIError):
138
+ await auth._login()
139
+
140
+ assert auth._state.backoff == before, "backoff grew on non-rate-limit failure"
141
+ assert auth._state.quiet_until is not None
142
+
143
+ delta = auth._state.quiet_until - datetime.datetime.now(datetime.timezone.utc)
144
+ # Allow a small clock-skew tolerance below the configured suppress window.
145
+ lower_bound = auth._OTHER_FAILURE_BACKOFF - datetime.timedelta(seconds=5)
146
+ assert lower_bound < delta <= auth._OTHER_FAILURE_BACKOFF
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_floor_invariant(self):
150
+ """Repeated successes never push the backoff below the floor."""
151
+ auth = _make_auth()
152
+
153
+ async def ok():
154
+ return _login_ok()
155
+
156
+ with patch.object(auth, "_do_login", ok):
157
+ for _ in range(20):
158
+ await auth._login()
159
+
160
+ assert auth._state.backoff == auth._BACKOFF_FLOOR
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_state_shared_across_instances_same_user(self):
164
+ """Two ``SmartAuthentication`` objects for the same user share state."""
165
+ a1 = _make_auth("shared_user")
166
+
167
+ async def boom():
168
+ raise _http_403()
169
+
170
+ with patch.object(a1, "_do_login", boom):
171
+ a1._state.quiet_until = None
172
+ with pytest.raises((SmartAPIError, httpx.HTTPStatusError)):
173
+ await a1._login()
174
+
175
+ grew_to = a1._state.backoff
176
+ assert grew_to > a1._BACKOFF_FLOOR
177
+
178
+ # New instance for the same username — must reuse the same state.
179
+ a2 = SmartAuthentication(username="shared_user", password="p")
180
+ assert a2._state is a1._state
181
+ assert a2._state.backoff == grew_to
182
+
183
+ calls = {"n": 0}
184
+
185
+ async def count_calls():
186
+ calls["n"] += 1
187
+ raise _http_403()
188
+
189
+ with patch.object(a2, "_do_login", count_calls):
190
+ with pytest.raises(SmartAPIError):
191
+ await a2._login()
192
+
193
+ assert calls["n"] == 0, "shared quiet window did not suppress second instance"
@@ -10,4 +10,4 @@ pbr
10
10
  time_machine<2.20.0
11
11
  pre-commit
12
12
  backports.zoneinfo;python_version<"3.9"
13
- ruff==0.14.14
13
+ ruff==0.15.12
@@ -1,5 +0,0 @@
1
- """Library to read data from the Smart API."""
2
-
3
- from importlib.metadata import version
4
-
5
- __version__ = version("pySmartHashtag")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes