Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
12 KiB
Python
375 lines
12 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
|
||
|
||
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
|