feat(api-client): implement session management for AdvowareAPI and EspoCRMAPI
This commit is contained in:
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user