346 lines
11 KiB
Python
346 lines
11 KiB
Python
"""
|
|
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
|