638 lines
24 KiB
Python
Executable File
638 lines
24 KiB
Python
Executable File
"""Synchronous HTTP client for the legacy admin API envelope."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from collections.abc import Callable, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from threading import Condition, RLock
|
|
from typing import Any
|
|
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
|
|
import httpx
|
|
|
|
from doctor_workstation import __version__
|
|
from doctor_workstation.core.errors import (
|
|
ApiBusinessError,
|
|
ApiHttpError,
|
|
ApiProtocolError,
|
|
ApiTimeoutError,
|
|
ApiTransportError,
|
|
AuthenticationExpiredError,
|
|
InstallationRequiredError,
|
|
OpenPageRequiredError,
|
|
WorkWechatBindingRequiredError,
|
|
)
|
|
|
|
|
|
class ApiClient:
|
|
"""A small, testable client implementing the admin API contract.
|
|
|
|
The supplied base URL may be either the site origin or an URL already
|
|
ending in ``/adminapi``. Timeout retries are deliberately limited to GET
|
|
requests so that medical write operations are never submitted twice.
|
|
"""
|
|
|
|
API_VERSION = "1.9.4"
|
|
DEFAULT_MAX_PARALLEL_REQUESTS = 8
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
*,
|
|
token: str = "",
|
|
timeout: float | httpx.Timeout = 30.0,
|
|
max_retries: int = 2,
|
|
retry_backoff: float = 0.0,
|
|
verify: bool = True,
|
|
transport: httpx.BaseTransport | None = None,
|
|
client: httpx.Client | None = None,
|
|
sleep: Callable[[float], None] = time.sleep,
|
|
max_parallel_requests: int = DEFAULT_MAX_PARALLEL_REQUESTS,
|
|
) -> None:
|
|
"""Create a client without performing any network requests."""
|
|
|
|
if max_retries < 0:
|
|
raise ValueError("max_retries must be non-negative")
|
|
if retry_backoff < 0:
|
|
raise ValueError("retry_backoff must be non-negative")
|
|
if client is not None and transport is not None:
|
|
raise ValueError("pass either client or transport, not both")
|
|
if max_parallel_requests <= 0:
|
|
raise ValueError("max_parallel_requests must be positive")
|
|
self.base_url = self.normalise_base_url(base_url)
|
|
self.timeout = timeout
|
|
self.max_retries = max_retries
|
|
self.retry_backoff = retry_backoff
|
|
self._sleep = sleep
|
|
self._token = token.strip()
|
|
self._lock = RLock()
|
|
self._transport_lock = RLock()
|
|
self._client_condition = Condition(RLock())
|
|
self._available_clients: list[httpx.Client] = []
|
|
self._pooled_clients: list[httpx.Client] = []
|
|
self._active_requests = 0
|
|
self._closing = False
|
|
self._closed = False
|
|
self._max_parallel_requests = max_parallel_requests
|
|
# Production requests use a bounded pool of independent clients. This
|
|
# permits unrelated patient requests to run concurrently without
|
|
# sharing the SSLContext that previously caused native TLS crashes.
|
|
# A pool also works across PySide QRunnable boundaries, where Python
|
|
# thread-local state is not retained reliably.
|
|
# Explicit clients/transports remain on one locked client because
|
|
# their ownership and thread-safety contracts are unknown.
|
|
self._uses_client_pool = client is None and transport is None
|
|
self._owns_client = client is None
|
|
self._stream_transport = transport
|
|
self._verify = verify
|
|
self._client = (
|
|
None
|
|
if self._uses_client_pool
|
|
else client or httpx.Client(transport=transport, verify=verify)
|
|
)
|
|
|
|
@contextmanager
|
|
def _borrow_client(self) -> Iterator[httpx.Client]:
|
|
"""Yield a safe synchronous client for the calling worker thread."""
|
|
|
|
if not self._uses_client_pool:
|
|
with self._transport_lock:
|
|
with self._client_condition:
|
|
if self._closing or self._closed:
|
|
raise RuntimeError("ApiClient is closed")
|
|
if self._client is None: # Defensive; this mode always has one.
|
|
raise RuntimeError("ApiClient transport is unavailable")
|
|
yield self._client
|
|
return
|
|
|
|
with self._client_condition:
|
|
while True:
|
|
if self._closing or self._closed:
|
|
raise RuntimeError("ApiClient is closed")
|
|
if self._available_clients:
|
|
pooled_client = self._available_clients.pop()
|
|
break
|
|
if len(self._pooled_clients) < self._max_parallel_requests:
|
|
pooled_client = httpx.Client(verify=self._verify)
|
|
self._pooled_clients.append(pooled_client)
|
|
break
|
|
self._client_condition.wait()
|
|
self._active_requests += 1
|
|
try:
|
|
yield pooled_client
|
|
finally:
|
|
with self._client_condition:
|
|
self._active_requests -= 1
|
|
self._available_clients.append(pooled_client)
|
|
if self._active_requests == 0:
|
|
self._client_condition.notify_all()
|
|
else:
|
|
self._client_condition.notify()
|
|
|
|
@staticmethod
|
|
def normalise_base_url(base_url: str) -> str:
|
|
"""Return an absolute URL ending in exactly one ``/adminapi/``."""
|
|
|
|
candidate = base_url.strip()
|
|
parsed = urlsplit(candidate)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("base_url must be an absolute http(s) URL")
|
|
if parsed.query or parsed.fragment:
|
|
raise ValueError("base_url must not include query parameters or fragments")
|
|
path = parsed.path.rstrip("/")
|
|
if path.lower().endswith("/adminapi"):
|
|
normalised_path = f"{path}/"
|
|
else:
|
|
normalised_path = f"{path}/adminapi/" if path else "/adminapi/"
|
|
return urlunsplit((parsed.scheme, parsed.netloc, normalised_path, "", ""))
|
|
|
|
@property
|
|
def token(self) -> str:
|
|
"""Return the current in-memory access token."""
|
|
|
|
with self._lock:
|
|
return self._token
|
|
|
|
@token.setter
|
|
def token(self, value: str) -> None:
|
|
"""Replace the in-memory access token used by later requests."""
|
|
|
|
with self._lock:
|
|
self._token = value.strip()
|
|
|
|
def set_token(self, token: str) -> None:
|
|
"""Set the authentication token; provided for explicit session code."""
|
|
|
|
self.token = token
|
|
|
|
def clear_token(self) -> None:
|
|
"""Remove the authentication token from memory."""
|
|
|
|
self.token = ""
|
|
|
|
def get(
|
|
self,
|
|
endpoint: str,
|
|
params: Mapping[str, Any] | None = None,
|
|
*,
|
|
headers: Mapping[str, str] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> Any:
|
|
"""Issue a GET request and return the unwrapped envelope data."""
|
|
|
|
return self.request(
|
|
"GET", endpoint, params=params, headers=headers, timeout=timeout
|
|
)
|
|
|
|
def post(
|
|
self,
|
|
endpoint: str,
|
|
payload: Mapping[str, Any] | None = None,
|
|
*,
|
|
json: Mapping[str, Any] | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> Any:
|
|
"""Issue a non-retried JSON POST and return the unwrapped data."""
|
|
|
|
if payload is not None and json is not None:
|
|
raise ValueError("pass either payload or json, not both")
|
|
body = json if json is not None else payload
|
|
return self.request(
|
|
"POST", endpoint, json=body or {}, headers=headers, timeout=timeout
|
|
)
|
|
|
|
def post_multipart(
|
|
self,
|
|
endpoint: str,
|
|
*,
|
|
files: Mapping[str, Any],
|
|
data: Mapping[str, Any] | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
) -> Any:
|
|
"""Issue a non-retried multipart POST and return unwrapped data.
|
|
|
|
``httpx`` owns the multipart boundary. In particular this method does
|
|
not inherit the JSON ``Content-Type`` used by ordinary API writes.
|
|
File objects remain owned by the caller and are consumed synchronously.
|
|
"""
|
|
|
|
if not files:
|
|
raise ValueError("multipart files must not be empty")
|
|
return self.request(
|
|
"POST",
|
|
endpoint,
|
|
data=data or {},
|
|
files=files,
|
|
headers=headers,
|
|
)
|
|
|
|
def post_event_stream(
|
|
self,
|
|
endpoint: str,
|
|
payload: Mapping[str, Any],
|
|
*,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
cancelled: Callable[[], bool] | None = None,
|
|
) -> Iterator[dict[str, Any]]:
|
|
"""POST JSON and yield parsed server-sent events on an isolated client.
|
|
|
|
Streaming deliberately does not use the bounded short-request client
|
|
pool. A diagnosis response can remain open for more than a minute and
|
|
must not consume capacity needed by unrelated page requests.
|
|
"""
|
|
|
|
url = self._endpoint_url(endpoint)
|
|
headers = self._headers({"Accept": "text/event-stream"})
|
|
request_timeout = self.timeout if timeout is None else timeout
|
|
try:
|
|
with httpx.Client(
|
|
transport=self._stream_transport,
|
|
verify=self._verify,
|
|
) as stream_client, stream_client.stream(
|
|
"POST",
|
|
url,
|
|
json=dict(payload),
|
|
headers=headers,
|
|
timeout=request_timeout,
|
|
) as response:
|
|
request_id = self._request_id(response)
|
|
if not 200 <= response.status_code < 300:
|
|
raise ApiHttpError(
|
|
f"API returned HTTP {response.status_code}",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
content_type = response.headers.get("content-type", "").lower()
|
|
if "text/event-stream" not in content_type:
|
|
response.read()
|
|
if "json" in content_type:
|
|
self._unwrap(response)
|
|
raise ApiProtocolError(
|
|
"API response is not an event stream",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
yield from _iter_event_stream(response.iter_lines(), cancelled=cancelled)
|
|
except httpx.TimeoutException as exc:
|
|
raise ApiTimeoutError(
|
|
f"POST {endpoint} stream timed out",
|
|
data={"method": "POST", "endpoint": endpoint},
|
|
) from exc
|
|
except httpx.RequestError as exc:
|
|
raise ApiTransportError(
|
|
f"POST {endpoint} stream failed: {exc}",
|
|
data={"method": "POST", "endpoint": endpoint},
|
|
) from exc
|
|
|
|
def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes:
|
|
"""Download a public binary asset without applying the JSON envelope contract."""
|
|
|
|
if max_bytes <= 0:
|
|
raise ValueError("max_bytes must be positive")
|
|
candidate = url.strip()
|
|
if not candidate:
|
|
raise ValueError("url must not be empty")
|
|
target = urljoin(self.base_url, candidate)
|
|
parsed = urlsplit(target)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
raise ValueError("url must resolve to an absolute http(s) URL")
|
|
try:
|
|
with self._borrow_client() as client:
|
|
response = client.get(
|
|
target,
|
|
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
|
|
timeout=self.timeout,
|
|
follow_redirects=True,
|
|
)
|
|
except httpx.TimeoutException as exc:
|
|
raise ApiTimeoutError("Image download timed out", data={"url": target}) from exc
|
|
except httpx.RequestError as exc:
|
|
raise ApiTransportError(
|
|
f"Image download failed: {exc}", data={"url": target}
|
|
) from exc
|
|
request_id = self._request_id(response)
|
|
if not 200 <= response.status_code < 300:
|
|
raise ApiHttpError(
|
|
f"Image server returned HTTP {response.status_code}",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
length = response.headers.get("content-length")
|
|
if length:
|
|
try:
|
|
if int(length) > max_bytes:
|
|
raise ApiProtocolError(
|
|
"Image response is too large",
|
|
data={"max_bytes": max_bytes, "content_length": int(length)},
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
except ValueError:
|
|
pass
|
|
content = response.content
|
|
if not content:
|
|
raise ApiProtocolError(
|
|
"Image response is empty",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
if len(content) > max_bytes:
|
|
raise ApiProtocolError(
|
|
"Image response is too large",
|
|
data={"max_bytes": max_bytes, "content_length": len(content)},
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
return content
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
*,
|
|
params: Mapping[str, Any] | None = None,
|
|
json: Mapping[str, Any] | None = None,
|
|
data: Mapping[str, Any] | None = None,
|
|
files: Mapping[str, Any] | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> Any:
|
|
"""Issue one API request with structured transport/envelope errors."""
|
|
|
|
verb = method.upper().strip()
|
|
if verb not in {"GET", "POST"}:
|
|
raise ValueError("ApiClient only supports GET and POST")
|
|
if verb == "GET" and (json is not None or data is not None or files is not None):
|
|
raise ValueError("GET requests cannot include a body")
|
|
if json is not None and (data is not None or files is not None):
|
|
raise ValueError("JSON and multipart/form data are mutually exclusive")
|
|
if files is not None and verb != "POST":
|
|
raise ValueError("multipart uploads require POST")
|
|
url = self._endpoint_url(endpoint)
|
|
request_headers = self._headers(
|
|
headers,
|
|
json_content_type=files is None and data is None,
|
|
)
|
|
attempts = self.max_retries + 1 if verb == "GET" else 1
|
|
response: httpx.Response | None = None
|
|
request_timeout = self.timeout if timeout is None else timeout
|
|
with self._borrow_client() as client:
|
|
for attempt in range(attempts):
|
|
try:
|
|
response = client.request(
|
|
verb,
|
|
url,
|
|
params=dict(params) if params is not None else None,
|
|
json=dict(json) if verb == "POST" and json is not None else None,
|
|
data=dict(data) if verb == "POST" and data is not None else None,
|
|
files=dict(files) if files is not None else None,
|
|
headers=request_headers,
|
|
timeout=request_timeout,
|
|
)
|
|
break
|
|
except httpx.TimeoutException as exc:
|
|
if attempt + 1 < attempts:
|
|
delay = self.retry_backoff * (2**attempt)
|
|
if delay:
|
|
self._sleep(delay)
|
|
continue
|
|
raise ApiTimeoutError(
|
|
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
|
|
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
|
|
) from exc
|
|
except httpx.RequestError as exc:
|
|
raise ApiTransportError(
|
|
f"{verb} {endpoint} failed: {exc}",
|
|
data={"method": verb, "endpoint": endpoint},
|
|
) from exc
|
|
if response is None: # Defensive; the loop always returns or raises.
|
|
raise ApiTransportError(f"{verb} {endpoint} produced no response")
|
|
return self._unwrap(response)
|
|
|
|
def close(self) -> None:
|
|
"""Close the internally-created HTTP transport."""
|
|
|
|
with self._client_condition:
|
|
if self._closed:
|
|
return
|
|
if self._closing:
|
|
while not self._closed:
|
|
self._client_condition.wait()
|
|
return
|
|
self._closing = True
|
|
self._client_condition.notify_all()
|
|
|
|
clients: list[httpx.Client] = []
|
|
if self._uses_client_pool:
|
|
with self._client_condition:
|
|
while self._active_requests:
|
|
self._client_condition.wait()
|
|
clients = list(self._pooled_clients)
|
|
self._available_clients.clear()
|
|
self._pooled_clients.clear()
|
|
else:
|
|
with self._transport_lock:
|
|
if self._owns_client and self._client is not None:
|
|
clients = [self._client]
|
|
|
|
close_error: Exception | None = None
|
|
for client in clients:
|
|
try:
|
|
client.close()
|
|
except Exception as error: # Close every transport before surfacing one failure.
|
|
if close_error is None:
|
|
close_error = error
|
|
with self._client_condition:
|
|
self._closed = True
|
|
self._closing = False
|
|
self._client_condition.notify_all()
|
|
if close_error is not None:
|
|
raise close_error
|
|
|
|
def __enter__(self) -> ApiClient:
|
|
"""Return this client for use as a context manager."""
|
|
|
|
return self
|
|
|
|
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
"""Close owned resources when leaving a context manager."""
|
|
|
|
self.close()
|
|
|
|
def _endpoint_url(self, endpoint: str) -> str:
|
|
value = endpoint.strip()
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme or parsed.netloc:
|
|
raise ValueError("endpoint must be a relative API path")
|
|
path = parsed.path.lstrip("/")
|
|
if path.lower().startswith("adminapi/"):
|
|
path = path[len("adminapi/") :]
|
|
if not path:
|
|
raise ValueError("endpoint must not be empty")
|
|
suffix = f"?{parsed.query}" if parsed.query else ""
|
|
return f"{self.base_url}{path}{suffix}"
|
|
|
|
def _headers(
|
|
self,
|
|
extra: Mapping[str, str] | None,
|
|
*,
|
|
json_content_type: bool = True,
|
|
) -> dict[str, str]:
|
|
result = {
|
|
"Accept": "application/json",
|
|
"version": self.API_VERSION,
|
|
"User-Agent": f"ZytDoctorWorkstation/{__version__}",
|
|
}
|
|
if json_content_type:
|
|
result["Content-Type"] = "application/json;charset=UTF-8"
|
|
token = self.token
|
|
if token:
|
|
result["token"] = token
|
|
if extra:
|
|
result.update(extra)
|
|
if not json_content_type:
|
|
for key in tuple(result):
|
|
if key.lower() == "content-type":
|
|
result.pop(key)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _request_id(response: httpx.Response) -> str | None:
|
|
return response.headers.get("x-request-id") or response.headers.get("request-id")
|
|
|
|
def _unwrap(self, response: httpx.Response) -> Any:
|
|
request_id = self._request_id(response)
|
|
if not 200 <= response.status_code < 300:
|
|
raise ApiHttpError(
|
|
f"API returned HTTP {response.status_code}",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
try:
|
|
envelope = response.json()
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
raise ApiProtocolError(
|
|
"API response is not valid JSON",
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
) from exc
|
|
if not isinstance(envelope, Mapping):
|
|
raise ApiProtocolError(
|
|
"API response envelope must be an object",
|
|
data=envelope,
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
raw_code = envelope.get("code")
|
|
try:
|
|
if isinstance(raw_code, bool):
|
|
raise ValueError
|
|
code = int(raw_code)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ApiProtocolError(
|
|
"API response envelope has no valid code",
|
|
data=dict(envelope),
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
) from exc
|
|
data = envelope.get("data")
|
|
message = str(envelope.get("msg") or envelope.get("message") or "").strip()
|
|
if code == 1:
|
|
return data
|
|
context = {
|
|
"code": code,
|
|
"data": data,
|
|
"status_code": response.status_code,
|
|
"request_id": request_id,
|
|
}
|
|
if code == 0:
|
|
if not message and isinstance(data, str):
|
|
message = data
|
|
raise ApiBusinessError(message or "API rejected the operation", **context)
|
|
if code == -1:
|
|
raise AuthenticationExpiredError(message or "Login has expired", **context)
|
|
if code == 10:
|
|
raise WorkWechatBindingRequiredError(
|
|
message or "Enterprise WeChat binding is required", **context
|
|
)
|
|
if code == 2:
|
|
target = str(_mapping(data).get("url") or "")
|
|
raise OpenPageRequiredError(
|
|
message or "The operation must continue in another page",
|
|
url=target,
|
|
data=data,
|
|
status_code=response.status_code,
|
|
request_id=request_id,
|
|
)
|
|
if code == -2:
|
|
raise InstallationRequiredError(
|
|
message or "A required companion component is not installed", **context
|
|
)
|
|
raise ApiProtocolError(message or f"Unsupported API envelope code: {code}", **context)
|
|
|
|
|
|
def _mapping(value: object) -> Mapping[str, Any]:
|
|
return value if isinstance(value, Mapping) else {}
|
|
|
|
|
|
def _iter_event_stream(
|
|
lines: Iterator[str],
|
|
*,
|
|
cancelled: Callable[[], bool] | None = None,
|
|
) -> Iterator[dict[str, Any]]:
|
|
"""Parse SSE fields, including multi-line data and a final unterminated event."""
|
|
|
|
event_name = ""
|
|
event_id = ""
|
|
data_lines: list[str] = []
|
|
|
|
def build_event() -> dict[str, Any] | None:
|
|
nonlocal event_name, event_id, data_lines
|
|
if not event_name and not data_lines:
|
|
event_id = ""
|
|
return None
|
|
raw_data = "\n".join(data_lines)
|
|
try:
|
|
data: Any = json.loads(raw_data) if raw_data else {}
|
|
except (TypeError, ValueError):
|
|
data = raw_data
|
|
inferred = data.get("event") or data.get("type") if isinstance(data, Mapping) else ""
|
|
kind = event_name or str(inferred or "message")
|
|
if raw_data.strip() == "[DONE]":
|
|
kind, data = "done", {}
|
|
event = {"event": kind, "data": data}
|
|
if event_id:
|
|
event["id"] = event_id
|
|
event_name = ""
|
|
event_id = ""
|
|
data_lines = []
|
|
return event
|
|
|
|
for raw_line in lines:
|
|
if cancelled is not None and cancelled():
|
|
return
|
|
line = raw_line.lstrip("\ufeff")
|
|
if not line:
|
|
event = build_event()
|
|
if event is not None:
|
|
yield event
|
|
continue
|
|
if line.startswith(":"):
|
|
continue
|
|
field, separator, value = line.partition(":")
|
|
if separator and value.startswith(" "):
|
|
value = value[1:]
|
|
if field == "event":
|
|
event_name = value
|
|
elif field == "data":
|
|
data_lines.append(value)
|
|
elif field == "id":
|
|
event_id = value
|
|
if cancelled is None or not cancelled():
|
|
event = build_event()
|
|
if event is not None:
|
|
yield event
|