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

@@ -2,23 +2,23 @@
import aiohttp
import asyncio
import logging
import redis
import os
from typing import Optional, Dict, Any, List
import os
from services.exceptions import (
EspoCRMAPIError,
EspoCRMAuthError,
EspoCRMTimeoutError,
RetryableError,
ValidationError
)
from services.redis_client import get_redis_client
from services.config import ESPOCRM_CONFIG, API_CONFIG
from services.logging_utils import get_service_logger
logger = logging.getLogger(__name__)
class EspoCRMError(Exception):
"""Base exception for EspoCRM API errors"""
pass
class EspoCRMAuthError(EspoCRMError):
"""Authentication error"""
pass
class EspoCRMAPI:
"""
EspoCRM API Client for BitByLaw integration.
@@ -32,7 +32,6 @@ class EspoCRMAPI:
- ESPOCRM_API_BASE_URL (e.g., https://crm.bitbylaw.com/api/v1)
- ESPOCRM_API_KEY (Marvin API key)
- ESPOCRM_API_TIMEOUT_SECONDS (optional, default: 30)
- REDIS_HOST, REDIS_PORT, REDIS_DB_ADVOWARE_CACHE (for caching)
"""
def __init__(self, context=None):
@@ -43,47 +42,25 @@ class EspoCRMAPI:
context: Motia FlowContext for logging (optional)
"""
self.context = context
self._log("EspoCRMAPI initializing", level='debug')
self.logger = get_service_logger('espocrm', context)
self.logger.debug("EspoCRMAPI initializing")
# Load configuration from environment
self.api_base_url = os.getenv('ESPOCRM_API_BASE_URL', 'https://crm.bitbylaw.com/api/v1')
self.api_key = os.getenv('ESPOCRM_API_KEY', '')
self.api_timeout_seconds = int(os.getenv('ESPOCRM_API_TIMEOUT_SECONDS', '30'))
self.api_timeout_seconds = int(os.getenv('ESPOCRM_API_TIMEOUT_SECONDS', str(API_CONFIG.default_timeout_seconds)))
if not self.api_key:
raise EspoCRMAuthError("ESPOCRM_API_KEY not configured in environment")
self._log(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}")
# Optional Redis for caching/rate limiting
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,
decode_responses=True
)
self.redis_client.ping()
self._log("Connected to Redis for EspoCRM operations")
except Exception as e:
self._log(f"Could not connect to Redis: {e}. Continuing without caching.", level='warning')
self.redis_client = None
def _log(self, message: str, level: str = 'info'):
"""Log message via context.logger if available, otherwise use module logger"""
if self.context and hasattr(self.context, 'logger'):
log_func = getattr(self.context.logger, level, self.context.logger.info)
log_func(f"[EspoCRM] {message}")
# Optional Redis for caching/rate limiting (centralized)
self.redis_client = get_redis_client(strict=False)
if self.redis_client:
self.logger.info("Connected to Redis for EspoCRM operations")
else:
log_func = getattr(logger, level, logger.info)
log_func(f"[EspoCRM] {message}")
self.logger.warning("⚠️ Redis unavailable - caching disabled")
def _get_headers(self) -> Dict[str, str]:
"""Generate request headers with API key"""
@@ -115,7 +92,9 @@ class EspoCRMAPI:
Parsed JSON response or None
Raises:
EspoCRMError: On API errors
EspoCRMAuthError: Authentication failed
EspoCRMTimeoutError: Request timed out
EspoCRMAPIError: Other API errors
"""
# Ensure endpoint starts with /
if not endpoint.startswith('/'):
@@ -127,45 +106,61 @@ class EspoCRMAPI:
total=timeout_seconds or self.api_timeout_seconds
)
self._log(f"API call: {method} {url}", level='debug')
if params:
self._log(f"Params: {params}", level='debug')
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
try:
async with session.request(
method,
url,
headers=headers,
params=params,
json=json_data
) as response:
# Log response status
self._log(f"Response status: {response.status}", level='debug')
# Handle errors
if response.status == 401:
raise EspoCRMAuthError("Authentication failed - check API key")
elif response.status == 403:
raise EspoCRMError("Access forbidden")
elif response.status == 404:
raise EspoCRMError(f"Resource not found: {endpoint}")
elif response.status >= 400:
error_text = await response.text()
raise EspoCRMError(f"API error {response.status}: {error_text}")
# Parse response
if response.content_type == 'application/json':
result = await response.json()
self._log(f"Response received", level='debug')
return result
else:
# For DELETE or other non-JSON responses
return None
with self.logger.api_call(endpoint, method):
async with session.request(
method,
url,
headers=headers,
params=params,
json=json_data
) as response:
# Handle errors
if response.status == 401:
raise EspoCRMAuthError(
"Authentication failed - check API key",
status_code=401
)
elif response.status == 403:
raise EspoCRMAPIError(
"Access forbidden",
status_code=403
)
elif response.status == 404:
raise EspoCRMAPIError(
f"Resource not found: {endpoint}",
status_code=404
)
elif response.status >= 500:
error_text = await response.text()
raise RetryableError(
f"Server error {response.status}: {error_text}"
)
elif response.status >= 400:
error_text = await response.text()
raise EspoCRMAPIError(
f"API error {response.status}: {error_text}",
status_code=response.status,
response_body=error_text
)
# Parse response
if response.content_type == 'application/json':
result = await response.json()
return result
else:
# For DELETE or other non-JSON responses
return None
except asyncio.TimeoutError:
raise EspoCRMTimeoutError(
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 EspoCRMError(f"Request failed: {e}") from 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]:
"""