Skip to content

Commit d0b9021

Browse files
committed
feat(client): add a retryable client wrapper
1 parent 61ad7a0 commit d0b9021

File tree

2 files changed

+158
-1
lines changed

2 files changed

+158
-1
lines changed

src/pynetmito/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .types import BaseAPIModel
2-
from .client import MitoHttpClient
2+
from .client import MitoHttpClient, PersistentMitoHttpClient
33
from .schemas import (
44
UserLoginArgs,
55
UserLoginReq,
@@ -73,6 +73,7 @@
7373
__all__ = [
7474
"BaseAPIModel",
7575
"MitoHttpClient",
76+
"PersistentMitoHttpClient",
7677
"UserLoginArgs",
7778
"UserLoginReq",
7879
"UserLoginResp",

src/pynetmito/client.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,3 +1034,159 @@ def batch_submit_tasks(self, req: TasksSubmitReq) -> TasksSubmitResp:
10341034
raise Exception(
10351035
f"Failed to batch submit tasks, status code: {resp.status_code}, error: {resp.text}"
10361036
)
1037+
1038+
1039+
class PersistentMitoHttpClient:
1040+
"""
1041+
A wrapper around MitoHttpClient that automatically handles re-authentication on 401 errors.
1042+
1043+
This client stores the username and password when connect() is called, and automatically
1044+
retries authentication once if a 401 Unauthorized error is encountered in any API call.
1045+
1046+
Example:
1047+
>>> client = PersistentMitoHttpClient("http://localhost:8080")
1048+
>>> client.connect(user="myuser", password="mypassword")
1049+
'myuser'
1050+
>>> # Now all API calls will automatically re-authenticate on 401 errors
1051+
>>> tasks = client.query_tasks_by_filter(TasksQueryReq(...))
1052+
>>> # If the token expires and a 401 is returned, the client will:
1053+
>>> # 1. Automatically call connect() again with stored credentials
1054+
>>> # 2. Retry the original API call
1055+
>>> # 3. If it still fails, raise the error
1056+
"""
1057+
1058+
def __init__(self, coordinator_addr: str):
1059+
"""Initialize the persistent client with a coordinator address."""
1060+
self._inner_client = MitoHttpClient(coordinator_addr)
1061+
self._stored_username: Optional[str] = None
1062+
self._stored_password: Optional[str] = None
1063+
self._stored_credential_path: Optional[Path] = None
1064+
self._stored_retain: bool = False
1065+
self.logger = self._inner_client.logger
1066+
1067+
def __del__(self):
1068+
"""Clean up the inner client."""
1069+
if hasattr(self, "_inner_client"):
1070+
del self._inner_client
1071+
1072+
def connect(
1073+
self,
1074+
credential_path: Optional[Path] = None,
1075+
user: Optional[str] = None,
1076+
password: Optional[str] = None,
1077+
retain: bool = False,
1078+
) -> str:
1079+
"""
1080+
Connect to the server and store credentials for automatic re-authentication.
1081+
1082+
Args:
1083+
credential_path: Path to credential file
1084+
user: Username
1085+
password: Password
1086+
retain: Whether to retain the session
1087+
1088+
Returns:
1089+
The authenticated username
1090+
"""
1091+
# Store credentials for future re-authentication
1092+
if user is not None and password is not None:
1093+
self._stored_username = user
1094+
self._stored_password = password
1095+
self._stored_credential_path = credential_path
1096+
self._stored_retain = retain
1097+
1098+
# Call the inner client's connect method
1099+
username = self._inner_client.connect(
1100+
credential_path=credential_path, user=user, password=password, retain=retain
1101+
)
1102+
self._stored_username = username
1103+
return username
1104+
1105+
def _should_retry_with_reauth(self, error: Exception) -> bool:
1106+
"""
1107+
Check if an error indicates a 401 Unauthorized that should trigger re-authentication.
1108+
1109+
Args:
1110+
error: The exception to check
1111+
1112+
Returns:
1113+
True if the error is a 401 and we should retry with re-authentication
1114+
"""
1115+
error_msg = str(error)
1116+
# Check if error message contains status code 401
1117+
return "status code: 401" in error_msg or "401" in error_msg
1118+
1119+
def _retry_with_reauth(self, method_name: str, *args, **kwargs):
1120+
"""
1121+
Execute a method with automatic re-authentication on 401 errors.
1122+
1123+
Args:
1124+
method_name: Name of the method to call on the inner client
1125+
*args: Positional arguments for the method
1126+
**kwargs: Keyword arguments for the method
1127+
1128+
Returns:
1129+
The result of the method call
1130+
1131+
Raises:
1132+
Exception: If the method fails even after re-authentication attempt
1133+
"""
1134+
# Get the method from the inner client
1135+
method = getattr(self._inner_client, method_name)
1136+
1137+
try:
1138+
# Try the original call
1139+
return method(*args, **kwargs)
1140+
except Exception as e:
1141+
# Check if this is a 401 error
1142+
if self._should_retry_with_reauth(e):
1143+
# Check if we have stored credentials to retry with
1144+
if self._stored_username is None or self._stored_password is None:
1145+
# No stored credentials, can't retry
1146+
raise
1147+
1148+
# Try to re-authenticate
1149+
try:
1150+
self._inner_client.connect(
1151+
credential_path=self._stored_credential_path,
1152+
user=self._stored_username,
1153+
password=self._stored_password,
1154+
retain=self._stored_retain,
1155+
)
1156+
except Exception:
1157+
# Re-authentication failed, raise the original error
1158+
raise e
1159+
1160+
# Re-authentication succeeded, retry the original call
1161+
try:
1162+
return method(*args, **kwargs)
1163+
except Exception as retry_error:
1164+
# Retry failed, raise the error
1165+
raise retry_error
1166+
else:
1167+
# Not a 401 error, raise it immediately
1168+
raise
1169+
1170+
def __getattr__(self, name: str):
1171+
"""
1172+
Dynamically wrap all methods from the inner client with retry logic.
1173+
1174+
Args:
1175+
name: The attribute name
1176+
1177+
Returns:
1178+
A wrapped version of the method if it's callable, otherwise the attribute itself
1179+
"""
1180+
# Get the attribute from the inner client
1181+
attr = getattr(self._inner_client, name)
1182+
1183+
# If it's a method and not a special/private method, wrap it with retry logic
1184+
if callable(attr) and not name.startswith("_") and name != "connect":
1185+
1186+
def wrapped_method(*args, **kwargs):
1187+
return self._retry_with_reauth(name, *args, **kwargs)
1188+
1189+
return wrapped_method
1190+
else:
1191+
# For non-callable attributes or private methods, return as-is
1192+
return attr

0 commit comments

Comments
 (0)