Files
zyt/app/src/doctor_workstation/services/api_client.py
T

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