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:
@@ -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]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user