The login cache is shared by every mdrs process, but the refresh was guarded by a lock that only reaches inside one. Concurrent runs each sent the same refresh token, and a provider that rotates them accepts the first and refuses the rest. - hold a lock that spans processes across the whole read-refresh- write, checking cheaply first so ordinary requests never take it - write the cache through a temporary file: opening it for writing truncates it, and a reader landing in that window found it empty and threw the session away - take the lock for every write, not just the refresh, so a login running beside one cannot be silently reverted - wait for a busy lock on Windows rather than giving up after the ten attempts msvcrt allows, and retry the rename it refuses while a reader still holds the file open - bound the refresh request on its own, so a provider that goes quiet cannot hold the lock indefinitely - accept bare hostnames such as localhost, store URLs without the trailing slash, and join download paths through one helper
122 lines
4.0 KiB
Python
122 lines
4.0 KiB
Python
import platform
|
|
import threading
|
|
from contextlib import AbstractContextManager
|
|
from typing import TypedDict
|
|
|
|
from requests import Response, Session
|
|
from requests_toolbelt.multipart.encoder import MultipartEncoder
|
|
|
|
# Unpack is new in 3.11
|
|
from typing_extensions import Unpack
|
|
|
|
from mdrsclient.__version__ import __version__
|
|
from mdrsclient.cache import CacheFile, CacheInterface
|
|
from mdrsclient.exceptions import MissingConfigurationException
|
|
from mdrsclient.models import Laboratories, Token, User
|
|
|
|
|
|
class _KwArgsMDRSConnectionGet(TypedDict, total=False):
|
|
params: dict[str, str | int]
|
|
stream: bool
|
|
|
|
|
|
class _KwArgsMDRSConnectionPost(TypedDict, total=False):
|
|
params: dict[str, str | int]
|
|
data: dict[str, str | int] | MultipartEncoder
|
|
headers: dict[str, str]
|
|
timeout: float | tuple[float, float]
|
|
|
|
|
|
class _KwArgsMDRSConnectionPut(TypedDict, total=False):
|
|
params: dict[str, str | int]
|
|
data: dict[str, str | int] | MultipartEncoder
|
|
headers: dict[str, str]
|
|
|
|
|
|
class _KwArgsMDRSConnectionDelete(TypedDict, total=False):
|
|
params: dict[str, str | int]
|
|
|
|
|
|
class MDRSConnection:
|
|
url: str
|
|
session: Session
|
|
lock: threading.Lock
|
|
__cache: CacheInterface
|
|
|
|
def __init__(self, remote: str, url: str, cache: CacheInterface | None = None) -> None:
|
|
super().__init__()
|
|
self.url = url
|
|
self.session = Session()
|
|
self.lock = threading.Lock()
|
|
self.__cache = cache if cache is not None else CacheFile(remote)
|
|
self.__prepare_headers()
|
|
|
|
def get(self, url: str, **kwargs: Unpack[_KwArgsMDRSConnectionGet]) -> Response:
|
|
return self.session.get(self.__build_url(url), **kwargs)
|
|
|
|
def post(self, url: str, **kwargs: Unpack[_KwArgsMDRSConnectionPost]) -> Response:
|
|
return self.session.post(self.__build_url(url), **kwargs)
|
|
|
|
def put(self, url: str, **kwargs: Unpack[_KwArgsMDRSConnectionPut]) -> Response:
|
|
return self.session.put(self.__build_url(url), **kwargs)
|
|
|
|
def delete(self, url: str, **kwargs: Unpack[_KwArgsMDRSConnectionDelete]) -> Response:
|
|
return self.session.delete(self.__build_url(url), **kwargs)
|
|
|
|
def cache_lock(self) -> AbstractContextManager[None]:
|
|
"""Hold exclusive access to the login cache across every process using it."""
|
|
return self.__cache.lock()
|
|
|
|
def reload_cache(self) -> None:
|
|
"""Re-read the login cache, discarding anything held from an earlier read."""
|
|
self.__cache.reload()
|
|
|
|
def logout(self) -> None:
|
|
del self.__cache.user
|
|
del self.__cache.token
|
|
self.session.headers.update({"Authorization": ""})
|
|
|
|
@property
|
|
def user(self) -> User | None:
|
|
return self.__cache.user
|
|
|
|
@user.setter
|
|
def user(self, user: User) -> None:
|
|
self.__cache.user = user
|
|
|
|
@property
|
|
def token(self) -> Token | None:
|
|
return self.__cache.token
|
|
|
|
@token.setter
|
|
def token(self, token: Token) -> None:
|
|
self.__cache.token = token
|
|
self.__prepare_headers()
|
|
|
|
@property
|
|
def laboratories(self) -> Laboratories:
|
|
return self.__cache.laboratories
|
|
|
|
@laboratories.setter
|
|
def laboratories(self, laboratories: Laboratories) -> None:
|
|
self.__cache.laboratories = laboratories
|
|
|
|
def __build_url(self, path: str) -> str:
|
|
if path.startswith("http://") or path.startswith("https://"):
|
|
return path
|
|
if self.url == "":
|
|
raise MissingConfigurationException("remote host is not configured")
|
|
# The path brings its own separator, and a configuration written before the URL
|
|
# was normalised may still carry a trailing slash of its own.
|
|
return f"{self.url.rstrip('/')}/{path}"
|
|
|
|
def __prepare_headers(self) -> None:
|
|
self.session.headers.update(
|
|
{
|
|
"User-Agent": f"MdrsClient/{__version__} (Python {platform.python_version()} - {platform.platform()})",
|
|
"Accept": "application/json",
|
|
}
|
|
)
|
|
if self.token is not None:
|
|
self.session.headers.update({"Authorization": f"Bearer {self.token.access}"})
|