feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
345
services/providers/fallback.py
Normal file
345
services/providers/fallback.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
_HAS_STRUCTLOG = True
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_HAS_STRUCTLOG = False
|
||||
|
||||
|
||||
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]:
|
||||
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
|
||||
Reference in New Issue
Block a user