ahs-cti-api-sdk 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ahs_cti/__init__.py +24 -0
- ahs_cti/_auth.py +236 -0
- ahs_cti/_endpoints.py +56 -0
- ahs_cti/_http.py +277 -0
- ahs_cti/client.py +197 -0
- ahs_cti/exceptions.py +56 -0
- ahs_cti/models.py +54 -0
- ahs_cti/resources/__init__.py +0 -0
- ahs_cti/resources/calls.py +535 -0
- ahs_cti_api_sdk-0.1.0a1.dist-info/METADATA +156 -0
- ahs_cti_api_sdk-0.1.0a1.dist-info/RECORD +13 -0
- ahs_cti_api_sdk-0.1.0a1.dist-info/WHEEL +5 -0
- ahs_cti_api_sdk-0.1.0a1.dist-info/top_level.txt +1 -0
ahs_cti/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""AHS CTI Platform — Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncCtiClient, CtiClient
|
|
4
|
+
from ._http import ClientConfig
|
|
5
|
+
from .exceptions import CtiApiError, CtiAuthError, CtiError, CtiRetryExhaustedError
|
|
6
|
+
from .models import DialResult, EndCallResult
|
|
7
|
+
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
# Clients
|
|
12
|
+
"CtiClient",
|
|
13
|
+
"AsyncCtiClient",
|
|
14
|
+
# Configuration
|
|
15
|
+
"ClientConfig",
|
|
16
|
+
# Models
|
|
17
|
+
"DialResult",
|
|
18
|
+
"EndCallResult",
|
|
19
|
+
# Exceptions
|
|
20
|
+
"CtiError",
|
|
21
|
+
"CtiAuthError",
|
|
22
|
+
"CtiApiError",
|
|
23
|
+
"CtiRetryExhaustedError",
|
|
24
|
+
]
|
ahs_cti/_auth.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Authentication strategies — sync and async, with shared-transport injection.
|
|
2
|
+
|
|
3
|
+
Design
|
|
4
|
+
------
|
|
5
|
+
Auth strategies satisfy the ``SyncAuthStrategy`` / ``AsyncAuthStrategy``
|
|
6
|
+
protocols — they expose only ``get_headers()``. The HTTP transport is
|
|
7
|
+
injected so that the same ``httpx.Client`` (and its ``verify`` / ``timeout``
|
|
8
|
+
settings) is used for both login calls and regular API calls.
|
|
9
|
+
|
|
10
|
+
Adding a new auth method (e.g. OAuth2) requires only implementing the
|
|
11
|
+
protocol — no changes to ``_http.py`` or ``client.py``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .exceptions import CtiAuthError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("ahs_cti.auth")
|
|
24
|
+
|
|
25
|
+
_MIME_JSON = "application/json"
|
|
26
|
+
_EXPIRY_BUFFER_SECS = 60
|
|
27
|
+
_DEFAULT_TOKEN_TTL = 55 * 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Protocols (open for extension, no import of concrete classes needed)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SyncAuthStrategy:
|
|
36
|
+
"""Interface for synchronous authentication strategies.
|
|
37
|
+
|
|
38
|
+
Any class that implements ``get_headers() -> Dict[str, str]``
|
|
39
|
+
satisfies this interface and can be used with ``_HttpClient``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def get_headers(self) -> Dict[str, str]:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncAuthStrategy:
|
|
47
|
+
"""Interface for asynchronous authentication strategies.
|
|
48
|
+
|
|
49
|
+
Any class that implements ``async get_headers() -> Dict[str, str]``
|
|
50
|
+
satisfies this interface and can be used with ``_AsyncHttpClient``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Helpers
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_headers(auth_headers: Dict[str, str]) -> Dict[str, str]:
|
|
63
|
+
return {**auth_headers, "Content-Type": _MIME_JSON, "Accept": _MIME_JSON}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_expiry(expiry_str: Optional[str]) -> float:
|
|
67
|
+
if expiry_str:
|
|
68
|
+
try:
|
|
69
|
+
dt = datetime.fromisoformat(expiry_str.replace("Z", "+00:00"))
|
|
70
|
+
return dt.astimezone(timezone.utc).timestamp()
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
73
|
+
return time.time() + _DEFAULT_TOKEN_TTL
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Sync strategies
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ApiKeyAuth(SyncAuthStrategy):
|
|
82
|
+
"""Static API-key auth. Headers are built in memory — no network calls."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
|
|
85
|
+
self._api_key = api_key
|
|
86
|
+
self._tenant_id = tenant_id
|
|
87
|
+
|
|
88
|
+
def get_headers(self) -> Dict[str, str]:
|
|
89
|
+
auth: Dict[str, str] = {"X-API-Key": self._api_key}
|
|
90
|
+
if self._tenant_id is not None:
|
|
91
|
+
auth["X-Tenant-ID"] = str(self._tenant_id)
|
|
92
|
+
return _build_headers(auth)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _CredentialAuth(SyncAuthStrategy):
|
|
96
|
+
"""Credential-based auth — lazy login, auto-refresh 60 s before expiry.
|
|
97
|
+
|
|
98
|
+
The caller injects the shared ``httpx.Client`` so that SSL and timeout
|
|
99
|
+
settings are consistent between the login call and regular API calls.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
login_url: Full URL of the login endpoint.
|
|
103
|
+
user_id: Agent username.
|
|
104
|
+
password: Agent password.
|
|
105
|
+
http_client: Shared ``httpx.Client`` instance (borrowed, not owned).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
login_url: str,
|
|
111
|
+
user_id: str,
|
|
112
|
+
password: str,
|
|
113
|
+
http_client: httpx.Client,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._login_url = login_url
|
|
116
|
+
self._user_id = user_id
|
|
117
|
+
self._password = password
|
|
118
|
+
self._http_client = http_client
|
|
119
|
+
self._access_token: Optional[str] = None
|
|
120
|
+
self._token_expiry: float = 0.0
|
|
121
|
+
|
|
122
|
+
def get_headers(self) -> Dict[str, str]:
|
|
123
|
+
if not self._is_token_valid():
|
|
124
|
+
self._login()
|
|
125
|
+
return _build_headers({"Authorization": f"Bearer {self._access_token}"})
|
|
126
|
+
|
|
127
|
+
def _is_token_valid(self) -> bool:
|
|
128
|
+
return (
|
|
129
|
+
self._access_token is not None
|
|
130
|
+
and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _login(self) -> None:
|
|
134
|
+
logger.debug("ahs_cti: acquiring token for user_id=%s", self._user_id)
|
|
135
|
+
try:
|
|
136
|
+
resp = self._http_client.post(
|
|
137
|
+
self._login_url,
|
|
138
|
+
json={"userId": self._user_id, "password": self._password},
|
|
139
|
+
)
|
|
140
|
+
except httpx.RequestError as exc:
|
|
141
|
+
raise CtiAuthError(f"Network error during login: {exc}") from exc
|
|
142
|
+
if resp.status_code != 200:
|
|
143
|
+
raise CtiAuthError(
|
|
144
|
+
f"Login failed (HTTP {resp.status_code}). "
|
|
145
|
+
"Verify user_id, password, and base_url."
|
|
146
|
+
)
|
|
147
|
+
data = resp.json()
|
|
148
|
+
token = data.get("accessToken")
|
|
149
|
+
if not token:
|
|
150
|
+
raise CtiAuthError("Login response did not contain an accessToken.")
|
|
151
|
+
self._access_token = token
|
|
152
|
+
self._token_expiry = _parse_expiry(data.get("expiration"))
|
|
153
|
+
logger.debug("ahs_cti: token acquired, expires at %s", self._token_expiry)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Async strategies
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _AsyncApiKeyAuth(SyncAuthStrategy):
|
|
162
|
+
"""API-key auth for use with the async client.
|
|
163
|
+
|
|
164
|
+
Satisfies ``SyncAuthStrategy`` — header building is pure in-memory,
|
|
165
|
+
so no coroutine is needed. ``_AsyncHttpClient`` resolves headers via
|
|
166
|
+
``_resolve_headers()`` which handles both sync and async strategies.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, api_key: str, tenant_id: Optional[int] = None) -> None:
|
|
170
|
+
self._inner = _ApiKeyAuth(api_key=api_key, tenant_id=tenant_id)
|
|
171
|
+
|
|
172
|
+
def get_headers(self) -> Dict[str, str]:
|
|
173
|
+
return self._inner.get_headers()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _AsyncCredentialAuth(AsyncAuthStrategy):
|
|
177
|
+
"""Async credential auth — lazy login with asyncio.Lock to prevent races.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
login_url: Full URL of the login endpoint.
|
|
181
|
+
user_id: Agent username.
|
|
182
|
+
password: Agent password.
|
|
183
|
+
http_client: Shared ``httpx.AsyncClient`` instance (borrowed, not owned).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
login_url: str,
|
|
189
|
+
user_id: str,
|
|
190
|
+
password: str,
|
|
191
|
+
http_client: httpx.AsyncClient,
|
|
192
|
+
) -> None:
|
|
193
|
+
self._login_url = login_url
|
|
194
|
+
self._user_id = user_id
|
|
195
|
+
self._password = password
|
|
196
|
+
self._http_client = http_client
|
|
197
|
+
self._access_token: Optional[str] = None
|
|
198
|
+
self._token_expiry: float = 0.0
|
|
199
|
+
self._lock: Optional["asyncio.Lock"] = None # created lazily inside event loop
|
|
200
|
+
|
|
201
|
+
async def get_headers(self) -> Dict[str, str]:
|
|
202
|
+
import asyncio
|
|
203
|
+
if self._lock is None:
|
|
204
|
+
self._lock = asyncio.Lock()
|
|
205
|
+
async with self._lock:
|
|
206
|
+
if not self._is_token_valid():
|
|
207
|
+
await self._login()
|
|
208
|
+
return _build_headers({"Authorization": f"Bearer {self._access_token}"})
|
|
209
|
+
|
|
210
|
+
def _is_token_valid(self) -> bool:
|
|
211
|
+
return (
|
|
212
|
+
self._access_token is not None
|
|
213
|
+
and time.time() < self._token_expiry - _EXPIRY_BUFFER_SECS
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def _login(self) -> None:
|
|
217
|
+
logger.debug("ahs_cti: async acquiring token for user_id=%s", self._user_id)
|
|
218
|
+
try:
|
|
219
|
+
resp = await self._http_client.post(
|
|
220
|
+
self._login_url,
|
|
221
|
+
json={"userId": self._user_id, "password": self._password},
|
|
222
|
+
)
|
|
223
|
+
except httpx.RequestError as exc:
|
|
224
|
+
raise CtiAuthError(f"Network error during login: {exc}") from exc
|
|
225
|
+
if resp.status_code != 200:
|
|
226
|
+
raise CtiAuthError(
|
|
227
|
+
f"Login failed (HTTP {resp.status_code}). "
|
|
228
|
+
"Verify user_id, password, and base_url."
|
|
229
|
+
)
|
|
230
|
+
data = resp.json()
|
|
231
|
+
token = data.get("accessToken")
|
|
232
|
+
if not token:
|
|
233
|
+
raise CtiAuthError("Login response did not contain an accessToken.")
|
|
234
|
+
self._access_token = token
|
|
235
|
+
self._token_expiry = _parse_expiry(data.get("expiration"))
|
|
236
|
+
logger.debug("ahs_cti: async token acquired, expires at %s", self._token_expiry)
|
ahs_cti/_endpoints.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Central API endpoint registry.
|
|
2
|
+
|
|
3
|
+
All paths are versioned under API_VERSION. To upgrade the entire SDK
|
|
4
|
+
to a new API version, change API_VERSION here — no other file needs editing.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from ._endpoints import Endpoints
|
|
9
|
+
self._http.post(Endpoints.DIAL, body)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
API_VERSION = "v1"
|
|
13
|
+
_BASE = f"/api/{API_VERSION}/cti"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Endpoints:
|
|
17
|
+
"""All CTI platform API endpoint paths."""
|
|
18
|
+
|
|
19
|
+
# ── Calls ─────────────────────────────────────────────────────────────────
|
|
20
|
+
DIAL = f"{_BASE}/calls/dial"
|
|
21
|
+
HANGUP = f"{_BASE}/calls/hangup"
|
|
22
|
+
ANSWER = f"{_BASE}/calls/{{call_id}}/answer"
|
|
23
|
+
FETCH_DETAILS = f"{_BASE}/calls/fetch-call-details"
|
|
24
|
+
ACTIVE_CALLS = f"{_BASE}/calls/active"
|
|
25
|
+
|
|
26
|
+
# ── Hold / Mute / DTMF ───────────────────────────────────────────────────
|
|
27
|
+
HOLD = f"{_BASE}/calls/hold"
|
|
28
|
+
MUTE = f"{_BASE}/calls/mute"
|
|
29
|
+
DTMF = f"{_BASE}/calls/{{call_id}}/dtmf"
|
|
30
|
+
|
|
31
|
+
# ── Transfer ──────────────────────────────────────────────────────────────
|
|
32
|
+
TRANSFER = f"{_BASE}/calls/{{call_id}}/transfer"
|
|
33
|
+
TRANSFER_CALL = f"{_BASE}/calls/transfer-call"
|
|
34
|
+
TRANSFER_CANCEL = f"{_BASE}/calls/transfer/cancel"
|
|
35
|
+
TRANSFER_COMPLETE = f"{_BASE}/calls/transfer/complete"
|
|
36
|
+
|
|
37
|
+
# ── Conference ────────────────────────────────────────────────────────────
|
|
38
|
+
CONFERENCE_ADD = f"{_BASE}/calls/conference/add"
|
|
39
|
+
CONFERENCE_MERGE = f"{_BASE}/calls/conference/merge"
|
|
40
|
+
|
|
41
|
+
# ── Agent ─────────────────────────────────────────────────────────────────
|
|
42
|
+
AGENT_REGISTER = f"{_BASE}/agent/register"
|
|
43
|
+
AGENT_UNREGISTER = f"{_BASE}/agent/unregister"
|
|
44
|
+
AGENT_CONFIG = f"{_BASE}/agent/config"
|
|
45
|
+
|
|
46
|
+
# ── Supervisor ────────────────────────────────────────────────────────────
|
|
47
|
+
SUPERVISOR_LISTEN = f"{_BASE}/supervisor/listen"
|
|
48
|
+
SUPERVISOR_LEAVE = f"{_BASE}/supervisor/leave"
|
|
49
|
+
SUPERVISOR_MODE = f"{_BASE}/supervisor/mode"
|
|
50
|
+
|
|
51
|
+
# ── Data / Lookup ─────────────────────────────────────────────────────────
|
|
52
|
+
DISPOSITIONS = f"{_BASE}/calls/dispositions"
|
|
53
|
+
TRANSFER_TO_DETAILS = f"{_BASE}/transfer-to-details"
|
|
54
|
+
USERS = f"{_BASE}/users"
|
|
55
|
+
HOSPITAL_SERVICES = f"{_BASE}/hospital/services"
|
|
56
|
+
BREAKS = f"{_BASE}/breaks"
|
ahs_cti/_http.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""HTTP transport — sync and async, with retry and shared client injection.
|
|
2
|
+
|
|
3
|
+
Design
|
|
4
|
+
------
|
|
5
|
+
- ``ClientConfig`` groups all transport settings in one immutable object.
|
|
6
|
+
- Both ``_HttpClient`` and ``_AsyncHttpClient`` receive an externally created
|
|
7
|
+
``httpx.Client`` / ``httpx.AsyncClient`` so that SSL and timeout settings
|
|
8
|
+
are consistent across login calls and regular API calls.
|
|
9
|
+
- Retry uses exponential backoff on 5xx responses and transient network errors.
|
|
10
|
+
4xx responses are never retried (deterministic client errors).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import inspect
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Dict, Optional, Union
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._auth import AsyncAuthStrategy, SyncAuthStrategy
|
|
23
|
+
from .exceptions import CtiApiError, CtiAuthError, CtiRetryExhaustedError
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("ahs_cti.http")
|
|
26
|
+
|
|
27
|
+
_RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504})
|
|
28
|
+
_RETRYABLE_EXCEPTIONS = (
|
|
29
|
+
httpx.ConnectError,
|
|
30
|
+
httpx.TimeoutException,
|
|
31
|
+
httpx.RemoteProtocolError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Configuration
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ClientConfig:
|
|
42
|
+
"""Immutable transport configuration for ``CtiClient`` and ``AsyncCtiClient``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
base_url: Root URL of the CTI platform (no trailing slash).
|
|
46
|
+
Example: ``"https://cti.example.com"``
|
|
47
|
+
timeout: HTTP request timeout in seconds. Applied to all requests
|
|
48
|
+
including the auth login call. Defaults to ``30.0``.
|
|
49
|
+
verify_ssl: Whether to verify the server's TLS certificate.
|
|
50
|
+
Set to ``False`` for servers with self-signed certificates.
|
|
51
|
+
Defaults to ``True``.
|
|
52
|
+
max_retries: Maximum number of retries after the first attempt for
|
|
53
|
+
5xx responses and transient connection errors. ``0`` disables
|
|
54
|
+
retries entirely. Defaults to ``3``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
base_url: str
|
|
58
|
+
timeout: float = 30.0
|
|
59
|
+
verify_ssl: bool = True
|
|
60
|
+
max_retries: int = 3
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Response parsing
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_response(resp: httpx.Response) -> Dict[str, Any]:
|
|
69
|
+
if resp.status_code == 401:
|
|
70
|
+
raise CtiAuthError(
|
|
71
|
+
"Unauthorized — the token or API key was rejected. "
|
|
72
|
+
"It may have been revoked or is expired."
|
|
73
|
+
)
|
|
74
|
+
if resp.status_code == 403:
|
|
75
|
+
raise CtiApiError(
|
|
76
|
+
"Forbidden — this account does not have permission for this action.",
|
|
77
|
+
status_code=403,
|
|
78
|
+
)
|
|
79
|
+
if resp.status_code >= 400:
|
|
80
|
+
try:
|
|
81
|
+
detail = resp.json()
|
|
82
|
+
except Exception:
|
|
83
|
+
detail = {"raw": resp.text}
|
|
84
|
+
raise CtiApiError(
|
|
85
|
+
"The platform returned an error.",
|
|
86
|
+
status_code=resp.status_code,
|
|
87
|
+
response=detail,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
return resp.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
return {"raw": resp.text}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Auth header resolution — handles both sync and async strategies
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _resolve_headers(
|
|
101
|
+
auth: Union[SyncAuthStrategy, AsyncAuthStrategy],
|
|
102
|
+
) -> Dict[str, str]:
|
|
103
|
+
"""Call ``auth.get_headers()`` whether it is sync or async."""
|
|
104
|
+
result = auth.get_headers()
|
|
105
|
+
if inspect.isawaitable(result):
|
|
106
|
+
return await result
|
|
107
|
+
return result # type: ignore[return-value]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Sync client
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _HttpClient:
|
|
116
|
+
"""Sync HTTP wrapper — injects auth, retries on transient failures.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
config: Transport configuration.
|
|
120
|
+
auth: Authentication strategy (sync).
|
|
121
|
+
http_client: Shared ``httpx.Client`` instance to use for all requests.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
config: ClientConfig,
|
|
127
|
+
auth: SyncAuthStrategy,
|
|
128
|
+
http_client: httpx.Client,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._config = config
|
|
131
|
+
self._auth = auth
|
|
132
|
+
self._client = http_client
|
|
133
|
+
|
|
134
|
+
def post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
135
|
+
url = f"{self._config.base_url.rstrip('/')}{path}"
|
|
136
|
+
logger.debug("ahs_cti: POST %s", path)
|
|
137
|
+
resp = self._execute_with_retry("post", url, json=body)
|
|
138
|
+
return _parse_response(resp)
|
|
139
|
+
|
|
140
|
+
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
141
|
+
url = f"{self._config.base_url.rstrip('/')}{path}"
|
|
142
|
+
logger.debug("ahs_cti: GET %s", path)
|
|
143
|
+
resp = self._execute_with_retry("get", url, params=params or {})
|
|
144
|
+
return _parse_response(resp)
|
|
145
|
+
|
|
146
|
+
def _execute_with_retry(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
147
|
+
last_exc: Optional[Exception] = None
|
|
148
|
+
for attempt in range(self._config.max_retries + 1):
|
|
149
|
+
if attempt > 0:
|
|
150
|
+
wait = min(2 ** attempt, 30)
|
|
151
|
+
logger.warning(
|
|
152
|
+
"ahs_cti: retrying %s %s (attempt %d/%d) in %ds",
|
|
153
|
+
method.upper(), url, attempt, self._config.max_retries, wait,
|
|
154
|
+
)
|
|
155
|
+
time.sleep(wait)
|
|
156
|
+
try:
|
|
157
|
+
headers = self._auth.get_headers()
|
|
158
|
+
resp = getattr(self._client, method)(url, headers=headers, **kwargs)
|
|
159
|
+
logger.debug("ahs_cti: %s %s → %d", method.upper(), url, resp.status_code)
|
|
160
|
+
if resp.status_code in _RETRYABLE_STATUS_CODES:
|
|
161
|
+
last_exc = CtiApiError(
|
|
162
|
+
f"Server error on {url}",
|
|
163
|
+
status_code=resp.status_code,
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
return resp
|
|
167
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
168
|
+
last_exc = exc
|
|
169
|
+
continue
|
|
170
|
+
except httpx.RequestError as exc:
|
|
171
|
+
raise CtiApiError(f"Network error calling {url}: {exc}") from exc
|
|
172
|
+
|
|
173
|
+
logger.error(
|
|
174
|
+
"ahs_cti: all %d attempts exhausted for %s %s",
|
|
175
|
+
self._config.max_retries + 1, method.upper(), url,
|
|
176
|
+
)
|
|
177
|
+
raise CtiRetryExhaustedError(
|
|
178
|
+
f"Request to {url} failed after {self._config.max_retries + 1} attempt(s).",
|
|
179
|
+
attempts=self._config.max_retries + 1,
|
|
180
|
+
last_exc=last_exc,
|
|
181
|
+
) from last_exc
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
self._client.close()
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> "_HttpClient":
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *_: Any) -> None:
|
|
190
|
+
self.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Async client
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _AsyncHttpClient:
|
|
199
|
+
"""Async HTTP wrapper — injects auth, retries on transient failures.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
config: Transport configuration.
|
|
203
|
+
auth: Authentication strategy (sync or async).
|
|
204
|
+
http_client: Shared ``httpx.AsyncClient`` instance to use for all requests.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
config: ClientConfig,
|
|
210
|
+
auth: Union[SyncAuthStrategy, AsyncAuthStrategy],
|
|
211
|
+
http_client: httpx.AsyncClient,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._config = config
|
|
214
|
+
self._auth = auth
|
|
215
|
+
self._client = http_client
|
|
216
|
+
|
|
217
|
+
async def post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
218
|
+
url = f"{self._config.base_url.rstrip('/')}{path}"
|
|
219
|
+
logger.debug("ahs_cti: async POST %s", path)
|
|
220
|
+
resp = await self._execute_with_retry("post", url, json=body)
|
|
221
|
+
return _parse_response(resp)
|
|
222
|
+
|
|
223
|
+
async def get(
|
|
224
|
+
self, path: str, params: Optional[Dict[str, Any]] = None
|
|
225
|
+
) -> Dict[str, Any]:
|
|
226
|
+
url = f"{self._config.base_url.rstrip('/')}{path}"
|
|
227
|
+
logger.debug("ahs_cti: async GET %s", path)
|
|
228
|
+
resp = await self._execute_with_retry("get", url, params=params or {})
|
|
229
|
+
return _parse_response(resp)
|
|
230
|
+
|
|
231
|
+
async def _execute_with_retry(
|
|
232
|
+
self, method: str, url: str, **kwargs: Any
|
|
233
|
+
) -> httpx.Response:
|
|
234
|
+
last_exc: Optional[Exception] = None
|
|
235
|
+
for attempt in range(self._config.max_retries + 1):
|
|
236
|
+
if attempt > 0:
|
|
237
|
+
wait = min(2 ** attempt, 30)
|
|
238
|
+
logger.warning(
|
|
239
|
+
"ahs_cti: retrying %s %s (attempt %d/%d) in %ds",
|
|
240
|
+
method.upper(), url, attempt, self._config.max_retries, wait,
|
|
241
|
+
)
|
|
242
|
+
await asyncio.sleep(wait)
|
|
243
|
+
try:
|
|
244
|
+
headers = await _resolve_headers(self._auth)
|
|
245
|
+
resp = await getattr(self._client, method)(url, headers=headers, **kwargs)
|
|
246
|
+
logger.debug("ahs_cti: %s %s → %d", method.upper(), url, resp.status_code)
|
|
247
|
+
if resp.status_code in _RETRYABLE_STATUS_CODES:
|
|
248
|
+
last_exc = CtiApiError(
|
|
249
|
+
f"Server error on {url}",
|
|
250
|
+
status_code=resp.status_code,
|
|
251
|
+
)
|
|
252
|
+
continue
|
|
253
|
+
return resp
|
|
254
|
+
except _RETRYABLE_EXCEPTIONS as exc:
|
|
255
|
+
last_exc = exc
|
|
256
|
+
continue
|
|
257
|
+
except httpx.RequestError as exc:
|
|
258
|
+
raise CtiApiError(f"Network error calling {url}: {exc}") from exc
|
|
259
|
+
|
|
260
|
+
logger.error(
|
|
261
|
+
"ahs_cti: all %d attempts exhausted for %s %s",
|
|
262
|
+
self._config.max_retries + 1, method.upper(), url,
|
|
263
|
+
)
|
|
264
|
+
raise CtiRetryExhaustedError(
|
|
265
|
+
f"Request to {url} failed after {self._config.max_retries + 1} attempt(s).",
|
|
266
|
+
attempts=self._config.max_retries + 1,
|
|
267
|
+
last_exc=last_exc,
|
|
268
|
+
) from last_exc
|
|
269
|
+
|
|
270
|
+
async def aclose(self) -> None:
|
|
271
|
+
await self._client.aclose()
|
|
272
|
+
|
|
273
|
+
async def __aenter__(self) -> "_AsyncHttpClient":
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
277
|
+
await self.aclose()
|