fix test and add doc
This commit is contained in:
255
src/server/exceptions/__init__.py
Normal file
255
src/server/exceptions/__init__.py
Normal 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,
|
||||
)
|
||||
35
src/server/exceptions/exceptions.py
Normal file
35
src/server/exceptions/exceptions.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
236
src/server/middleware/error_handler.py
Normal file
236
src/server/middleware/error_handler.py
Normal 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),
|
||||
),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
227
src/server/utils/error_tracking.py
Normal file
227
src/server/utils/error_tracking.py
Normal 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
|
||||
Reference in New Issue
Block a user