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:
bsiggel
2026-03-03 17:18:49 +00:00
parent bcb6454b2a
commit 69a48f5f9a
12 changed files with 2118 additions and 321 deletions

View File

@@ -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)