Files
bakery-ia/shared/clients/base_service_client.py

363 lines
15 KiB
Python
Raw Normal View History

2025-07-29 15:08:55 +02:00
# shared/clients/base_service_client.py
"""
Base Service Client for Inter-Service Communication
Provides a reusable foundation for all service-to-service API calls
"""
import time
import asyncio
import httpx
import structlog
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List, Union
from urllib.parse import urljoin
from shared.auth.jwt_handler import JWTHandler
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class ServiceAuthenticator:
"""Handles service-to-service authentication via gateway"""
def __init__(self, service_name: str, config: BaseServiceSettings):
self.service_name = service_name
self.config = config
self.jwt_handler = JWTHandler(config.JWT_SECRET_KEY)
self._cached_token = None
self._token_expires_at = 0
async def get_service_token(self) -> str:
"""Get a valid service token, using cache when possible"""
current_time = int(time.time())
# Return cached token if still valid (with 5 min buffer)
if (self._cached_token and
self._token_expires_at > current_time + 300):
return self._cached_token
# Create new service token
token_expires_at = current_time + 3600 # 1 hour
service_payload = {
"sub": f"{self.service_name}-service",
"user_id": f"{self.service_name}-service",
"email": f"{self.service_name}-service@internal",
"type": "access",
"exp": token_expires_at,
"iat": current_time,
"iss": f"{self.service_name}-service",
"service": self.service_name,
"full_name": f"{self.service_name.title()} Service",
"is_verified": True,
"is_active": True,
"tenant_id": None
}
try:
token = self.jwt_handler.create_access_token_from_payload(service_payload)
self._cached_token = token
self._token_expires_at = token_expires_at
logger.debug("Created new service token", service=self.service_name, expires_at=token_expires_at)
return token
except Exception as e:
logger.error(f"Failed to create service token: {e}", service=self.service_name)
raise ValueError(f"Service token creation failed: {e}")
def get_request_headers(self, tenant_id: Optional[str] = None) -> Dict[str, str]:
"""Get standard headers for service requests"""
headers = {
"X-Service": f"{self.service_name}-service",
"User-Agent": f"{self.service_name}-service/1.0.0"
}
if tenant_id:
headers["X-Tenant-ID"] = str(tenant_id)
return headers
class BaseServiceClient(ABC):
"""
Base class for all inter-service communication clients
Provides common functionality for API calls through the gateway
"""
def __init__(self, service_name: str, config: BaseServiceSettings):
self.service_name = service_name
self.config = config
self.gateway_url = config.GATEWAY_URL
self.authenticator = ServiceAuthenticator(service_name, config)
# HTTP client configuration
self.timeout = config.HTTP_TIMEOUT
self.retries = config.HTTP_RETRIES
self.retry_delay = config.HTTP_RETRY_DELAY
@abstractmethod
def get_service_base_path(self) -> str:
"""Return the base path for this service's APIs"""
pass
async def _make_request(
self,
method: str,
endpoint: str,
tenant_id: Optional[str] = None,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]:
"""
Make an authenticated request to another service via gateway
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (will be prefixed with service base path)
tenant_id: Optional tenant ID for tenant-scoped requests
data: Request body data (for POST/PUT)
params: Query parameters
headers: Additional headers
timeout: Request timeout override
Returns:
Response data or None if request failed
"""
try:
# Get service token
token = await self.authenticator.get_service_token()
# Build headers
request_headers = self.authenticator.get_request_headers(tenant_id)
request_headers["Authorization"] = f"Bearer {token}"
request_headers["Content-Type"] = "application/json"
if headers:
request_headers.update(headers)
# Build URL
base_path = self.get_service_base_path()
if tenant_id:
# For tenant-scoped endpoints
full_endpoint = f"{base_path}/tenants/{tenant_id}/{endpoint.lstrip('/')}"
else:
# For non-tenant endpoints
full_endpoint = f"{base_path}/{endpoint.lstrip('/')}"
url = urljoin(self.gateway_url, full_endpoint)
# Make request with retries
for attempt in range(self.retries + 1):
try:
# Handle different timeout configurations
if isinstance(timeout, httpx.Timeout):
client_timeout = timeout
else:
client_timeout = timeout or self.timeout
async with httpx.AsyncClient(timeout=client_timeout) as client:
response = await client.request(
method=method,
url=url,
json=data,
params=params,
headers=request_headers
)
if response.status_code == 200:
return response.json()
elif response.status_code == 201:
return response.json()
elif response.status_code == 204:
return {} # No content success
elif response.status_code == 401:
# Token might be expired, clear cache and retry once
if attempt == 0:
self.authenticator._cached_token = None
logger.warning("Token expired, retrying with new token")
continue
else:
logger.error("Authentication failed after retry")
return None
elif response.status_code == 404:
logger.warning(f"Endpoint not found: {url}")
return None
else:
error_detail = "Unknown error"
try:
error_json = response.json()
error_detail = error_json.get('detail', f"HTTP {response.status_code}")
except:
error_detail = f"HTTP {response.status_code}: {response.text}"
logger.error(f"Request failed: {error_detail}",
url=url, status_code=response.status_code)
return None
except httpx.TimeoutException:
if attempt < self.retries:
logger.warning(f"Request timeout, retrying ({attempt + 1}/{self.retries})")
import asyncio
await asyncio.sleep(self.retry_delay * (2 ** attempt)) # Exponential backoff
continue
else:
logger.error(f"Request timeout after {self.retries} retries", url=url)
return None
except Exception as e:
if attempt < self.retries:
logger.warning(f"Request failed, retrying ({attempt + 1}/{self.retries}): {e}")
import asyncio
await asyncio.sleep(self.retry_delay * (2 ** attempt))
continue
else:
logger.error(f"Request failed after {self.retries} retries: {e}", url=url)
return None
except Exception as e:
logger.error(f"Unexpected error in _make_request: {e}")
return None
async def _make_paginated_request(
self,
endpoint: str,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
page_size: int = 5000,
max_pages: int = 100,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> List[Dict[str, Any]]:
"""
Make paginated GET requests to fetch all records
Handles both direct list and paginated object responses
Args:
endpoint: API endpoint
tenant_id: Optional tenant ID
params: Base query parameters
page_size: Records per page (default 5000)
max_pages: Maximum pages to fetch (safety limit)
timeout: Request timeout override
Returns:
List of all records from all pages
"""
all_records = []
page = 0
base_params = params or {}
logger.info(f"Starting paginated request to {endpoint}",
tenant_id=tenant_id, page_size=page_size)
while page < max_pages:
# Prepare pagination parameters
page_params = base_params.copy()
page_params.update({
"limit": page_size,
"offset": page * page_size
})
logger.debug(f"Fetching page {page + 1} (offset: {page * page_size})",
tenant_id=tenant_id)
# Make request for this page
result = await self._make_request(
"GET",
endpoint,
tenant_id=tenant_id,
params=page_params,
timeout=timeout
)
if result is None:
logger.error(f"Failed to fetch page {page + 1}", tenant_id=tenant_id)
break
# Handle different response formats
if isinstance(result, list):
# Direct list response (no pagination metadata)
records = result
logger.debug(f"Retrieved {len(records)} records from page {page + 1} (direct list)")
if len(records) == 0:
logger.info("No records in response, pagination complete")
break
elif len(records) < page_size:
# Got fewer than requested, this is the last page
all_records.extend(records)
logger.info(f"Final page: retrieved {len(records)} records, total: {len(all_records)}")
break
else:
# Got full page, there might be more
all_records.extend(records)
logger.debug(f"Full page retrieved: {len(records)} records, continuing to next page")
elif isinstance(result, dict):
# Paginated response format
records = result.get('records', result.get('data', []))
total_available = result.get('total', 0)
logger.debug(f"Retrieved {len(records)} records from page {page + 1} (paginated response)")
if not records:
logger.info("No more records found in paginated response")
break
all_records.extend(records)
# Check if we've got all available records
if len(all_records) >= total_available:
logger.info(f"Retrieved all available records: {len(all_records)}/{total_available}")
break
else:
logger.warning(f"Unexpected response format: {type(result)}")
break
page += 1
if page >= max_pages:
logger.warning(f"Reached maximum page limit ({max_pages}), stopping pagination")
logger.info(f"Pagination complete: fetched {len(all_records)} total records",
tenant_id=tenant_id, pages_fetched=page)
return all_records
async def get(self, endpoint: str, tenant_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a GET request"""
return await self._make_request("GET", endpoint, tenant_id=tenant_id, params=params)
async def get_paginated(
self,
endpoint: str,
tenant_id: Optional[str] = None,
params: Optional[Dict[str, Any]] = None,
page_size: int = 5000,
max_pages: int = 100,
timeout: Optional[Union[int, httpx.Timeout]] = None
) -> List[Dict[str, Any]]:
"""Make a paginated GET request to fetch all records"""
return await self._make_paginated_request(
endpoint,
tenant_id=tenant_id,
params=params,
page_size=page_size,
max_pages=max_pages,
timeout=timeout
)
async def post(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a POST request"""
return await self._make_request("POST", endpoint, tenant_id=tenant_id, data=data)
async def put(self, endpoint: str, data: Dict[str, Any], tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a PUT request"""
return await self._make_request("PUT", endpoint, tenant_id=tenant_id, data=data)
async def delete(self, endpoint: str, tenant_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Make a DELETE request"""
return await self._make_request("DELETE", endpoint, tenant_id=tenant_id)