feat(api-client): implement session management for AdvowareAPI and EspoCRMAPI

This commit is contained in:
bsiggel
2026-03-03 17:24:35 +00:00
parent 69a48f5f9a
commit a53051ea8e
2 changed files with 201 additions and 176 deletions

View File

@@ -73,6 +73,17 @@ class AdvowareAPI:
self.logger.info("AdvowareAPI initialized") self.logger.info("AdvowareAPI initialized")
self._session: Optional[aiohttp.ClientSession] = None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _generate_hmac(self, request_time_stamp: str, nonce: Optional[str] = None) -> str: def _generate_hmac(self, request_time_stamp: str, nonce: Optional[str] = None) -> str:
"""Generate HMAC-SHA512 signature for authentication""" """Generate HMAC-SHA512 signature for authentication"""
if not nonce: if not nonce:
@@ -260,77 +271,79 @@ class AdvowareAPI:
# Use 'data' parameter if provided, otherwise 'json_data' # Use 'data' parameter if provided, otherwise 'json_data'
json_payload = data if data is not None else json_data json_payload = data if data is not None else json_data
async with aiohttp.ClientSession(timeout=effective_timeout) as session: session = await self._get_session()
try: try:
with self.logger.api_call(endpoint, method): with self.logger.api_call(endpoint, method):
async with session.request( async with session.request(
method, method,
url, url,
headers=effective_headers, headers=effective_headers,
params=params, params=params,
json=json_payload json=json_payload,
) as response: timeout=effective_timeout
# Handle 401 - retry with fresh token ) as response:
if response.status == 401: # Handle 401 - retry with fresh token
self.logger.warning("401 Unauthorized, refreshing token") if response.status == 401:
token = self.get_access_token(force_refresh=True) self.logger.warning("401 Unauthorized, refreshing token")
effective_headers['Authorization'] = f'Bearer {token}' token = self.get_access_token(force_refresh=True)
effective_headers['Authorization'] = f'Bearer {token}'
async with session.request(
method, async with session.request(
url, method,
headers=effective_headers, url,
params=params, headers=effective_headers,
json=json_payload params=params,
) as retry_response: json=json_payload,
if retry_response.status == 401: timeout=effective_timeout
raise AdvowareAuthError( ) as retry_response:
"Authentication failed even after token refresh", if retry_response.status == 401:
status_code=401 raise AdvowareAuthError(
) "Authentication failed even after token refresh",
status_code=401
if retry_response.status >= 500: )
error_text = await retry_response.text()
raise RetryableError( if retry_response.status >= 500:
f"Server error {retry_response.status}: {error_text}" error_text = await retry_response.text()
) raise RetryableError(
f"Server error {retry_response.status}: {error_text}"
retry_response.raise_for_status() )
return await self._parse_response(retry_response)
retry_response.raise_for_status()
# Handle other error codes return await self._parse_response(retry_response)
if response.status == 404:
error_text = await response.text() # Handle other error codes
raise AdvowareAPIError( if response.status == 404:
f"Resource not found: {endpoint}", error_text = await response.text()
status_code=404, raise AdvowareAPIError(
response_body=error_text f"Resource not found: {endpoint}",
) status_code=404,
response_body=error_text
if response.status >= 500: )
error_text = await response.text()
raise RetryableError( if response.status >= 500:
f"Server error {response.status}: {error_text}" error_text = await response.text()
) raise RetryableError(
f"Server error {response.status}: {error_text}"
if response.status >= 400: )
error_text = await response.text()
raise AdvowareAPIError( if response.status >= 400:
f"API error {response.status}: {error_text}", error_text = await response.text()
status_code=response.status, raise AdvowareAPIError(
response_body=error_text f"API error {response.status}: {error_text}",
) status_code=response.status,
response_body=error_text
return await self._parse_response(response) )
except asyncio.TimeoutError: return await self._parse_response(response)
raise AdvowareTimeoutError(
f"Request timed out after {effective_timeout.total}s", except asyncio.TimeoutError:
status_code=408 raise AdvowareTimeoutError(
) f"Request timed out after {effective_timeout.total}s",
except aiohttp.ClientError as e: status_code=408
self.logger.error(f"API call failed: {e}") )
raise AdvowareAPIError(f"Request failed: {str(e)}") except aiohttp.ClientError as e:
self.logger.error(f"API call failed: {e}")
raise AdvowareAPIError(f"Request failed: {str(e)}")
async def _parse_response(self, response: aiohttp.ClientResponse) -> Any: async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
"""Parse API response""" """Parse API response"""

View File

@@ -54,6 +54,8 @@ class EspoCRMAPI:
raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment") raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment")
self.logger.info(f"EspoCRM API initialized with base URL: {self.api_base_url}") self.logger.info(f"EspoCRM API initialized with base URL: {self.api_base_url}")
self._session: Optional[aiohttp.ClientSession] = None
# Optional Redis for caching/rate limiting (centralized) # Optional Redis for caching/rate limiting (centralized)
self.redis_client = get_redis_client(strict=False) self.redis_client = get_redis_client(strict=False)
@@ -70,6 +72,15 @@ class EspoCRMAPI:
'Accept': 'application/json' 'Accept': 'application/json'
} }
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
async def api_call( async def api_call(
self, self,
endpoint: str, endpoint: str,
@@ -106,61 +117,62 @@ class EspoCRMAPI:
total=timeout_seconds or self.api_timeout_seconds total=timeout_seconds or self.api_timeout_seconds
) )
async with aiohttp.ClientSession(timeout=effective_timeout) as session: session = await self._get_session()
try: try:
with self.logger.api_call(endpoint, method): with self.logger.api_call(endpoint, method):
async with session.request( async with session.request(
method, method,
url, url,
headers=headers, headers=headers,
params=params, params=params,
json=json_data json=json_data,
) as response: timeout=effective_timeout
# Handle errors ) as response:
if response.status == 401: # Handle errors
raise EspoCRMAuthError( if response.status == 401:
"Authentication failed - check API key", raise EspoCRMAuthError(
status_code=401 "Authentication failed - check API key",
) status_code=401
elif response.status == 403: )
raise EspoCRMAPIError( elif response.status == 403:
"Access forbidden", raise EspoCRMAPIError(
status_code=403 "Access forbidden",
) status_code=403
elif response.status == 404: )
raise EspoCRMAPIError( elif response.status == 404:
f"Resource not found: {endpoint}", raise EspoCRMAPIError(
status_code=404 f"Resource not found: {endpoint}",
) status_code=404
elif response.status >= 500: )
error_text = await response.text() elif response.status >= 500:
raise RetryableError( error_text = await response.text()
f"Server error {response.status}: {error_text}" raise RetryableError(
) f"Server error {response.status}: {error_text}"
elif response.status >= 400: )
error_text = await response.text() elif response.status >= 400:
raise EspoCRMAPIError( error_text = await response.text()
f"API error {response.status}: {error_text}", raise EspoCRMAPIError(
status_code=response.status, f"API error {response.status}: {error_text}",
response_body=error_text status_code=response.status,
) response_body=error_text
)
# Parse response
if response.content_type == 'application/json': # Parse response
result = await response.json() if response.content_type == 'application/json':
return result result = await response.json()
else: return result
# For DELETE or other non-JSON responses else:
return None # For DELETE or other non-JSON responses
return None
except asyncio.TimeoutError:
raise EspoCRMTimeoutError( except asyncio.TimeoutError:
f"Request timed out after {effective_timeout.total}s", raise EspoCRMTimeoutError(
status_code=408 f"Request timed out after {effective_timeout.total}s",
) status_code=408
except aiohttp.ClientError as e: )
self.logger.error(f"API call failed: {e}") except aiohttp.ClientError as e:
raise EspoCRMAPIError(f"Request failed: {str(e)}") self.logger.error(f"API call failed: {e}")
raise EspoCRMAPIError(f"Request failed: {str(e)}")
async def get_entity(self, entity_type: str, entity_id: str) -> Dict[str, Any]: async def get_entity(self, entity_type: str, entity_id: str) -> Dict[str, Any]:
""" """
@@ -340,36 +352,36 @@ class EspoCRMAPI:
effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds) effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
async with aiohttp.ClientSession(timeout=effective_timeout) as session: session = await self._get_session()
try: try:
async with session.post(url, headers=headers, data=form_data) as response: async with session.post(url, headers=headers, data=form_data, timeout=effective_timeout) as response:
self._log(f"Upload response status: {response.status}") self._log(f"Upload response status: {response.status}")
if response.status == 401: if response.status == 401:
raise EspoCRMAuthError("Authentication failed - check API key") raise EspoCRMAuthError("Authentication failed - check API key")
elif response.status == 403: elif response.status == 403:
raise EspoCRMError("Access forbidden") raise EspoCRMError("Access forbidden")
elif response.status == 404: elif response.status == 404:
raise EspoCRMError(f"Attachment endpoint not found") raise EspoCRMError(f"Attachment endpoint not found")
elif response.status >= 400: elif response.status >= 400:
error_text = await response.text() error_text = await response.text()
self._log(f"❌ Upload failed with {response.status}. Response: {error_text}", level='error') self._log(f"❌ Upload failed with {response.status}. Response: {error_text}", level='error')
raise EspoCRMError(f"Upload error {response.status}: {error_text}") raise EspoCRMError(f"Upload error {response.status}: {error_text}")
# Parse response # Parse response
if response.content_type == 'application/json': if response.content_type == 'application/json':
result = await response.json() result = await response.json()
attachment_id = result.get('id') attachment_id = result.get('id')
self._log(f"✅ Attachment uploaded successfully: {attachment_id}") self._log(f"✅ Attachment uploaded successfully: {attachment_id}")
return result return result
else: else:
response_text = await response.text() response_text = await response.text()
self._log(f"⚠️ Non-JSON response: {response_text[:200]}", level='warn') self._log(f"⚠️ Non-JSON response: {response_text[:200]}", level='warn')
return {'success': True, 'response': response_text} return {'success': True, 'response': response_text}
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
self._log(f"Upload failed: {e}", level='error') self._log(f"Upload failed: {e}", level='error')
raise EspoCRMError(f"Upload request failed: {e}") from e raise EspoCRMError(f"Upload request failed: {e}") from e
async def download_attachment(self, attachment_id: str) -> bytes: async def download_attachment(self, attachment_id: str) -> bytes:
""" """
@@ -390,23 +402,23 @@ class EspoCRMAPI:
effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds) effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
async with aiohttp.ClientSession(timeout=effective_timeout) as session: session = await self._get_session()
try: try:
async with session.get(url, headers=headers) as response: async with session.get(url, headers=headers, timeout=effective_timeout) as response:
if response.status == 401: if response.status == 401:
raise EspoCRMAuthError("Authentication failed - check API key") raise EspoCRMAuthError("Authentication failed - check API key")
elif response.status == 403: elif response.status == 403:
raise EspoCRMError("Access forbidden") raise EspoCRMError("Access forbidden")
elif response.status == 404: elif response.status == 404:
raise EspoCRMError(f"Attachment not found: {attachment_id}") raise EspoCRMError(f"Attachment not found: {attachment_id}")
elif response.status >= 400: elif response.status >= 400:
error_text = await response.text() error_text = await response.text()
raise EspoCRMError(f"Download error {response.status}: {error_text}") raise EspoCRMError(f"Download error {response.status}: {error_text}")
content = await response.read() content = await response.read()
self._log(f"✅ Downloaded {len(content)} bytes") self._log(f"✅ Downloaded {len(content)} bytes")
return content return content
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
self._log(f"Download failed: {e}", level='error') self._log(f"Download failed: {e}", level='error')
raise EspoCRMError(f"Download request failed: {e}") from e raise EspoCRMError(f"Download request failed: {e}") from e