Files
mdrs-client-python/mdrsclient/connection.py
T
orrisroot 0cac30ccf8 fix(auth): serialise the token refresh across processes
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
2026-08-14 16:36:50 +09:00

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}"})