mirror of
https://github.com/StuffAnThings/qbit_manage.git
synced 2025-10-11 22:36:35 +08:00
# Requirements Updated - qbittorrent-api==2025.7.0 - fastapi==0.116.1 # New Features - **Uncategorized Category**: Allow multiple paths for Uncategorized category and add error handling (Thanks to @cat-of-wisdom #849) - **Config Auto Backup and Cleanup**: implement automatic backup rotation (30 most recent backups per config) and cleanup - **Web UI**: add base URL support for reverse proxy deployments (Fixes #871) - **Share Limits**: add option to preserve upload speed limits when minimums unmet (New config option `reset_upload_speed_on_unmet_minimums`) (Fixes #835, #791) # Improvements - Optimize webUI form rendering - Better centralized error handling for qbitorrent API operations - **Web UI**: add editable group names to share limit modal # Bug Fixes - Fix bug in remove orphaned to notify when there are 0 orphaned files - Fixes [Bug]: Cannot run on Python 3.9.18 #864 - fix(qbit): add error handling for qBittorrent API operations **Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.0...v4.5.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: cat-of-wisdom <217637421+cat-of-wisdom@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""
|
|
Centralized error handling for qBittorrent API exceptions
|
|
"""
|
|
|
|
import functools
|
|
import time
|
|
from typing import Any
|
|
from typing import Callable
|
|
|
|
from qbittorrentapi import APIConnectionError
|
|
from qbittorrentapi import APIError
|
|
from qbittorrentapi import Conflict409Error
|
|
from qbittorrentapi import Forbidden403Error
|
|
from qbittorrentapi import HTTP4XXError
|
|
from qbittorrentapi import HTTP5XXError
|
|
from qbittorrentapi import HTTPError
|
|
from qbittorrentapi import InternalServerError500Error
|
|
from qbittorrentapi import InvalidRequest400Error
|
|
from qbittorrentapi import LoginFailed
|
|
from qbittorrentapi import MissingRequiredParameters400Error
|
|
from qbittorrentapi import NotFound404Error
|
|
from qbittorrentapi import TorrentFileError
|
|
from qbittorrentapi import TorrentFileNotFoundError
|
|
from qbittorrentapi import TorrentFilePermissionError
|
|
from qbittorrentapi import Unauthorized401Error
|
|
from qbittorrentapi import UnsupportedMediaType415Error
|
|
from qbittorrentapi import UnsupportedQbittorrentVersion
|
|
|
|
from modules import util
|
|
|
|
logger = util.logger
|
|
|
|
|
|
class QbitAPIErrorHandler:
|
|
"""Centralized handler for qBittorrent API errors"""
|
|
|
|
def __init__(self, config=None):
|
|
self.config = config
|
|
self.retry_attempts = 3
|
|
self.retry_delay = 5 # seconds
|
|
|
|
def handle_api_error(self, error: Exception, context: str = "") -> bool:
|
|
"""
|
|
Handle qBittorrent API errors with appropriate logging and notifications
|
|
|
|
Args:
|
|
error: The exception that occurred
|
|
context: Additional context about where the error occurred
|
|
|
|
Returns:
|
|
bool: True if the error was handled gracefully, False if it should be re-raised
|
|
"""
|
|
error_msg = f"qBittorrent API Error{f' in {context}' if context else ''}: {str(error)}"
|
|
|
|
if isinstance(error, Forbidden403Error):
|
|
logger.error(f"{error_msg} - Access forbidden. Check qBittorrent permissions and authentication.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent access forbidden: {str(error)}", "API Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, LoginFailed):
|
|
logger.error(f"{error_msg} - Login failed. Check qBittorrent credentials.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent login failed: {str(error)}", "Authentication Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, APIConnectionError):
|
|
logger.error(f"{error_msg} - Connection failed. Check qBittorrent server status.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent connection failed: {str(error)}", "Connection Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, NotFound404Error):
|
|
logger.warning(f"{error_msg} - Resource not found. This may be expected behavior.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent resource not found: {str(error)}", "API Warning", False)
|
|
return True
|
|
|
|
elif isinstance(error, Conflict409Error):
|
|
logger.warning(f"{error_msg} - Resource conflict. This may be expected behavior.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent resource conflict: {str(error)}", "API Warning", False)
|
|
return True
|
|
|
|
elif isinstance(error, TorrentFileNotFoundError):
|
|
logger.error(f"{error_msg} - Torrent file not found.")
|
|
if self.config:
|
|
self.config.notify(f"Torrent file not found: {str(error)}", "File Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, TorrentFilePermissionError):
|
|
logger.error(f"{error_msg} - Permission denied for torrent file.")
|
|
if self.config:
|
|
self.config.notify(f"Torrent file permission denied: {str(error)}", "Permission Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, TorrentFileError):
|
|
logger.error(f"{error_msg} - Torrent file error.")
|
|
if self.config:
|
|
self.config.notify(f"Torrent file error: {str(error)}", "File Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, (MissingRequiredParameters400Error, InvalidRequest400Error)):
|
|
logger.error(f"{error_msg} - Invalid request parameters.")
|
|
if self.config:
|
|
self.config.notify(f"Invalid qBittorrent request parameters: {str(error)}", "Request Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, Unauthorized401Error):
|
|
logger.error(f"{error_msg} - Unauthorized access. Check authentication.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent unauthorized access: {str(error)}", "Authentication Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, UnsupportedMediaType415Error):
|
|
logger.error(f"{error_msg} - Unsupported media type (invalid torrent file/URL).")
|
|
if self.config:
|
|
self.config.notify(f"Unsupported media type: {str(error)}", "Media Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, InternalServerError500Error):
|
|
logger.error(f"{error_msg} - qBittorrent internal server error.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent server error: {str(error)}", "Server Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, UnsupportedQbittorrentVersion):
|
|
logger.error(f"{error_msg} - Unsupported qBittorrent version.")
|
|
if self.config:
|
|
self.config.notify(f"Unsupported qBittorrent version: {str(error)}", "Version Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, (HTTPError, HTTP4XXError, HTTP5XXError)):
|
|
# Catch any other HTTP errors we might have missed
|
|
logger.error(f"{error_msg} - HTTP error (status: {getattr(error, 'http_status_code', 'unknown')}).")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent HTTP error: {str(error)}", "HTTP Error", False)
|
|
return True
|
|
|
|
elif isinstance(error, APIError):
|
|
# Catch any other API errors we might have missed
|
|
logger.error(f"{error_msg} - General API error.")
|
|
if self.config:
|
|
self.config.notify(f"qBittorrent API error: {str(error)}", "API Error", False)
|
|
return True
|
|
|
|
else:
|
|
# Unknown qBittorrent API error
|
|
logger.error(f"Unknown qBittorrent API error{f' in {context}' if context else ''}: {str(error)}")
|
|
if self.config:
|
|
self.config.notify(f"Unknown qBittorrent error: {str(error)}", "Unknown Error", False)
|
|
return False
|
|
|
|
|
|
def handle_qbit_api_errors(context: str = "", retry_attempts: int = 3, retry_delay: int = 5):
|
|
"""
|
|
Decorator to handle qBittorrent API errors with retry logic
|
|
|
|
Args:
|
|
context: Description of where the error occurred
|
|
retry_attempts: Number of retry attempts for recoverable errors
|
|
retry_delay: Delay between retry attempts in seconds
|
|
"""
|
|
|
|
def decorator(func: Callable) -> Callable:
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs) -> Any:
|
|
# Try to get config from args/kwargs for notifications
|
|
config = None
|
|
if args and hasattr(args[0], "config"):
|
|
config = args[0].config
|
|
elif "config" in kwargs:
|
|
config = kwargs["config"]
|
|
|
|
error_handler = QbitAPIErrorHandler(config)
|
|
|
|
for attempt in range(retry_attempts + 1):
|
|
try:
|
|
return func(*args, **kwargs)
|
|
|
|
except (
|
|
APIConnectionError,
|
|
Forbidden403Error,
|
|
LoginFailed,
|
|
NotFound404Error,
|
|
Conflict409Error,
|
|
TorrentFileError,
|
|
TorrentFileNotFoundError,
|
|
TorrentFilePermissionError,
|
|
UnsupportedQbittorrentVersion,
|
|
MissingRequiredParameters400Error,
|
|
InvalidRequest400Error,
|
|
Unauthorized401Error,
|
|
UnsupportedMediaType415Error,
|
|
InternalServerError500Error,
|
|
HTTPError,
|
|
HTTP4XXError,
|
|
HTTP5XXError,
|
|
APIError,
|
|
) as e:
|
|
# Handle the error
|
|
handled = error_handler.handle_api_error(e, context)
|
|
|
|
if not handled:
|
|
# Re-raise if not handled
|
|
raise
|
|
|
|
# For certain errors, don't retry
|
|
if isinstance(
|
|
e,
|
|
(
|
|
UnsupportedQbittorrentVersion,
|
|
MissingRequiredParameters400Error,
|
|
InvalidRequest400Error,
|
|
NotFound404Error,
|
|
Conflict409Error,
|
|
UnsupportedMediaType415Error,
|
|
TorrentFileNotFoundError,
|
|
TorrentFilePermissionError,
|
|
),
|
|
):
|
|
logger.info(f"Skipping operation due to {type(e).__name__}")
|
|
return None
|
|
|
|
# Retry for connection/auth errors
|
|
if attempt < retry_attempts:
|
|
logger.info(f"Retrying in {retry_delay} seconds... (attempt {attempt + 1}/{retry_attempts})")
|
|
time.sleep(retry_delay)
|
|
else:
|
|
logger.error(f"Max retry attempts ({retry_attempts}) exceeded for {context}")
|
|
if config:
|
|
config.notify(f"Max retry attempts exceeded for {context}: {str(e)}", "Retry Error", False)
|
|
return None
|
|
|
|
except Exception as e:
|
|
# Non-qBittorrent API error, let it propagate
|
|
logger.error(f"Non-API error in {context}: {str(e)}")
|
|
if config:
|
|
config.notify(f"Non-API error in {context}: {str(e)}", "System Error", False)
|
|
raise
|
|
|
|
return None
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def safe_execute_with_qbit_error_handling(func: Callable, context: str = "", *args, **kwargs) -> Any:
|
|
"""
|
|
Safely execute a function with qBittorrent API error handling
|
|
|
|
Args:
|
|
func: Function to execute
|
|
context: Description of the operation
|
|
*args, **kwargs: Arguments to pass to the function
|
|
|
|
Returns:
|
|
Function result or None if an error occurred
|
|
"""
|
|
try:
|
|
# Apply the decorator dynamically
|
|
wrapped_func = handle_qbit_api_errors(context)(func)
|
|
return wrapped_func(*args, **kwargs)
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in {context}: {str(e)}")
|
|
logger.stacktrace()
|
|
# Try to get config from args for notification
|
|
config = None
|
|
if args and hasattr(args[0], "config"):
|
|
config = args[0].config
|
|
elif "config" in kwargs:
|
|
config = kwargs["config"]
|
|
if config:
|
|
config.notify(f"Unexpected error in {context}: {str(e)}", "System Error", False)
|
|
return None
|