Skip to content

Commit f8833de

Browse files
authored
rate limit patch (#45)
1 parent 8827393 commit f8833de

File tree

6 files changed

+396
-0
lines changed

6 files changed

+396
-0
lines changed

docs/RATE_LIMITING_AND_TIMEOUTS.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Rate Limiting & Default Timeouts (Backwards-Compatible)
2+
3+
This fork adds a **client-side write rate limiter** and **default HTTP timeouts** without breaking existing callers.
4+
5+
- **Backwards compatible:** Existing code that calls `Frigidaire(email, password)` keeps working.
6+
- **Write call smoothing:** Mutating HTTP verbs (`POST`, `PUT`, `PATCH`, `DELETE`) are spaced out by default to avoid server lockouts.
7+
- **Retry-After aware:** `429/423` responses back off, honoring `Retry-After` (capped).
8+
- **Default HTTP timeout:** If a request doesn’t pass `timeout=...`, a library default is applied (`15s` by default).
9+
10+
## Quickstart
11+
12+
```python
13+
from frigidaire import Frigidaire
14+
# If your build doesn't auto-enable autowrap at import time, uncomment one of these:
15+
# import frigidaire.rl_autowrap as _
16+
# from frigidaire.rl_autowrap import enable_autowrap; enable_autowrap()
17+
18+
api = Frigidaire("email", "password")
19+
devices = api.get_appliances()
20+
print(devices)
21+
```
22+
23+
## Customization (optional)
24+
25+
You can pass kwargs to `Frigidaire(...)` to tune behavior. All are optional:
26+
27+
- `http_timeout`: float or `(connect, read)` tuple; default `15.0` seconds
28+
- `rate_limit_min_interval`: seconds between write requests; default `1.25`
29+
- `rate_limit_jitter`: random jitter added to spacing; default `0.25`
30+
- `rate_limit_methods`: set of verbs to limit; default `{"POST","PUT","PATCH","DELETE"}`
31+
- `rate_limit_scope_key`: share a limiter across instances by key (default: the account email)
32+
- `max_retry_after`: cap for honoring server `Retry-After`; default `60.0`
33+
- `max_retries_on_429`: max retries for `429/423`; default `4`
34+
35+
**Example:**
36+
37+
```python
38+
api = Frigidaire(
39+
"email", "password",
40+
http_timeout=20.0, # or (5, 30)
41+
rate_limit_min_interval=1.5, # seconds between writes
42+
rate_limit_jitter=0.3, # smooth bursts
43+
max_retry_after=90.0,
44+
max_retries_on_429=5,
45+
# rate_limit_methods={"POST","PUT","PATCH","DELETE"},
46+
# rate_limit_scope_key="household-42",
47+
)
48+
```
49+
50+
## CLI Smoke Tests
51+
52+
We provide a small script in `scripts/smoke_test_frigidaire.py` that exercises the new features without changing device state.
53+
54+
```bash
55+
python3 -m venv .venv
56+
source .venv/bin/activate
57+
pip install -e .
58+
59+
export FRIGIDAIRE_EMAIL="you@example.com"
60+
export FRIGIDAIRE_PASSWORD="••••••••"
61+
62+
# Optional tunables for the test run
63+
export MIN_INTERVAL=1.5
64+
export JITTER=0.0
65+
export HTTP_TIMEOUT=15.0
66+
67+
python scripts/smoke_test_frigidaire.py --min-interval "${MIN_INTERVAL:-1.5}" --jitter "${JITTER:-0.0}" --http-timeout "${HTTP_TIMEOUT:-15.0}"
68+
```
69+
70+
What it checks:
71+
72+
1. **Auth + list devices** (read-only)
73+
2. **Default timeout** (~15s) using a delayed URL when no per-request timeout is passed
74+
3. **Rate-limit spacing** using harmless POSTs to prove gaps between writes
75+
4. **Retry-After** handling — tries a live 429, then falls back to a local simulation if the live check is blocked
76+
77+
## Opt-in vs. auto-enable
78+
79+
If your package enables autowrap at import time (via a small block in `frigidaire/__init__.py`), no changes are needed by callers.
80+
Otherwise, add one of these once at startup:
81+
82+
```python
83+
import frigidaire.rl_autowrap as _
84+
# or
85+
from frigidaire.rl_autowrap import enable_autowrap
86+
enable_autowrap()
87+
```
88+
89+
## Notes
90+
91+
- The rate limiter is **in-process** and keyed by `rate_limit_scope_key` (defaults to the account email).
92+
- Reads (`GET`) are not rate-limited by default.
93+
- Timeouts apply **only** when a given request doesn’t already supply `timeout=`.
94+
- For Home Assistant users, no changes are required if the library is imported normally and autowrap is enabled.

frigidaire/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,12 @@ def put_request(self, url: str, path: str, headers: Dict[str, str], data: Dict)
592592
return self.parse_response(response)
593593
except Exception as e:
594594
self.handle_request_exception(e, "PUT", f'{url}{path}', headers, encoded_data)
595+
596+
# ---- Auto-enable write rate limiting (safe no-op if already enabled) ----
597+
try:
598+
from .rl_autowrap import enable_autowrap as _frigidaire_enable_autowrap
599+
_frigidaire_enable_autowrap()
600+
except Exception:
601+
# Don't fail imports if autowrap can't be enabled
602+
pass
603+
# -------------------------------------------------------------------------

frigidaire/rate_limit.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# SPDX-License-Identifier: MIT
2+
from __future__ import annotations
3+
import random
4+
import threading
5+
import time
6+
from typing import Callable, Optional, Set, Union, Tuple
7+
8+
RL_DEFAULT_METHODS: Set[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
9+
TimeoutType = Union[float, Tuple[float, float]] # requests supports float or (connect, read)
10+
11+
class RateLimiter:
12+
"""Thread-safe limiter that ensures at least `min_interval` seconds between calls."""
13+
def __init__(self, min_interval: float = 1.25, jitter: float = 0.0) -> None:
14+
self._min = max(0.0, float(min_interval))
15+
self._jitter = max(0.0, float(jitter))
16+
self._lock = threading.Lock()
17+
self._next_ok_at = 0.0 # monotonic timestamp (seconds)
18+
19+
def wait(self) -> None:
20+
with self._lock:
21+
now = time.monotonic()
22+
if now < self._next_ok_at:
23+
time.sleep(self._next_ok_at - now)
24+
delay = self._min + (random.uniform(0.0, self._jitter) if self._jitter else 0.0)
25+
self._next_ok_at = time.monotonic() + delay
26+
27+
def cool_down(self, extra_seconds: float) -> None:
28+
if extra_seconds <= 0:
29+
return
30+
with self._lock:
31+
self._next_ok_at = max(self._next_ok_at, time.monotonic() + float(extra_seconds))
32+
33+
def _parse_retry_after_seconds(value: Optional[str]) -> Optional[float]:
34+
if not value:
35+
return None
36+
try:
37+
return float(value) # prefer seconds
38+
except Exception:
39+
return None
40+
41+
def wrap_session_request(
42+
request_func: Callable[..., "requests.Response"],
43+
limiter: "RateLimiter",
44+
rl_methods: Set[str] = RL_DEFAULT_METHODS,
45+
max_retry_after: float = 60.0,
46+
max_retries_on_429: int = 4,
47+
default_timeout: Optional[TimeoutType] = 15.0,
48+
) -> Callable[..., "requests.Response"]:
49+
"""Wrap requests.Session.request with rate limiting, 429 handling, and default timeout."""
50+
import time as _time
51+
52+
def _wrapped(method: str, url: str, **kwargs):
53+
m = (method or "").upper()
54+
if m in rl_methods:
55+
limiter.wait()
56+
57+
if default_timeout is not None and "timeout" not in kwargs:
58+
kwargs["timeout"] = default_timeout
59+
60+
retries = 0
61+
backoff = 1.0
62+
while True:
63+
resp = request_func(method, url, **kwargs)
64+
status = getattr(resp, "status_code", None)
65+
if status in (429, 423):
66+
retry_after = _parse_retry_after_seconds(getattr(resp, "headers", {}).get("Retry-After"))
67+
delay = min(float(max_retry_after), retry_after or backoff)
68+
limiter.cool_down(delay)
69+
if retries >= max_retries_on_429:
70+
return resp
71+
_time.sleep(delay)
72+
retries += 1
73+
backoff = min(float(max_retry_after), backoff * 2.0)
74+
continue
75+
return resp
76+
77+
return _wrapped

frigidaire/rl_autowrap.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# SPDX-License-Identifier: MIT
2+
from __future__ import annotations
3+
4+
from typing import Any, Optional, Tuple, Union, Set
5+
from .rate_limit import RateLimiter, RL_DEFAULT_METHODS, wrap_session_request
6+
7+
TimeoutType = Union[float, Tuple[float, float]]
8+
_ENABLED = False
9+
10+
def _coerce_timeout(val: Any) -> Optional[TimeoutType]:
11+
if val is None:
12+
return None
13+
if isinstance(val, (int, float)):
14+
return float(val)
15+
if isinstance(val, (tuple, list)) and len(val) == 2:
16+
return (float(val[0]), float(val[1]))
17+
if isinstance(val, str):
18+
s = val.strip()
19+
if "," in s:
20+
a, b = s.split(",", 1)
21+
return (float(a), float(b))
22+
try:
23+
return float(s)
24+
except ValueError:
25+
return None
26+
return None
27+
28+
def enable_autowrap() -> None:
29+
global _ENABLED
30+
if _ENABLED:
31+
return
32+
try:
33+
import frigidaire as pkg
34+
except Exception:
35+
return
36+
37+
Frigidaire = getattr(pkg, "Frigidaire", None)
38+
if Frigidaire is None or getattr(Frigidaire, "_rl_patched", False):
39+
_ENABLED = True
40+
return
41+
42+
orig_init = Frigidaire.__init__
43+
44+
def __init__(self, email: str, password: str, *args: Any, **kwargs: Any):
45+
_rl_min = float(kwargs.pop("rate_limit_min_interval", 1.25))
46+
_rl_jit = float(kwargs.pop("rate_limit_jitter", 0.25))
47+
_rl_methods: Set[str] = kwargs.pop("rate_limit_methods", None) or RL_DEFAULT_METHODS
48+
_rl_scope = kwargs.pop("rate_limit_scope_key", None) or email
49+
_max_retry_after = float(kwargs.pop("max_retry_after", 60.0))
50+
_max_retries_on_429 = int(kwargs.pop("max_retries_on_429", 4))
51+
52+
_http_timeout_raw = kwargs.pop("http_timeout", 15.0)
53+
_http_timeout = _coerce_timeout(_http_timeout_raw)
54+
if _http_timeout is None:
55+
_http_timeout = 15.0
56+
57+
orig_init(self, email, password, *args, **kwargs)
58+
59+
global _SCOPED_LIMITERS
60+
try:
61+
_SCOPED_LIMITERS # type: ignore[name-defined]
62+
except NameError:
63+
_SCOPED_LIMITERS = {} # type: ignore[assignment]
64+
limiter = _SCOPED_LIMITERS.setdefault(_rl_scope, RateLimiter(_rl_min, _rl_jit))
65+
66+
try:
67+
self._session.request = wrap_session_request( # type: ignore[attr-defined]
68+
self._session.request, # type: ignore[attr-defined]
69+
limiter,
70+
_rl_methods,
71+
_max_retry_after,
72+
_max_retries_on_429,
73+
default_timeout=_http_timeout,
74+
)
75+
except Exception:
76+
pass
77+
78+
Frigidaire.__init__ = __init__ # type: ignore[method-assign]
79+
Frigidaire._rl_patched = True # type: ignore[attr-defined]
80+
_ENABLED = True

run_smoke_tests.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# run_smoke_tests.sh — sets up a venv, installs the library in editable mode, and runs smoke tests.
5+
here="$(cd "$(dirname "$0")" && pwd)"
6+
repo_root="$here" # assume script is placed at repo root
7+
8+
# venv
9+
if [[ ! -d "$repo_root/.venv" ]]; then
10+
python3 -m venv "$repo_root/.venv"
11+
fi
12+
# shellcheck disable=SC1091
13+
source "$repo_root/.venv/bin/activate"
14+
pip install -e "$repo_root"
15+
16+
# Prompt for creds if not provided via env (password hidden)
17+
if [[ -z "${FRIGIDAIRE_EMAIL:-}" ]]; then
18+
read -r -p "Frigidaire email: " FRIGIDAIRE_EMAIL
19+
export FRIGIDAIRE_EMAIL
20+
fi
21+
if [[ -z "${FRIGIDAIRE_PASSWORD:-}" ]]; then
22+
read -r -s -p "Frigidaire password: " FRIGIDAIRE_PASSWORD
23+
echo
24+
export FRIGIDAIRE_PASSWORD
25+
fi
26+
27+
# Defaults chosen to make spacing visible
28+
MIN_INTERVAL="${MIN_INTERVAL:-1.5}"
29+
JITTER="${JITTER:-0.0}"
30+
HTTP_TIMEOUT="${HTTP_TIMEOUT:-15.0}"
31+
32+
python "$repo_root/smoke_test_frigidaire.py" \
33+
--min-interval "$MIN_INTERVAL" \
34+
--jitter "$JITTER" \
35+
--http-timeout "$HTTP_TIMEOUT"

0 commit comments

Comments
 (0)