Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "mist_openapi"]
path = mist_openapi
url = https://github.com/mistsys/mist_openapi.git
branch = master
branch = 2602.1.7
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,58 @@
# CHANGELOG
## Version 0.61.2 (March 2026)

**Released**: March 17, 2026

This release adds automatic reconnection support for WebSocket streams, updates the OpenAPI specification, and includes minor bug fixes.

---

### 1. NEW FEATURES

#### **WebSocket Auto-Reconnect**
`_MistWebsocket` now supports automatic reconnection with configurable parameters:
- `auto_reconnect` — Enable/disable auto-reconnect (default: `False`)
- `max_reconnect_attempts` — Maximum reconnect attempts before giving up (default: `5`)
- `reconnect_backoff` — Base backoff delay in seconds, with exponential increase (default: `2.0`)

When enabled, the WebSocket automatically reconnects on transient failures using exponential backoff. User-initiated `disconnect()` calls are respected during reconnection attempts.

```python
ws = mistapi.websockets.sites.DeviceStatsEvents(
apisession,
site_ids=["<site_id>"],
auto_reconnect=True,
max_reconnect_attempts=5,
reconnect_backoff=2.0
)
ws.connect(run_in_background=True)
```

---

### 2. API CHANGES (OpenAPI 2602.1.7)

Updated to mist_openapi spec version 2602.1.7.

#### **Insights API**
- **`getSiteInsightMetrics()`** — Now uses `metrics` as a query parameter instead of a path parameter
- **`getSiteInsightMetricsForAP()`** — New function to retrieve insight metrics for a specific AP
- **`getSiteInsightMetricsForClient()`** — Changed `metric` path parameter to `metrics` query parameter
- **`getSiteInsightMetricsForGateway()`** — Changed `metric` path parameter to `metrics` query parameter

#### **Stats API**
- **`getOrgStats()`** — Removed `start`, `end`, `duration`, `limit`, `page` query parameters
- **`listOrgSiteStats()`** — Removed `start`, `end`, `duration` query parameters

---

### 3. BUG FIXES
- Fixed `ShellSession.recv()` to gracefully handle socket timeout reset when the connection is already closed
- Fixed thread-safety (TOCTOU) race conditions in `ShellSession` by capturing WebSocket reference in local variables across `disconnect()`, `connected`, `send()`, `recv()`, and `resize()` methods
- Fixed thread-safety race condition in `_MistWebsocket.disconnect()` with local variable capture

---

## Version 0.61.1 (March 2026)

**Released**: March 15, 2026
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,19 +579,23 @@ The package provides a WebSocket client for real-time event streaming from the M

### Connection Parameters

All channel classes accept the following optional keyword arguments to control the WebSocket keep-alive behaviour:
All channel classes accept the following optional keyword arguments:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `ping_interval` | `int` | `30` | Seconds between automatic ping frames. Set to `0` to disable pings. |
| `ping_timeout` | `int` | `10` | Seconds to wait for a pong response before treating the connection as dead. |
| `auto_reconnect` | `bool` | `False` | Automatically reconnect on transient failures using exponential backoff. |
| `max_reconnect_attempts` | `int` | `5` | Maximum number of reconnect attempts before giving up. |
| `reconnect_backoff` | `float` | `2.0` | Base backoff delay in seconds. Doubles after each failed attempt (2s, 4s, 8s, ...). Resets on successful reconnection. |

```python
ws = mistapi.websockets.sites.DeviceStatsEvents(
apisession,
site_ids=["<site_id>"],
ping_interval=60, # ping every 60 s
ping_timeout=20, # wait up to 20 s for pong
ping_interval=60, # ping every 60 s
ping_timeout=20, # wait up to 20 s for pong
auto_reconnect=True, # reconnect on transient failures
)
ws.connect()
```
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mistapi"
version = "0.61.1"
version = "0.61.2"
authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }]
description = "Python package to simplify the Mist System APIs usage"
keywords = ["Mist", "Juniper", "API"]
Expand Down
3 changes: 2 additions & 1 deletion src/mistapi/__api_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ def _request_with_retry(
"apirequest:%s:Exception occurred", method_name, exc_info=True
)
break
self._count += 1
with self._token_lock:
self._count += 1
return APIResponse(url=url, response=resp, proxy_error=proxy_failed)

def mist_get(self, uri: str, query: dict[str, str] | None = None) -> APIResponse:
Expand Down
8 changes: 7 additions & 1 deletion src/mistapi/__api_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
This module manages API responses
"""

import re

from requests import Response
from requests.structures import CaseInsensitiveDict

Expand Down Expand Up @@ -85,7 +87,11 @@ def _check_next(self) -> None:
separator = "&" if "?" in uri else "?"
self.next = f"{uri}{separator}page={page + 1}"
else:
self.next = uri.replace(f"page={page}", f"page={page + 1}")
self.next = re.sub(
rf"(?<=[?&])page={page}(?=&|$)",
f"page={page + 1}",
uri,
)
logger.debug(f"apiresponse:_check_next:set next to {self.next}")
except ValueError:
logger.error(
Expand Down
18 changes: 12 additions & 6 deletions src/mistapi/__api_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ def _get_api_token_data(self, apitoken) -> tuple[str | None, list | None]:
if data_json.get("email"):
token_type = "user" # nosec bandit B105

for priv in data_json.get("privileges"):
for priv in data_json.get("privileges", []):
tmp = {
"scope": priv.get("scope"),
"role": priv.get("role"),
Expand Down Expand Up @@ -715,7 +715,7 @@ def _process_login(self, retry: bool = True) -> str | None:
"email/password cleaned up. Restarting authentication function"
)
if retry:
return self._process_login(retry)
return self._process_login(retry=False)
except requests.exceptions.ProxyError as proxy_error:
LOGGER.critical("apisession:_process_login:proxy not valid...")
CONSOLE.critical("Proxy not valid...\r\n")
Expand Down Expand Up @@ -935,9 +935,15 @@ def _set_authenticated(self, authentication_status: bool) -> None:
LOGGER.error(
"apirequest:mist_post_file: Exception occurred", exc_info=True
)
self._csrftoken = self._session.cookies["csrftoken" + cookies_ext]
self._session.headers.update({"X-CSRFToken": self._csrftoken})
LOGGER.info("apisession:_set_authenticated:CSRF Token stored")
csrf_cookie = self._session.cookies.get("csrftoken" + cookies_ext)
if csrf_cookie:
self._csrftoken = csrf_cookie
self._session.headers.update({"X-CSRFToken": self._csrftoken})
LOGGER.info("apisession:_set_authenticated:CSRF Token stored")
else:
LOGGER.error(
"apisession:_set_authenticated:CSRF Token cookie not found"
)
elif authentication_status is False:
self._authenticated = False
LOGGER.info(
Expand Down Expand Up @@ -1093,7 +1099,7 @@ def _getself(self) -> bool:
for key, val in resp.data.items():
if key == "privileges":
self.privileges = Privileges(resp.data["privileges"])
if key == "tags":
elif key == "tags":
for tag in resp.data["tags"]:
self.tags.append(tag)
else:
Expand Down
2 changes: 1 addition & 1 deletion src/mistapi/__version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "0.61.1"
__version__ = "0.61.2"
__author__ = "Thomas Munzer <tmunzer@juniper.net>"
40 changes: 1 addition & 39 deletions src/mistapi/api/v1/orgs/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,7 @@
from mistapi.__api_response import APIResponse as _APIResponse


def getOrgStats(
mist_session: _APISession,
org_id: str,
start: str | None = None,
end: str | None = None,
duration: str | None = None,
limit: int | None = None,
page: int | None = None,
) -> _APIResponse:
def getOrgStats(mist_session: _APISession, org_id: str) -> _APIResponse:
"""
API doc: https://www.juniper.net/documentation/us/en/software/mist/api/http/api/orgs/stats/get-org-stats

Expand All @@ -35,14 +27,6 @@ def getOrgStats(
-----------
org_id : str

QUERY PARAMS
------------
start : str
end : str
duration : str, default: 1d
limit : int, default: 100
page : int, default: 1

RETURN
-----------
mistapi.APIResponse
Expand All @@ -51,16 +35,6 @@ def getOrgStats(

uri = f"/api/v1/orgs/{org_id}/stats"
query_params: dict[str, str] = {}
if start:
query_params["start"] = str(start)
if end:
query_params["end"] = str(end)
if duration:
query_params["duration"] = str(duration)
if limit:
query_params["limit"] = str(limit)
if page:
query_params["page"] = str(page)
resp = mist_session.mist_get(uri=uri, query=query_params)
return resp

Expand Down Expand Up @@ -1017,9 +991,6 @@ def searchOrgSwOrGwPorts(
def listOrgSiteStats(
mist_session: _APISession,
org_id: str,
start: str | None = None,
end: str | None = None,
duration: str | None = None,
limit: int | None = None,
page: int | None = None,
) -> _APIResponse:
Expand All @@ -1037,9 +1008,6 @@ def listOrgSiteStats(

QUERY PARAMS
------------
start : str
end : str
duration : str, default: 1d
limit : int, default: 100
page : int, default: 1

Expand All @@ -1051,12 +1019,6 @@ def listOrgSiteStats(

uri = f"/api/v1/orgs/{org_id}/stats/sites"
query_params: dict[str, str] = {}
if start:
query_params["start"] = str(start)
if end:
query_params["end"] = str(end)
if duration:
query_params["duration"] = str(duration)
if limit:
query_params["limit"] = str(limit)
if page:
Expand Down
Loading
Loading