Skip to content

Commit 08d224b

Browse files
committed
0.61.4
1 parent ff179af commit 08d224b

13 files changed

Lines changed: 178 additions & 26 deletions

File tree

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[submodule "mist_openapi"]
22
path = mist_openapi
33
url = https://github.com/mistsys/mist_openapi.git
4-
branch = 2602.1.8
4+
branch = master

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,56 @@
11
# CHANGELOG
2+
## Version 0.61.4 (April 2026)
3+
4+
**Released**: April 1, 2026
5+
6+
This release improves WebSocket reconnection, hardens credential handling, and fixes the two-factor authentication flow.
7+
8+
---
9+
10+
### 1. NEW FEATURES
11+
12+
#### **Capped Reconnect Backoff (`max_reconnect_backoff`)**
13+
The `_MistWebsocket` client now supports a `max_reconnect_backoff` parameter to cap the exponential backoff delay during reconnection attempts:
14+
15+
```python
16+
ws = mistapi.websockets.sites.DeviceStatsEvents(
17+
apisession,
18+
site_ids=["<site_id>"],
19+
auto_reconnect=True,
20+
max_reconnect_backoff=60.0 # Cap backoff at 60 seconds
21+
)
22+
```
23+
24+
#### **Unlimited Reconnect Attempts**
25+
Setting `max_reconnect_attempts=0` now enables unlimited reconnection attempts:
26+
27+
```python
28+
ws = mistapi.websockets.sites.DeviceStatsEvents(
29+
apisession,
30+
site_ids=["<site_id>"],
31+
auto_reconnect=True,
32+
max_reconnect_attempts=0 # Reconnect indefinitely
33+
)
34+
```
35+
36+
---
37+
38+
### 2. IMPROVEMENTS
39+
40+
#### **Credential Override Logging**
41+
`APISession` now logs INFO-level messages when credentials (host, email, password, API token) are overridden by:
42+
- Constructor parameters overriding environment variables
43+
- Vault secrets overriding previously loaded values
44+
- Keyring credentials overriding previously loaded values
45+
46+
#### **Security: Password Cleared After Login**
47+
The stored password is now cleared from memory immediately after successful login authentication.
48+
49+
#### **User Attribute Handling**
50+
`_get_self()` now only sets known user attributes (`first_name`, `last_name`, `email`, `enable_two_factor`, `two_factor_verified`, `no_tracking`, `password_expiry`, `password_modified_time`) instead of setting arbitrary response keys as object attributes.
51+
52+
---
53+
254
## Version 0.61.3 (March 2026)
355

456
**Released**: March 18, 2026

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mistapi"
7-
version = "0.61.3"
7+
version = "0.61.4"
88
authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }]
99
description = "Python package to simplify the Mist System APIs usage"
1010
keywords = ["Mist", "Juniper", "API"]

src/mistapi/__api_session.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,28 @@ def __init__(
157157
self._session.proxies.update(filtered_proxies)
158158

159159
if host:
160+
if self._cloud_uri:
161+
LOGGER.info(
162+
"apisession:__init__: overriding previously loaded MIST_HOST with constructor parameter"
163+
)
160164
self.set_cloud(host)
161165
if email:
166+
if self.email:
167+
LOGGER.info(
168+
"apisession:__init__: overriding previously loaded MIST_USER with constructor parameter"
169+
)
162170
self.set_email(email)
163171
if password:
172+
if self._password:
173+
LOGGER.info(
174+
"apisession:__init__: overriding previously loaded MIST_PASSWORD with constructor parameter"
175+
)
164176
self.set_password(password)
165177
if apitoken:
178+
if self._apitoken:
179+
LOGGER.info(
180+
"apisession:__init__: overriding previously loaded MIST_APITOKEN with constructor parameter"
181+
)
166182
self.set_api_token(apitoken)
167183
self.first_name: str = ""
168184
self.last_name: str = ""
@@ -244,10 +260,18 @@ def _load_vault(
244260
mist_host = read_response["data"]["data"].get("MIST_HOST", None)
245261
LOGGER.info("apisession:_load_vault: MIST_HOST=%s", mist_host)
246262
if mist_host:
263+
if self._cloud_uri:
264+
LOGGER.info(
265+
"apisession:_load_vault: overriding previously loaded MIST_HOST"
266+
)
247267
self.set_cloud(mist_host)
248268

249269
mist_apitoken = read_response["data"]["data"].get("MIST_APITOKEN", None)
250270
if mist_apitoken:
271+
if self._apitoken:
272+
LOGGER.info(
273+
"apisession:_load_vault: overriding previously loaded MIST_APITOKEN"
274+
)
251275
self.set_api_token(mist_apitoken)
252276
except (KeyError, TypeError, AttributeError):
253277
LOGGER.error("apisession:_load_vault: Failed to retrieve secret")
@@ -270,10 +294,18 @@ def _load_keyring(self, keyring_service) -> None:
270294
try:
271295
mist_host = keyring.get_password(keyring_service, "MIST_HOST")
272296
if mist_host:
297+
if self._cloud_uri:
298+
LOGGER.info(
299+
"apisession:_load_keyring: overriding previously loaded MIST_HOST"
300+
)
273301
LOGGER.info("apisession:_load_keyring: MIST_HOST=%s", mist_host)
274302
self.set_cloud(mist_host)
275303
mist_apitoken = keyring.get_password(keyring_service, "MIST_APITOKEN")
276304
if mist_apitoken:
305+
if self._apitoken:
306+
LOGGER.info(
307+
"apisession:_load_keyring: overriding previously loaded MIST_APITOKEN"
308+
)
277309
if isinstance(mist_apitoken, str):
278310
for token in mist_apitoken.split(","):
279311
token = token.strip()
@@ -285,10 +317,18 @@ def _load_keyring(self, keyring_service) -> None:
285317
self.set_api_token(mist_apitoken)
286318
mist_user = keyring.get_password(keyring_service, "MIST_USER")
287319
if mist_user:
320+
if self.email:
321+
LOGGER.info(
322+
"apisession:_load_keyring: overriding previously loaded MIST_USER"
323+
)
288324
LOGGER.info("apisession:_load_keyring: MIST_USER retrieved")
289325
self.set_email(mist_user)
290326
mist_password = keyring.get_password(keyring_service, "MIST_PASSWORD")
291327
if mist_password:
328+
if self._password:
329+
LOGGER.info(
330+
"apisession:_load_keyring: overriding previously loaded MIST_PASSWORD"
331+
)
292332
LOGGER.info("apisession:_load_keyring: MIST_PASSWORD retrieved")
293333
self.set_password(mist_password)
294334
except Exception as e:
@@ -701,6 +741,7 @@ def _process_login(self, retry: bool = True) -> str | None:
701741
if resp.status_code == 200:
702742
LOGGER.info("apisession:_process_login:authentication successful!")
703743
CONSOLE.info("Authentication successful!")
744+
self._password = None
704745
self._set_authenticated(True)
705746
else:
706747
error = resp.json().get("detail")
@@ -818,14 +859,22 @@ def login_with_return(
818859
elif self.email and self._password:
819860
if two_factor:
820861
LOGGER.debug("apisession:login_with_return:login/pwd provided with 2FA")
821-
if self._two_factor_authentication(two_factor):
862+
error_login = self._process_login(retry=False)
863+
if error_login:
864+
LOGGER.error(
865+
"apisession:login_with_return:login/pwd auth failed: %s",
866+
error_login,
867+
)
868+
return {"authenticated": False, "error": error_login}
869+
if not self._two_factor_authentication(two_factor):
822870
LOGGER.error(
823-
"apisession:login_with_return:login/pwd auth failed: 2FA authentication failed"
871+
"apisession:login_with_return:2FA authentication failed"
824872
)
825873
return {
826874
"authenticated": False,
827875
"error": "2FA authentication failed",
828876
}
877+
LOGGER.info("apisession:login_with_return:authenticated with 2FA")
829878
else:
830879
LOGGER.debug("apisession:login_with_return:login/pwd provided w/o 2FA")
831880
error_login = self._process_login(retry=False)
@@ -1044,10 +1093,8 @@ def _two_factor_authentication(self, two_factor: str) -> bool:
10441093
True if authentication succeed, False otherwise
10451094
"""
10461095
LOGGER.debug("apisession:_two_factor_authentication")
1047-
uri = "/api/v1/login"
1096+
uri = "/api/v1/login/two_factor"
10481097
body = {
1049-
"email": self.email,
1050-
"password": self._password,
10511098
"two_factor": two_factor,
10521099
}
10531100
resp = self._session.post(self._url(uri), json=body)
@@ -1102,7 +1149,16 @@ def _getself(self) -> bool:
11021149
elif key == "tags":
11031150
for tag in resp.data["tags"]:
11041151
self.tags.append(tag)
1105-
else:
1152+
elif key in [
1153+
"first_name",
1154+
"last_name",
1155+
"email",
1156+
"enable_two_factor",
1157+
"two_factor_verified",
1158+
"no_tracking",
1159+
"password_expiry",
1160+
"password_modified_time",
1161+
]:
11061162
setattr(self, key, val)
11071163
if self._show_cli_notif:
11081164
print()

src/mistapi/__version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.61.3"
1+
__version__ = "0.61.4"
22
__author__ = "Thomas Munzer <tmunzer@juniper.net>"

src/mistapi/api/v1/sites/sle.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
@deprecation.deprecated(
1919
deprecated_in="0.59.2",
2020
removed_in="0.65.0",
21-
current_version="0.61.3",
21+
current_version="0.61.4",
2222
details="function replaced with getSiteSleClassifierSummaryTrend",
2323
)
2424
def getSiteSleClassifierDetails(
@@ -690,7 +690,7 @@ def listSiteSleImpactedWirelessClients(
690690
@deprecation.deprecated(
691691
deprecated_in="0.59.2",
692692
removed_in="0.65.0",
693-
current_version="0.61.3",
693+
current_version="0.61.4",
694694
details="function replaced with getSiteSleSummaryTrend",
695695
)
696696
def getSiteSleSummary(

src/mistapi/websockets/__ws_client.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,15 @@ def __init__(
6969
auto_reconnect: bool = False,
7070
max_reconnect_attempts: int = 5,
7171
reconnect_backoff: float = 2.0,
72+
max_reconnect_backoff: float | None = None,
7273
queue_maxsize: int = 0,
7374
) -> None:
7475
if max_reconnect_attempts < 0:
75-
raise ValueError("max_reconnect_attempts must be >= 0")
76+
raise ValueError("max_reconnect_attempts must be >= 0 (0 = unlimited)")
7677
if reconnect_backoff <= 0:
7778
raise ValueError("reconnect_backoff must be > 0")
79+
if max_reconnect_backoff is not None and max_reconnect_backoff <= 0:
80+
raise ValueError("max_reconnect_backoff must be > 0")
7881
if queue_maxsize < 0:
7982
raise ValueError("queue_maxsize must be >= 0")
8083

@@ -85,6 +88,7 @@ def __init__(
8588
self._auto_reconnect = auto_reconnect
8689
self._max_reconnect_attempts = max_reconnect_attempts
8790
self._reconnect_backoff = reconnect_backoff
91+
self._max_reconnect_backoff = max_reconnect_backoff
8892
self._lock = threading.Lock()
8993
self._ws: websocket.WebSocketApp | None = None
9094
self._thread: threading.Thread | None = None
@@ -305,22 +309,32 @@ def _run_forever_safe(self) -> None:
305309
break
306310

307311
self._reconnect_attempts += 1
308-
if self._reconnect_attempts > self._max_reconnect_attempts:
312+
if (
313+
self._max_reconnect_attempts > 0
314+
and self._reconnect_attempts > self._max_reconnect_attempts
315+
):
309316
logger.warning(
310317
"Max reconnect attempts (%d) reached, giving up",
311318
self._max_reconnect_attempts,
312319
)
313320
break
314321

315-
delay = self._reconnect_backoff * (
316-
2 ** (self._reconnect_attempts - 1)
317-
)
318-
logger.info(
319-
"Reconnecting in %.1fs (attempt %d/%d)",
320-
delay,
321-
self._reconnect_attempts,
322-
self._max_reconnect_attempts,
323-
)
322+
delay = self._reconnect_backoff * (2 ** (self._reconnect_attempts - 1))
323+
if self._max_reconnect_backoff is not None:
324+
delay = min(delay, self._max_reconnect_backoff)
325+
if self._max_reconnect_attempts > 0:
326+
logger.info(
327+
"Reconnecting in %.1fs (attempt %d/%d)",
328+
delay,
329+
self._reconnect_attempts,
330+
self._max_reconnect_attempts,
331+
)
332+
else:
333+
logger.info(
334+
"Reconnecting in %.1fs (attempt %d, unlimited)",
335+
delay,
336+
self._reconnect_attempts,
337+
)
324338
if self._user_disconnect.wait(timeout=delay):
325339
break # disconnect() called during backoff
326340

src/mistapi/websockets/location.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def __init__(
8080
auto_reconnect: bool = False,
8181
max_reconnect_attempts: int = 5,
8282
reconnect_backoff: float = 2.0,
83+
max_reconnect_backoff: float | None = None,
8384
queue_maxsize: int = 0,
8485
) -> None:
8586
channels = [f"/sites/{site_id}/stats/maps/{mid}/assets" for mid in map_ids]
@@ -91,6 +92,7 @@ def __init__(
9192
auto_reconnect=auto_reconnect,
9293
max_reconnect_attempts=max_reconnect_attempts,
9394
reconnect_backoff=reconnect_backoff,
95+
max_reconnect_backoff=max_reconnect_backoff,
9496
queue_maxsize=queue_maxsize,
9597
)
9698

@@ -160,6 +162,7 @@ def __init__(
160162
auto_reconnect: bool = False,
161163
max_reconnect_attempts: int = 5,
162164
reconnect_backoff: float = 2.0,
165+
max_reconnect_backoff: float | None = None,
163166
queue_maxsize: int = 0,
164167
) -> None:
165168
channels = [f"/sites/{site_id}/stats/maps/{mid}/clients" for mid in map_ids]
@@ -171,6 +174,7 @@ def __init__(
171174
auto_reconnect=auto_reconnect,
172175
max_reconnect_attempts=max_reconnect_attempts,
173176
reconnect_backoff=reconnect_backoff,
177+
max_reconnect_backoff=max_reconnect_backoff,
174178
queue_maxsize=queue_maxsize,
175179
)
176180

@@ -240,6 +244,7 @@ def __init__(
240244
auto_reconnect: bool = False,
241245
max_reconnect_attempts: int = 5,
242246
reconnect_backoff: float = 2.0,
247+
max_reconnect_backoff: float | None = None,
243248
queue_maxsize: int = 0,
244249
) -> None:
245250
channels = [f"/sites/{site_id}/stats/maps/{mid}/sdkclients" for mid in map_ids]
@@ -251,6 +256,7 @@ def __init__(
251256
auto_reconnect=auto_reconnect,
252257
max_reconnect_attempts=max_reconnect_attempts,
253258
reconnect_backoff=reconnect_backoff,
259+
max_reconnect_backoff=max_reconnect_backoff,
254260
queue_maxsize=queue_maxsize,
255261
)
256262

@@ -320,6 +326,7 @@ def __init__(
320326
auto_reconnect: bool = False,
321327
max_reconnect_attempts: int = 5,
322328
reconnect_backoff: float = 2.0,
329+
max_reconnect_backoff: float | None = None,
323330
queue_maxsize: int = 0,
324331
) -> None:
325332
channels = [
@@ -333,6 +340,7 @@ def __init__(
333340
auto_reconnect=auto_reconnect,
334341
max_reconnect_attempts=max_reconnect_attempts,
335342
reconnect_backoff=reconnect_backoff,
343+
max_reconnect_backoff=max_reconnect_backoff,
336344
queue_maxsize=queue_maxsize,
337345
)
338346

@@ -402,6 +410,7 @@ def __init__(
402410
auto_reconnect: bool = False,
403411
max_reconnect_attempts: int = 5,
404412
reconnect_backoff: float = 2.0,
413+
max_reconnect_backoff: float | None = None,
405414
queue_maxsize: int = 0,
406415
) -> None:
407416
channels = [
@@ -415,5 +424,6 @@ def __init__(
415424
auto_reconnect=auto_reconnect,
416425
max_reconnect_attempts=max_reconnect_attempts,
417426
reconnect_backoff=reconnect_backoff,
427+
max_reconnect_backoff=max_reconnect_backoff,
418428
queue_maxsize=queue_maxsize,
419429
)

0 commit comments

Comments
 (0)