feat: Add SaaS robustness middleware - Rate limiting with token bucket and sliding window algorithms - Input validation (file, language, provider) - Security headers middleware (CSP, XSS protection, etc.) - Automatic file cleanup with TTL tracking - Memory and disk monitoring - Enhanced health check and metrics endpoints - Request logging with unique IDs

This commit is contained in:
2025-11-30 19:25:09 +01:00
parent 8c7716bf4d
commit 500502440c
9 changed files with 1681 additions and 26 deletions

216
main.py
View File

@@ -1,24 +1,55 @@
"""
Document Translation API
FastAPI application for translating complex documents while preserving formatting
SaaS-ready with rate limiting, validation, and robust error handling
"""
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import asyncio
import logging
import os
from config import config
from translators import excel_translator, word_translator, pptx_translator
from utils import file_handler, handle_translation_error, DocumentProcessingError
# Configure logging
logging.basicConfig(level=logging.INFO)
# Import SaaS middleware
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
from middleware.security import SecurityHeadersMiddleware, RequestLoggingMiddleware, ErrorHandlingMiddleware
from middleware.cleanup import FileCleanupManager, MemoryMonitor, HealthChecker, create_cleanup_manager
from middleware.validation import FileValidator, LanguageValidator, ProviderValidator, InputSanitizer, ValidationError
# Configure structured logging
logging.basicConfig(
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Initialize SaaS components
rate_limit_config = RateLimitConfig(
requests_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")),
requests_per_hour=int(os.getenv("RATE_LIMIT_PER_HOUR", "200")),
translations_per_minute=int(os.getenv("TRANSLATIONS_PER_MINUTE", "10")),
translations_per_hour=int(os.getenv("TRANSLATIONS_PER_HOUR", "50")),
max_concurrent_translations=int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5")),
)
rate_limit_manager = RateLimitManager(rate_limit_config)
cleanup_manager = create_cleanup_manager(config)
memory_monitor = MemoryMonitor(max_memory_percent=float(os.getenv("MAX_MEMORY_PERCENT", "80")))
health_checker = HealthChecker(cleanup_manager, memory_monitor)
file_validator = FileValidator(
max_size_mb=config.MAX_FILE_SIZE_MB,
allowed_extensions=config.SUPPORTED_EXTENSIONS
)
def build_full_prompt(system_prompt: str, glossary: str) -> str:
"""Combine system prompt and glossary into a single prompt for LLM translation."""
@@ -40,23 +71,47 @@ Always use the translations from this glossary when you encounter these terms.""
return "\n\n".join(parts) if parts else ""
# Ensure necessary directories exist
config.ensure_directories()
# Lifespan context manager for startup/shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle startup and shutdown events"""
# Startup
logger.info("Starting Document Translation API...")
config.ensure_directories()
await cleanup_manager.start()
logger.info("API ready to accept requests")
yield
# Shutdown
logger.info("Shutting down...")
await cleanup_manager.stop()
logger.info("Cleanup completed")
# Create FastAPI app
# Create FastAPI app with lifespan
app = FastAPI(
title=config.API_TITLE,
version=config.API_VERSION,
description=config.API_DESCRIPTION
description=config.API_DESCRIPTION,
lifespan=lifespan
)
# Add CORS middleware
# Add middleware (order matters - first added is outermost)
app.add_middleware(ErrorHandlingMiddleware)
app.add_middleware(RequestLoggingMiddleware, log_body=False)
app.add_middleware(SecurityHeadersMiddleware, config={"enable_hsts": os.getenv("ENABLE_HSTS", "false").lower() == "true"})
app.add_middleware(RateLimitMiddleware, rate_limit_manager=rate_limit_manager)
# CORS - configure for production
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["X-Request-ID", "X-Original-Filename", "X-File-Size-MB", "X-Target-Language"]
)
# Mount static files
@@ -65,6 +120,20 @@ if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Custom exception handler for ValidationError
@app.exception_handler(ValidationError)
async def validation_error_handler(request: Request, exc: ValidationError):
"""Handle validation errors with user-friendly messages"""
return JSONResponse(
status_code=400,
content={
"error": exc.code,
"message": exc.message,
"details": exc.details
}
)
@app.get("/")
async def root():
"""Root endpoint with API information"""
@@ -83,11 +152,24 @@ async def root():
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"translation_service": config.TRANSLATION_SERVICE
}
"""Health check endpoint with detailed system status"""
health_status = await health_checker.check_health()
status_code = 200 if health_status.get("status") == "healthy" else 503
return JSONResponse(
status_code=status_code,
content={
"status": health_status.get("status", "unknown"),
"translation_service": config.TRANSLATION_SERVICE,
"memory": health_status.get("memory", {}),
"disk": health_status.get("disk", {}),
"cleanup_service": health_status.get("cleanup_service", {}),
"rate_limits": {
"requests_per_minute": rate_limit_config.requests_per_minute,
"translations_per_minute": rate_limit_config.translations_per_minute,
}
}
)
@app.get("/languages")
@@ -128,6 +210,7 @@ async def get_supported_languages():
@app.post("/translate")
async def translate_document(
request: Request,
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
@@ -160,11 +243,38 @@ async def translate_document(
"""
input_path = None
output_path = None
request_id = getattr(request.state, 'request_id', 'unknown')
try:
# Validate inputs
sanitized_language = InputSanitizer.sanitize_language_code(target_language)
LanguageValidator.validate(sanitized_language)
ProviderValidator.validate(provider)
# Validate file before processing
validation_result = await file_validator.validate_async(file)
if not validation_result.is_valid:
raise ValidationError(
message=f"File validation failed: {'; '.join(validation_result.errors)}",
code="INVALID_FILE",
details={"errors": validation_result.errors, "warnings": validation_result.warnings}
)
# Log any warnings
if validation_result.warnings:
logger.warning(f"[{request_id}] File validation warnings: {validation_result.warnings}")
# Check rate limit for translations
client_ip = request.client.host if request.client else "unknown"
if not await rate_limit_manager.check_translation_limit(client_ip):
raise HTTPException(
status_code=429,
detail="Translation rate limit exceeded. Please try again later."
)
# Validate file extension
file_extension = file_handler.validate_file_extension(file.filename)
logger.info(f"Processing {file_extension} file: {file.filename}")
logger.info(f"[{request_id}] Processing {file_extension} file: {file.filename}")
# Validate file size
file_handler.validate_file_size(file)
@@ -178,7 +288,11 @@ async def translate_document(
output_path = config.OUTPUT_DIR / output_filename
await file_handler.save_upload_file(file, input_path)
logger.info(f"Saved input file to: {input_path}")
logger.info(f"[{request_id}] Saved input file to: {input_path}")
# Track file for cleanup
await cleanup_manager.track_file(input_path, ttl_minutes=30)
await cleanup_manager.track_file(output_path, ttl_minutes=60)
# Configure translation provider
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, translation_service
@@ -657,6 +771,74 @@ async def reconstruct_document(
raise HTTPException(status_code=500, detail=f"Failed to reconstruct document: {str(e)}")
# ============== SaaS Management Endpoints ==============
@app.get("/metrics")
async def get_metrics():
"""Get system metrics and statistics for monitoring"""
health_status = await health_checker.check_health()
cleanup_stats = cleanup_manager.get_stats()
rate_limit_stats = rate_limit_manager.get_stats()
return {
"system": {
"memory": health_status.get("memory", {}),
"disk": health_status.get("disk", {}),
"status": health_status.get("status", "unknown")
},
"cleanup": cleanup_stats,
"rate_limits": rate_limit_stats,
"config": {
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
"translation_service": config.TRANSLATION_SERVICE
}
}
@app.get("/rate-limit/status")
async def get_rate_limit_status(request: Request):
"""Get current rate limit status for the requesting client"""
client_ip = request.client.host if request.client else "unknown"
status = await rate_limit_manager.get_client_status(client_ip)
return {
"client_ip": client_ip,
"limits": {
"requests_per_minute": rate_limit_config.requests_per_minute,
"requests_per_hour": rate_limit_config.requests_per_hour,
"translations_per_minute": rate_limit_config.translations_per_minute,
"translations_per_hour": rate_limit_config.translations_per_hour
},
"current_usage": status
}
@app.post("/admin/cleanup/trigger")
async def trigger_cleanup():
"""Trigger manual cleanup of expired files"""
try:
cleaned = await cleanup_manager.cleanup_expired()
return {
"status": "success",
"files_cleaned": cleaned,
"message": f"Cleaned up {cleaned} expired files"
}
except Exception as e:
logger.error(f"Manual cleanup failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Cleanup failed: {str(e)}")
@app.get("/admin/files/tracked")
async def get_tracked_files():
"""Get list of currently tracked files"""
tracked = cleanup_manager.get_tracked_files()
return {
"count": len(tracked),
"files": tracked
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)