""" Fallback Translation Service - Provider fallback chain implementation. Provides automatic fallback between translation providers when one fails, ensuring translation remains available even if individual providers are down. Features: - Try providers in order until one succeeds - Return structured error when all providers fail - Log failed attempts and successful provider - Never expose HTTP 500 or document content """ from typing import List, Optional, Dict, Any import time from core.logging import get_logger logger = get_logger(__name__) _HAS_STRUCTLOG = True def _log_info(event: str, **kwargs): """Log info with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.info(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.info(msg) def _log_warning(event: str, **kwargs): """Log warning with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.warning(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.warning(msg) def _log_error(event: str, **kwargs): """Log error with structlog or standard logging compatibility.""" if _HAS_STRUCTLOG: logger.error(event, **kwargs) else: msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items()) logger.error(msg) from .registry import registry from .schemas import TranslationRequest, TranslationResponse # Error code for when all providers fail ALL_PROVIDERS_FAILED = "ALL_PROVIDERS_FAILED" class AllProvidersFailedError(Exception): """ Exception raised when all providers in the fallback chain fail. This exception is used to signal that no provider could successfully translate the text, and includes details about which providers were tried and what errors occurred. """ def __init__( self, message: str = "Tous les fournisseurs de traduction ont échoué.", providers_tried: Optional[List[str]] = None, errors: Optional[List[Dict[str, Any]]] = None, ): self.code = ALL_PROVIDERS_FAILED self.message = message self.providers_tried = providers_tried or [] self.errors = errors or [] super().__init__(message) def to_dict(self) -> Dict[str, Any]: """Convert error to dictionary format for API responses.""" result = { "error": self.code, "message": self.message, "details": { "providers_tried": self.providers_tried, "error_count": len(self.errors), }, } if self.errors: # Include last error details (without sensitive info) last_error = self.errors[-1] result["details"]["last_error"] = { "provider": last_error.get("provider"), "error_code": last_error.get("error_code"), "message": last_error.get("message", "")[:200], # Truncate } return result def translate_with_fallback( request: TranslationRequest, provider_names: List[str], skip_unavailable: bool = True, ) -> TranslationResponse: """ Translate text using a fallback chain of providers. Iterates through the list of provider names in order, attempting to translate with each one. Returns the first successful translation. If all providers fail, raises AllProvidersFailedError. Args: request: TranslationRequest with text and language info provider_names: Ordered list of provider names to try skip_unavailable: If True, skip providers that are not available (health check fails). If False, try anyway. Returns: TranslationResponse with translated text and provider_name set to the successful provider. Raises: AllProvidersFailedError: When all providers in the chain fail Example: >>> request = TranslationRequest(text="Hello", target_language="fr") >>> response = translate_with_fallback( ... request, ["google", "deepl", "openai"] ... ) >>> print(response.translated_text) # "Bonjour" >>> print(response.provider_name) # "deepl" (first that succeeded) """ if not provider_names: raise AllProvidersFailedError( message="Aucun fournisseur configuré dans la chaîne de fallback.", providers_tried=[], ) providers_tried: List[str] = [] errors: List[Dict[str, Any]] = [] _log_info( "fallback_translation_started", providers=provider_names, source_lang=request.source_language, target_lang=request.target_language, text_length=len(request.text), ) for provider_name in provider_names: # Get provider from registry provider = registry.get(provider_name) if provider is None: _log_warning( "fallback_provider_not_registered", provider=provider_name, ) errors.append( { "provider": provider_name, "error_code": "PROVIDER_NOT_REGISTERED", "message": f"Provider '{provider_name}' not registered", } ) providers_tried.append(provider_name) continue # Check availability if requested if skip_unavailable and not provider.is_available(): _log_info( "fallback_provider_unavailable", provider=provider_name, ) errors.append( { "provider": provider_name, "error_code": "PROVIDER_UNAVAILABLE", "message": f"Provider '{provider_name}' is not available", } ) providers_tried.append(provider_name) continue # Try to translate start_time = time.time() try: response = provider.translate_text(request) latency_ms = (time.time() - start_time) * 1000 # Check if translation succeeded if response.error is None: # Success! _log_info( "fallback_translation_success", provider=provider_name, latency_ms=round(latency_ms, 2), attempts=len(providers_tried) + 1, text_length=len(request.text), source_lang=request.source_language, target_lang=request.target_language, ) # Ensure provider_name is set if not response.provider_name: response.provider_name = provider_name return response else: # Provider returned an error _log_warning( "fallback_provider_error", provider=provider_name, error_code=response.error_code, error_message=response.error[:200], # Truncate ) errors.append( { "provider": provider_name, "error_code": response.error_code, "message": response.error, } ) providers_tried.append(provider_name) except Exception as e: # Provider raised an exception latency_ms = (time.time() - start_time) * 1000 error_str = str(e) _log_error( "fallback_provider_exception", provider=provider_name, error_type=type(e).__name__, latency_ms=round(latency_ms, 2), ) errors.append( { "provider": provider_name, "error_code": "PROVIDER_EXCEPTION", "message": error_str[:200], # Truncate } ) providers_tried.append(provider_name) # All providers failed _log_error( "fallback_all_providers_failed", providers_tried=providers_tried, error_count=len(errors), text_length=len(request.text), source_lang=request.source_language, target_lang=request.target_language, ) raise AllProvidersFailedError( message="Tous les fournisseurs de traduction ont échoué. Veuillez réessayer plus tard.", providers_tried=providers_tried, errors=errors, ) def translate_with_fallback_by_mode( request: TranslationRequest, mode: str = "auto", ) -> TranslationResponse: """ Translate text using the fallback chain for a specific mode. Args: request: TranslationRequest with text and language info mode: "classic" for Classic providers, "llm" for LLM providers, "auto" for general fallback chain Returns: TranslationResponse with translated text Raises: AllProvidersFailedError: When all providers fail """ from .config import ProvidersConfig provider_names = ProvidersConfig.get_fallback_chain(mode) if not provider_names: raise AllProvidersFailedError( message=f"Aucune chaîne de fallback configurée pour le mode '{mode}'.", providers_tried=[], ) return translate_with_fallback(request, provider_names) class LegacyFallbackAdapter: """ Exposes the fallback chain via the legacy interface used by translation_service: .translate(text, target_lang, source_lang) -> str and .translate_batch(texts, target_lang, source_lang) -> List[str]. Raises AllProvidersFailedError when all providers fail (API returns 502). """ def __init__(self, mode: str = "classic"): """ Args: mode: "classic" (Google → DeepL) or "llm" (Ollama → OpenAI) """ self._mode = mode.lower() self.provider_name = f"fallback_{self._mode}" self._last_provider_used: Optional[str] = None def translate( self, text: str, target_language: str, source_language: str = "auto" ) -> str: req = TranslationRequest( text=text, target_language=target_language, source_language=source_language, ) response = translate_with_fallback_by_mode(req, self._mode) self._last_provider_used = response.provider_name or self._last_provider_used return response.translated_text def translate_batch( self, texts: List[str], target_language: str, source_language: str = "auto", batch_size: int = 50, ) -> List[str]: if not texts: return [] from .config import ProvidersConfig provider_names = ProvidersConfig.get_fallback_chain(self._mode) requests = [ TranslationRequest( text=t, target_language=target_language, source_language=source_language, ) for t in texts ] # Try each provider once with a full batch (avoids N× fallback chain walks). for provider_name in provider_names: p = registry.get(provider_name) if p is None: continue if not p.is_available(): continue try: responses = p.translate_batch(requests) except Exception: continue if len(responses) != len(requests): continue if all(r.error is None for r in responses): self._last_provider_used = provider_name return [ r.translated_text if r.translated_text is not None else texts[i] for i, r in enumerate(responses) ] results: List[str] = [] for t in texts: req = TranslationRequest( text=t, target_language=target_language, source_language=source_language, ) response = translate_with_fallback_by_mode(req, self._mode) self._last_provider_used = response.provider_name or self._last_provider_used results.append(response.translated_text) return results