fix test and add doc

This commit is contained in:
2025-10-22 11:30:04 +02:00
parent 1637835fe6
commit 9692dfc63b
13 changed files with 3562 additions and 146 deletions

View File

@@ -0,0 +1,255 @@
"""
Custom exception classes for Aniworld API layer.
This module defines exception hierarchy for the web API with proper
HTTP status code mappings and error handling.
"""
from typing import Any, Dict, Optional
class AniWorldAPIException(Exception):
"""
Base exception for Aniworld API.
All API-specific exceptions inherit from this class.
"""
def __init__(
self,
message: str,
status_code: int = 500,
error_code: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
"""
Initialize API exception.
Args:
message: Human-readable error message
status_code: HTTP status code for response
error_code: Machine-readable error identifier
details: Additional error details and context
"""
self.message = message
self.status_code = status_code
self.error_code = error_code or self.__class__.__name__
self.details = details or {}
super().__init__(self.message)
def to_dict(self) -> Dict[str, Any]:
"""
Convert exception to dictionary for JSON response.
Returns:
Dictionary containing error information
"""
return {
"error": self.error_code,
"message": self.message,
"details": self.details,
}
class AuthenticationError(AniWorldAPIException):
"""Exception raised when authentication fails."""
def __init__(
self,
message: str = "Authentication failed",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize authentication error."""
super().__init__(
message=message,
status_code=401,
error_code="AUTHENTICATION_ERROR",
details=details,
)
class AuthorizationError(AniWorldAPIException):
"""Exception raised when user lacks required permissions."""
def __init__(
self,
message: str = "Insufficient permissions",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize authorization error."""
super().__init__(
message=message,
status_code=403,
error_code="AUTHORIZATION_ERROR",
details=details,
)
class ValidationError(AniWorldAPIException):
"""Exception raised when request validation fails."""
def __init__(
self,
message: str = "Request validation failed",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize validation error."""
super().__init__(
message=message,
status_code=422,
error_code="VALIDATION_ERROR",
details=details,
)
class NotFoundError(AniWorldAPIException):
"""Exception raised when resource is not found."""
def __init__(
self,
message: str = "Resource not found",
resource_type: Optional[str] = None,
resource_id: Optional[Any] = None,
details: Optional[Dict[str, Any]] = None,
):
"""Initialize not found error."""
if details is None:
details = {}
if resource_type:
details["resource_type"] = resource_type
if resource_id:
details["resource_id"] = resource_id
super().__init__(
message=message,
status_code=404,
error_code="NOT_FOUND",
details=details,
)
class ConflictError(AniWorldAPIException):
"""Exception raised when resource conflict occurs."""
def __init__(
self,
message: str = "Resource conflict",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize conflict error."""
super().__init__(
message=message,
status_code=409,
error_code="CONFLICT",
details=details,
)
class RateLimitError(AniWorldAPIException):
"""Exception raised when rate limit is exceeded."""
def __init__(
self,
message: str = "Rate limit exceeded",
retry_after: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
):
"""Initialize rate limit error."""
if details is None:
details = {}
if retry_after:
details["retry_after"] = retry_after
super().__init__(
message=message,
status_code=429,
error_code="RATE_LIMIT_EXCEEDED",
details=details,
)
class ServerError(AniWorldAPIException):
"""Exception raised for internal server errors."""
def __init__(
self,
message: str = "Internal server error",
error_code: str = "INTERNAL_SERVER_ERROR",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize server error."""
super().__init__(
message=message,
status_code=500,
error_code=error_code,
details=details,
)
class DownloadError(ServerError):
"""Exception raised when download operation fails."""
def __init__(
self,
message: str = "Download failed",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize download error."""
super().__init__(
message=message,
error_code="DOWNLOAD_ERROR",
details=details,
)
class ConfigurationError(ServerError):
"""Exception raised when configuration is invalid."""
def __init__(
self,
message: str = "Configuration error",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize configuration error."""
super().__init__(
message=message,
error_code="CONFIGURATION_ERROR",
details=details,
)
class ProviderError(ServerError):
"""Exception raised when provider operation fails."""
def __init__(
self,
message: str = "Provider error",
provider_name: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
"""Initialize provider error."""
if details is None:
details = {}
if provider_name:
details["provider"] = provider_name
super().__init__(
message=message,
error_code="PROVIDER_ERROR",
details=details,
)
class DatabaseError(ServerError):
"""Exception raised when database operation fails."""
def __init__(
self,
message: str = "Database error",
details: Optional[Dict[str, Any]] = None,
):
"""Initialize database error."""
super().__init__(
message=message,
error_code="DATABASE_ERROR",
details=details,
)

View File

@@ -0,0 +1,35 @@
"""
Exceptions module for Aniworld server API.
This module provides custom exception classes for the web API layer
with proper HTTP status code mappings.
"""
from src.server.exceptions import (
AniWorldAPIException,
AuthenticationError,
AuthorizationError,
ConfigurationError,
ConflictError,
DatabaseError,
DownloadError,
NotFoundError,
ProviderError,
RateLimitError,
ServerError,
ValidationError,
)
__all__ = [
"AniWorldAPIException",
"AuthenticationError",
"AuthorizationError",
"ValidationError",
"NotFoundError",
"ConflictError",
"RateLimitError",
"ServerError",
"DownloadError",
"ConfigurationError",
"ProviderError",
"DatabaseError",
]

View File

@@ -31,6 +31,7 @@ from src.server.controllers.error_controller import (
from src.server.controllers.health_controller import router as health_router
from src.server.controllers.page_controller import router as page_router
from src.server.middleware.auth import AuthMiddleware
from src.server.middleware.error_handler import register_exception_handlers
from src.server.services.progress_service import get_progress_service
from src.server.services.websocket_service import get_websocket_service
@@ -68,6 +69,9 @@ app.include_router(anime_router)
app.include_router(download_router)
app.include_router(websocket_router)
# Register exception handlers
register_exception_handlers(app)
# Global variables for application state
series_app: Optional[SeriesApp] = None

View File

@@ -0,0 +1,236 @@
"""
Global exception handlers for FastAPI application.
This module provides centralized error handling that converts custom
exceptions to structured JSON responses with appropriate HTTP status codes.
"""
import logging
import traceback
from typing import Any, Dict
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from src.server.exceptions import (
AniWorldAPIException,
AuthenticationError,
AuthorizationError,
ConflictError,
NotFoundError,
RateLimitError,
ValidationError,
)
logger = logging.getLogger(__name__)
def create_error_response(
status_code: int,
error: str,
message: str,
details: Dict[str, Any] | None = None,
request_id: str | None = None,
) -> Dict[str, Any]:
"""
Create standardized error response.
Args:
status_code: HTTP status code
error: Error code/type
message: Human-readable error message
details: Additional error details
request_id: Unique request identifier for tracking
Returns:
Dictionary containing structured error response
"""
response = {
"success": False,
"error": error,
"message": message,
}
if details:
response["details"] = details
if request_id:
response["request_id"] = request_id
return response
def register_exception_handlers(app: FastAPI) -> None:
"""
Register all exception handlers with FastAPI app.
Args:
app: FastAPI application instance
"""
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(
request: Request, exc: AuthenticationError
) -> JSONResponse:
"""Handle authentication errors (401)."""
logger.warning(
f"Authentication error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(AuthorizationError)
async def authorization_error_handler(
request: Request, exc: AuthorizationError
) -> JSONResponse:
"""Handle authorization errors (403)."""
logger.warning(
f"Authorization error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(ValidationError)
async def validation_error_handler(
request: Request, exc: ValidationError
) -> JSONResponse:
"""Handle validation errors (422)."""
logger.info(
f"Validation error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(NotFoundError)
async def not_found_error_handler(
request: Request, exc: NotFoundError
) -> JSONResponse:
"""Handle not found errors (404)."""
logger.info(
f"Not found error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(ConflictError)
async def conflict_error_handler(
request: Request, exc: ConflictError
) -> JSONResponse:
"""Handle conflict errors (409)."""
logger.info(
f"Conflict error: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(RateLimitError)
async def rate_limit_error_handler(
request: Request, exc: RateLimitError
) -> JSONResponse:
"""Handle rate limit errors (429)."""
logger.warning(
f"Rate limit exceeded: {exc.message}",
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(AniWorldAPIException)
async def api_exception_handler(
request: Request, exc: AniWorldAPIException
) -> JSONResponse:
"""Handle generic API exceptions."""
logger.error(
f"API error: {exc.message}",
extra={
"error_code": exc.error_code,
"details": exc.details,
"path": str(request.url.path),
},
)
return JSONResponse(
status_code=exc.status_code,
content=create_error_response(
status_code=exc.status_code,
error=exc.error_code,
message=exc.message,
details=exc.details,
request_id=getattr(request.state, "request_id", None),
),
)
@app.exception_handler(Exception)
async def general_exception_handler(
request: Request, exc: Exception
) -> JSONResponse:
"""Handle unexpected exceptions."""
logger.exception(
f"Unexpected error: {str(exc)}",
extra={"path": str(request.url.path)},
)
# Log full traceback for debugging
logger.debug(f"Traceback: {traceback.format_exc()}")
# Return generic error response for security
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=create_error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error="INTERNAL_SERVER_ERROR",
message="An unexpected error occurred",
request_id=getattr(request.state, "request_id", None),
),
)

View File

@@ -13,10 +13,10 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
import psutil
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.server.database.models import Download, DownloadStatus
from src.server.database.models import DownloadQueueItem, DownloadStatus
logger = logging.getLogger(__name__)
@@ -124,8 +124,8 @@ class AnalyticsService:
cutoff_date = datetime.now() - timedelta(days=days)
# Query downloads within period
stmt = select(Download).where(
Download.created_at >= cutoff_date
stmt = select(DownloadQueueItem).where(
DownloadQueueItem.created_at >= cutoff_date
)
result = await db.execute(stmt)
downloads = result.scalars().all()
@@ -138,16 +138,16 @@ class AnalyticsService:
failed = [d for d in downloads
if d.status == DownloadStatus.FAILED]
total_bytes = sum(d.size_bytes or 0 for d in successful)
total_seconds = sum(
d.duration_seconds or 0 for d in successful
) or 1
avg_speed = (
(total_bytes / (1024 * 1024)) / total_seconds
if total_seconds > 0
total_bytes = sum(d.total_bytes or 0 for d in successful)
avg_speed_list = [
d.download_speed or 0.0 for d in successful if d.download_speed
]
avg_speed_mbps = (
sum(avg_speed_list) / len(avg_speed_list) / (1024 * 1024)
if avg_speed_list
else 0.0
)
success_rate = (
len(successful) / len(downloads) * 100 if downloads else 0.0
)
@@ -157,11 +157,9 @@ class AnalyticsService:
successful_downloads=len(successful),
failed_downloads=len(failed),
total_bytes_downloaded=total_bytes,
average_speed_mbps=avg_speed,
average_speed_mbps=avg_speed_mbps,
success_rate=success_rate,
average_duration_seconds=total_seconds / len(successful)
if successful
else 0.0,
average_duration_seconds=0.0, # Not available in model
)
async def get_series_popularity(
@@ -176,39 +174,42 @@ class AnalyticsService:
Returns:
List of SeriesPopularity objects
"""
stmt = (
select(
Download.series_name,
func.count(Download.id).label("download_count"),
func.sum(Download.size_bytes).label("total_size"),
func.max(Download.created_at).label("last_download"),
func.countif(
Download.status == DownloadStatus.COMPLETED
).label("successful"),
)
.group_by(Download.series_name)
.order_by(func.count(Download.id).desc())
.limit(limit)
)
# Use raw SQL approach since we need to group and join
from sqlalchemy import text
result = await db.execute(stmt)
query = text("""
SELECT
s.title as series_name,
COUNT(d.id) as download_count,
SUM(d.total_bytes) as total_size,
MAX(d.created_at) as last_download,
SUM(CASE WHEN d.status = 'COMPLETED'
THEN 1 ELSE 0 END) as successful
FROM download_queue d
JOIN anime_series s ON d.series_id = s.id
GROUP BY s.id, s.title
ORDER BY download_count DESC
LIMIT :limit
""")
result = await db.execute(query, {"limit": limit})
rows = result.all()
popularity = []
for row in rows:
success_rate = 0.0
if row.download_count > 0:
success_rate = (
(row.successful or 0) / row.download_count * 100
)
download_count = row[1] or 0
if download_count > 0:
successful = row[4] or 0
success_rate = (successful / download_count * 100)
popularity.append(
SeriesPopularity(
series_name=row.series_name or "Unknown",
download_count=row.download_count or 0,
total_size_bytes=row.total_size or 0,
last_download=row.last_download.isoformat()
if row.last_download
series_name=row[0] or "Unknown",
download_count=download_count,
total_size_bytes=row[2] or 0,
last_download=row[3].isoformat()
if row[3]
else None,
success_rate=success_rate,
)
@@ -288,8 +289,8 @@ class AnalyticsService:
cutoff_time = datetime.now() - timedelta(hours=hours)
# Get download metrics
stmt = select(Download).where(
Download.created_at >= cutoff_time
stmt = select(DownloadQueueItem).where(
DownloadQueueItem.created_at >= cutoff_time
)
result = await db.execute(stmt)
downloads = result.scalars().all()

View File

@@ -0,0 +1,227 @@
"""
Error tracking utilities for Aniworld API.
This module provides error tracking, logging, and reporting functionality
for comprehensive error monitoring and debugging.
"""
import logging
import uuid
from datetime import datetime
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class ErrorTracker:
"""
Centralized error tracking and management.
Collects error metadata and provides insights into error patterns.
"""
def __init__(self):
"""Initialize error tracker."""
self.error_history: list[Dict[str, Any]] = []
self.max_history_size = 1000
def track_error(
self,
error_type: str,
message: str,
request_path: str,
request_method: str,
user_id: Optional[str] = None,
status_code: int = 500,
details: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None,
) -> str:
"""
Track an error occurrence.
Args:
error_type: Type of error
message: Error message
request_path: Request path that caused error
request_method: HTTP method
user_id: User ID if available
status_code: HTTP status code
details: Additional error details
request_id: Request ID for correlation
Returns:
Unique error tracking ID
"""
error_id = str(uuid.uuid4())
timestamp = datetime.utcnow().isoformat()
error_entry = {
"id": error_id,
"timestamp": timestamp,
"type": error_type,
"message": message,
"request_path": request_path,
"request_method": request_method,
"user_id": user_id,
"status_code": status_code,
"details": details or {},
"request_id": request_id,
}
self.error_history.append(error_entry)
# Keep history size manageable
if len(self.error_history) > self.max_history_size:
self.error_history = self.error_history[-self.max_history_size:]
logger.info(
f"Error tracked: {error_id}",
extra={
"error_id": error_id,
"error_type": error_type,
"status_code": status_code,
"request_path": request_path,
},
)
return error_id
def get_error_stats(self) -> Dict[str, Any]:
"""
Get error statistics from history.
Returns:
Dictionary containing error statistics
"""
if not self.error_history:
return {"total_errors": 0, "error_types": {}}
error_types: Dict[str, int] = {}
status_codes: Dict[int, int] = {}
for error in self.error_history:
error_type = error["type"]
error_types[error_type] = error_types.get(error_type, 0) + 1
status_code = error["status_code"]
status_codes[status_code] = status_codes.get(status_code, 0) + 1
return {
"total_errors": len(self.error_history),
"error_types": error_types,
"status_codes": status_codes,
"last_error": (
self.error_history[-1] if self.error_history else None
),
}
def get_recent_errors(self, limit: int = 10) -> list[Dict[str, Any]]:
"""
Get recent errors.
Args:
limit: Maximum number of errors to return
Returns:
List of recent error entries
"""
return self.error_history[-limit:] if self.error_history else []
def clear_history(self) -> None:
"""Clear error history."""
self.error_history.clear()
logger.info("Error history cleared")
# Global error tracker instance
_error_tracker: Optional[ErrorTracker] = None
def get_error_tracker() -> ErrorTracker:
"""
Get or create global error tracker instance.
Returns:
ErrorTracker instance
"""
global _error_tracker
if _error_tracker is None:
_error_tracker = ErrorTracker()
return _error_tracker
def reset_error_tracker() -> None:
"""Reset error tracker for testing."""
global _error_tracker
_error_tracker = None
class RequestContextManager:
"""
Manages request context for error tracking.
Stores request metadata for error correlation.
"""
def __init__(self):
"""Initialize context manager."""
self.context_stack: list[Dict[str, Any]] = []
def push_context(
self,
request_id: str,
request_path: str,
request_method: str,
user_id: Optional[str] = None,
) -> None:
"""
Push request context onto stack.
Args:
request_id: Unique request identifier
request_path: Request path
request_method: HTTP method
user_id: User ID if available
"""
context = {
"request_id": request_id,
"request_path": request_path,
"request_method": request_method,
"user_id": user_id,
"timestamp": datetime.utcnow().isoformat(),
}
self.context_stack.append(context)
def pop_context(self) -> Optional[Dict[str, Any]]:
"""
Pop request context from stack.
Returns:
Context dictionary or None if empty
"""
return self.context_stack.pop() if self.context_stack else None
def get_current_context(self) -> Optional[Dict[str, Any]]:
"""
Get current request context.
Returns:
Current context or None if empty
"""
return self.context_stack[-1] if self.context_stack else None
# Global request context manager
_context_manager: Optional[RequestContextManager] = None
def get_context_manager() -> RequestContextManager:
"""
Get or create global context manager instance.
Returns:
RequestContextManager instance
"""
global _context_manager
if _context_manager is None:
_context_manager = RequestContextManager()
return _context_manager