Implement central configuration, custom exceptions, logging utilities, Pydantic models, and Redis client for BitByLaw integration
- Added `config.py` for centralized configuration management including Sync, API, Advoware, EspoCRM, Redis, Logging, Calendar Sync, and Feature Flags. - Created `exceptions.py` with a hierarchy of custom exceptions for integration errors, API errors, sync errors, and Redis errors. - Developed `logging_utils.py` for a unified logging wrapper supporting structured logging and performance tracking. - Defined Pydantic models in `models.py` for data validation of Advoware and EspoCRM entities, including sync operation models. - Introduced `redis_client.py` for a centralized Redis client factory with connection pooling, automatic reconnection, and health checks.
This commit is contained in:
@@ -8,18 +8,22 @@ import hashlib
|
||||
import base64
|
||||
import os
|
||||
import datetime
|
||||
import redis
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from services.exceptions import (
|
||||
AdvowareAPIError,
|
||||
AdvowareAuthError,
|
||||
AdvowareTimeoutError,
|
||||
RetryableError
|
||||
)
|
||||
from services.redis_client import get_redis_client
|
||||
from services.config import ADVOWARE_CONFIG, API_CONFIG
|
||||
from services.logging_utils import get_service_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdvowareTokenError(Exception):
|
||||
"""Raised when token acquisition fails"""
|
||||
pass
|
||||
|
||||
|
||||
class AdvowareAPI:
|
||||
"""
|
||||
Advoware API client with token caching via Redis.
|
||||
@@ -34,14 +38,7 @@ class AdvowareAPI:
|
||||
- ADVOWARE_USER
|
||||
- ADVOWARE_ROLE
|
||||
- ADVOWARE_PASSWORD
|
||||
- REDIS_HOST (optional, default: localhost)
|
||||
- REDIS_PORT (optional, default: 6379)
|
||||
- REDIS_DB_ADVOWARE_CACHE (optional, default: 1)
|
||||
"""
|
||||
|
||||
AUTH_URL = "https://security.advo-net.net/api/v1/Token"
|
||||
TOKEN_CACHE_KEY = 'advoware_access_token'
|
||||
TOKEN_TIMESTAMP_CACHE_KEY = 'advoware_token_timestamp'
|
||||
|
||||
def __init__(self, context=None):
|
||||
"""
|
||||
@@ -51,7 +48,8 @@ class AdvowareAPI:
|
||||
context: Motia FlowContext for logging (optional)
|
||||
"""
|
||||
self.context = context
|
||||
self._log("AdvowareAPI initializing", level='debug')
|
||||
self.logger = get_service_logger('advoware', context)
|
||||
self.logger.debug("AdvowareAPI initializing")
|
||||
|
||||
# Load configuration from environment
|
||||
self.API_BASE_URL = os.getenv('ADVOWARE_API_BASE_URL', 'https://www2.advo-net.net:90/')
|
||||
@@ -63,30 +61,17 @@ class AdvowareAPI:
|
||||
self.user = os.getenv('ADVOWARE_USER', '')
|
||||
self.role = int(os.getenv('ADVOWARE_ROLE', '2'))
|
||||
self.password = os.getenv('ADVOWARE_PASSWORD', '')
|
||||
self.token_lifetime_minutes = int(os.getenv('ADVOWARE_TOKEN_LIFETIME_MINUTES', '55'))
|
||||
self.api_timeout_seconds = int(os.getenv('ADVOWARE_API_TIMEOUT_SECONDS', '30'))
|
||||
self.token_lifetime_minutes = ADVOWARE_CONFIG.token_lifetime_minutes
|
||||
self.api_timeout_seconds = API_CONFIG.default_timeout_seconds
|
||||
|
||||
# Initialize Redis for token caching
|
||||
try:
|
||||
redis_host = os.getenv('REDIS_HOST', 'localhost')
|
||||
redis_port = int(os.getenv('REDIS_PORT', '6379'))
|
||||
redis_db = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1'))
|
||||
redis_timeout = int(os.getenv('REDIS_TIMEOUT_SECONDS', '5'))
|
||||
|
||||
self.redis_client = redis.Redis(
|
||||
host=redis_host,
|
||||
port=redis_port,
|
||||
db=redis_db,
|
||||
socket_timeout=redis_timeout,
|
||||
socket_connect_timeout=redis_timeout
|
||||
)
|
||||
self.redis_client.ping()
|
||||
self._log("Connected to Redis for token caching")
|
||||
except (redis.exceptions.ConnectionError, Exception) as e:
|
||||
self._log(f"Could not connect to Redis: {e}. Token caching disabled.", level='warning')
|
||||
self.redis_client = None
|
||||
# Initialize Redis for token caching (centralized)
|
||||
self.redis_client = get_redis_client(strict=False)
|
||||
if self.redis_client:
|
||||
self.logger.info("Connected to Redis for token caching")
|
||||
else:
|
||||
self.logger.warning("⚠️ Redis unavailable - token caching disabled!")
|
||||
|
||||
self._log("AdvowareAPI initialized")
|
||||
self.logger.info("AdvowareAPI initialized")
|
||||
|
||||
def _generate_hmac(self, request_time_stamp: str, nonce: Optional[str] = None) -> str:
|
||||
"""Generate HMAC-SHA512 signature for authentication"""
|
||||
@@ -107,7 +92,7 @@ class AdvowareAPI:
|
||||
|
||||
def _fetch_new_access_token(self) -> str:
|
||||
"""Fetch new access token from Advoware Auth API"""
|
||||
self._log("Fetching new access token from Advoware")
|
||||
self.logger.info("Fetching new access token from Advoware")
|
||||
|
||||
nonce = str(uuid.uuid4())
|
||||
request_time_stamp = datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
@@ -127,35 +112,56 @@ class AdvowareAPI:
|
||||
"RequestTimeStamp": request_time_stamp
|
||||
}
|
||||
|
||||
self._log(f"Token request: AppID={self.app_id}, User={self.user}", level='debug')
|
||||
self.logger.debug(f"Token request: AppID={self.app_id}, User={self.user}")
|
||||
|
||||
# Using synchronous requests for token fetch (called from sync context)
|
||||
# TODO: Convert to async in future version
|
||||
import requests
|
||||
response = requests.post(
|
||||
self.AUTH_URL,
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=self.api_timeout_seconds
|
||||
)
|
||||
|
||||
self._log(f"Token response status: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.post(
|
||||
ADVOWARE_CONFIG.auth_url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
timeout=self.api_timeout_seconds
|
||||
)
|
||||
|
||||
self.logger.debug(f"Token response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 401:
|
||||
raise AdvowareAuthError(
|
||||
"Authentication failed - check credentials",
|
||||
status_code=401
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
except requests.Timeout:
|
||||
raise AdvowareTimeoutError(
|
||||
"Token request timed out",
|
||||
status_code=408
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise AdvowareAPIError(
|
||||
f"Token request failed: {str(e)}",
|
||||
status_code=getattr(e.response, 'status_code', None) if hasattr(e, 'response') else None
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
access_token = result.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
self._log("No access_token in response", level='error')
|
||||
raise AdvowareTokenError("No access_token received from Advoware")
|
||||
self.logger.error("No access_token in response")
|
||||
raise AdvowareAuthError("No access_token received from Advoware")
|
||||
|
||||
self._log("Access token fetched successfully")
|
||||
self.logger.info("Access token fetched successfully")
|
||||
|
||||
# Cache token in Redis
|
||||
if self.redis_client:
|
||||
effective_ttl = max(1, (self.token_lifetime_minutes - 2) * 60)
|
||||
self.redis_client.set(self.TOKEN_CACHE_KEY, access_token, ex=effective_ttl)
|
||||
self.redis_client.set(self.TOKEN_TIMESTAMP_CACHE_KEY, str(time.time()), ex=effective_ttl)
|
||||
self._log(f"Token cached in Redis with TTL {effective_ttl}s")
|
||||
self.redis_client.set(ADVOWARE_CONFIG.token_cache_key, access_token, ex=effective_ttl)
|
||||
self.redis_client.set(ADVOWARE_CONFIG.token_timestamp_key, str(time.time()), ex=effective_ttl)
|
||||
self.logger.debug(f"Token cached in Redis with TTL {effective_ttl}s")
|
||||
|
||||
return access_token
|
||||
|
||||
@@ -169,32 +175,33 @@ class AdvowareAPI:
|
||||
Returns:
|
||||
Valid access token
|
||||
"""
|
||||
self._log("Getting access token", level='debug')
|
||||
self.logger.debug("Getting access token")
|
||||
|
||||
if not self.redis_client:
|
||||
self._log("No Redis available, fetching new token")
|
||||
self.logger.info("No Redis available, fetching new token")
|
||||
return self._fetch_new_access_token()
|
||||
|
||||
if force_refresh:
|
||||
self._log("Force refresh requested, fetching new token")
|
||||
self.logger.info("Force refresh requested, fetching new token")
|
||||
return self._fetch_new_access_token()
|
||||
|
||||
# Check cache
|
||||
cached_token = self.redis_client.get(self.TOKEN_CACHE_KEY)
|
||||
token_timestamp = self.redis_client.get(self.TOKEN_TIMESTAMP_CACHE_KEY)
|
||||
cached_token = self.redis_client.get(ADVOWARE_CONFIG.token_cache_key)
|
||||
token_timestamp = self.redis_client.get(ADVOWARE_CONFIG.token_timestamp_key)
|
||||
|
||||
if cached_token and token_timestamp:
|
||||
try:
|
||||
timestamp = float(token_timestamp.decode('utf-8'))
|
||||
# Redis decode_responses=True returns strings
|
||||
timestamp = float(token_timestamp)
|
||||
age_seconds = time.time() - timestamp
|
||||
|
||||
if age_seconds < (self.token_lifetime_minutes - 1) * 60:
|
||||
self._log(f"Using cached token (age: {age_seconds:.0f}s)", level='debug')
|
||||
return cached_token.decode('utf-8')
|
||||
except (ValueError, AttributeError) as e:
|
||||
self._log(f"Error reading cached token: {e}", level='debug')
|
||||
self.logger.debug(f"Using cached token (age: {age_seconds:.0f}s)")
|
||||
return cached_token
|
||||
except (ValueError, AttributeError, TypeError) as e:
|
||||
self.logger.debug(f"Error reading cached token: {e}")
|
||||
|
||||
self._log("Cached token expired or invalid, fetching new")
|
||||
self.logger.info("Cached token expired or invalid, fetching new")
|
||||
return self._fetch_new_access_token()
|
||||
|
||||
async def api_call(
|
||||
@@ -223,6 +230,11 @@ class AdvowareAPI:
|
||||
|
||||
Returns:
|
||||
JSON response or None
|
||||
|
||||
Raises:
|
||||
AdvowareAuthError: Authentication failed
|
||||
AdvowareTimeoutError: Request timed out
|
||||
AdvowareAPIError: Other API errors
|
||||
"""
|
||||
# Clean endpoint
|
||||
endpoint = endpoint.lstrip('/')
|
||||
@@ -233,7 +245,12 @@ class AdvowareAPI:
|
||||
)
|
||||
|
||||
# Get auth token
|
||||
token = self.get_access_token()
|
||||
try:
|
||||
token = self.get_access_token()
|
||||
except AdvowareAuthError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise AdvowareAPIError(f"Failed to get access token: {str(e)}")
|
||||
|
||||
# Prepare headers
|
||||
effective_headers = headers.copy() if headers else {}
|
||||
@@ -245,37 +262,75 @@ class AdvowareAPI:
|
||||
|
||||
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
|
||||
try:
|
||||
self._log(f"API call: {method} {url}", level='debug')
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=effective_headers,
|
||||
params=params,
|
||||
json=json_payload
|
||||
) as response:
|
||||
# Handle 401 - retry with fresh token
|
||||
if response.status == 401:
|
||||
self._log("401 Unauthorized, refreshing token")
|
||||
token = self.get_access_token(force_refresh=True)
|
||||
effective_headers['Authorization'] = f'Bearer {token}'
|
||||
with self.logger.api_call(endpoint, method):
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=effective_headers,
|
||||
params=params,
|
||||
json=json_payload
|
||||
) as response:
|
||||
# Handle 401 - retry with fresh token
|
||||
if response.status == 401:
|
||||
self.logger.warning("401 Unauthorized, refreshing token")
|
||||
token = self.get_access_token(force_refresh=True)
|
||||
effective_headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=effective_headers,
|
||||
params=params,
|
||||
json=json_payload
|
||||
) as retry_response:
|
||||
if retry_response.status == 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(
|
||||
f"Server error {retry_response.status}: {error_text}"
|
||||
)
|
||||
|
||||
retry_response.raise_for_status()
|
||||
return await self._parse_response(retry_response)
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=effective_headers,
|
||||
params=params,
|
||||
json=json_payload
|
||||
) as retry_response:
|
||||
retry_response.raise_for_status()
|
||||
return await self._parse_response(retry_response)
|
||||
|
||||
response.raise_for_status()
|
||||
return await self._parse_response(response)
|
||||
|
||||
# Handle other error codes
|
||||
if response.status == 404:
|
||||
error_text = await response.text()
|
||||
raise AdvowareAPIError(
|
||||
f"Resource not found: {endpoint}",
|
||||
status_code=404,
|
||||
response_body=error_text
|
||||
)
|
||||
|
||||
if response.status >= 500:
|
||||
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(
|
||||
f"API error {response.status}: {error_text}",
|
||||
status_code=response.status,
|
||||
response_body=error_text
|
||||
)
|
||||
|
||||
return await self._parse_response(response)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise AdvowareTimeoutError(
|
||||
f"Request timed out after {effective_timeout.total}s",
|
||||
status_code=408
|
||||
)
|
||||
except aiohttp.ClientError as e:
|
||||
self._log(f"API call failed: {e}", level='error')
|
||||
raise
|
||||
self.logger.error(f"API call failed: {e}")
|
||||
raise AdvowareAPIError(f"Request failed: {str(e)}")
|
||||
|
||||
async def _parse_response(self, response: aiohttp.ClientResponse) -> Any:
|
||||
"""Parse API response"""
|
||||
@@ -283,27 +338,6 @@ class AdvowareAPI:
|
||||
try:
|
||||
return await response.json()
|
||||
except Exception as e:
|
||||
self._log(f"JSON parse error: {e}", level='debug')
|
||||
self.logger.debug(f"JSON parse error: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def _log(self, message: str, level: str = 'info'):
|
||||
"""Log message via context or standard logger"""
|
||||
if self.context:
|
||||
if level == 'debug':
|
||||
self.context.logger.debug(message)
|
||||
elif level == 'warning':
|
||||
self.context.logger.warning(message)
|
||||
elif level == 'error':
|
||||
self.context.logger.error(message)
|
||||
else:
|
||||
self.context.logger.info(message)
|
||||
else:
|
||||
if level == 'debug':
|
||||
logger.debug(message)
|
||||
elif level == 'warning':
|
||||
logger.warning(message)
|
||||
elif level == 'error':
|
||||
logger.error(message)
|
||||
else:
|
||||
logger.info(message)
|
||||
|
||||
Reference in New Issue
Block a user