Add granular DB error types with retry logic

New exceptions: DatabaseBusyError, DatabasePermissionDeniedError,
DatabasePathInvalidError, DatabaseCorruptedError, DatabaseUnavailableError.

open_db creates parent directory if missing. Catches all aiosqlite errors
and maps to specific exception types.

get_db retries up to 3x on locked database with backoff.
Propagates specific exceptions instead of generic HTTPException.

Tests for all new error types and retry behavior.
This commit is contained in:
2026-05-23 22:21:42 +02:00
parent ef8feba4b2
commit 9e59fc8bae
4 changed files with 370 additions and 12 deletions

View File

@@ -475,14 +475,75 @@ async def init_db(db: aiosqlite.Connection) -> None:
async def open_db(database_path: str) -> aiosqlite.Connection:
"""Open a new application SQLite connection with the standard settings.
Creates the parent directory if it does not exist.
Args:
database_path: Path to the BanGUI SQLite database.
Returns:
A configured :class:`aiosqlite.Connection` instance.
Raises:
DatabasePathInvalidError: If the directory cannot be created or is inaccessible.
DatabasePermissionDeniedError: If aiosqlite.connect raises PermissionError.
DatabaseCorruptedError: If the database file is corrupted.
DatabaseUnavailableError: For any other unexpected error.
"""
await _cleanup_wal_files(database_path)
db = await aiosqlite.connect(database_path)
from app.exceptions import (
DatabaseCorruptedError,
DatabasePathInvalidError,
DatabasePermissionDeniedError,
DatabaseUnavailableError,
)
db_dir = Path(database_path).parent
if not db_dir.exists():
try:
db_dir.mkdir(parents=True, exist_ok=True)
except PermissionError as exc:
log.error("database_open_failed", error=str(exc), database_path=database_path)
raise DatabasePathInvalidError(database_path) from exc
except OSError as exc:
log.error("database_open_failed", error=str(exc), database_path=database_path)
raise DatabaseUnavailableError(database_path, str(exc)) from exc
try:
db = await aiosqlite.connect(database_path)
except PermissionError as exc:
log.error("database_open_failed", error=str(exc), database_path=database_path)
raise DatabasePermissionDeniedError(database_path) from exc
except aiosqlite.OperationalError as exc:
error_msg = str(exc).lower()
sqlite_code = getattr(exc, "sqlite_errorcode", None)
log.error(
"database_open_failed",
error=str(exc),
sqlite_errorcode=sqlite_code,
database_path=database_path,
)
if "database is locked" in error_msg or "busy" in error_msg:
raise DatabaseUnavailableError(database_path, str(exc)) from exc
if "unable to open database file" in error_msg:
raise DatabasePathInvalidError(database_path) from exc
raise DatabaseUnavailableError(database_path, str(exc)) from exc
except aiosqlite.DatabaseError as exc:
log.error(
"database_open_failed",
error=str(exc),
database_path=database_path,
)
raise DatabaseCorruptedError(database_path) from exc
except OSError as exc:
log.error("database_open_failed", error=str(exc), database_path=database_path)
raise DatabaseUnavailableError(database_path, str(exc)) from exc
except Exception as exc:
log.error("database_open_failed", error=str(exc), database_path=database_path)
raise DatabaseUnavailableError(database_path, str(exc)) from exc
db.row_factory = aiosqlite.Row
await _configure_connection(db)
try:
await _configure_connection(db)
except Exception:
await db.close()
raise
return db

View File

@@ -165,22 +165,61 @@ async def get_db(
Yields:
An open :class:`aiosqlite.Connection` for the request.
Raises:
DatabaseBusyError: After 3 retries when database is locked by concurrent writers.
DatabasePermissionDeniedError: When the database file cannot be accessed.
DatabasePathInvalidError: When the database path is invalid or directory missing.
DatabaseCorruptedError: When the database file is corrupted.
DatabaseUnavailableError: For any other unexpected database error.
"""
from app.db import open_db # noqa: PLC0415
from app.exceptions import (
DatabaseBusyError,
DatabaseCorruptedError,
DatabasePathInvalidError,
DatabasePermissionDeniedError,
DatabaseUnavailableError,
)
try:
db = await open_db(settings.database_path)
except Exception as exc:
log.error("database_open_failed", error=str(exc))
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Database is not available.",
) from exc
db = None
retries = 3
retry_delay = 0.1
last_exc = None
for attempt in range(1, retries + 1):
try:
db = await open_db(settings.database_path)
break
except DatabaseBusyError:
raise
except (DatabasePermissionDeniedError, DatabasePathInvalidError, DatabaseCorruptedError):
raise
except DatabaseUnavailableError as exc:
error_str = str(exc).lower()
if "database is locked" in error_str or "busy" in error_str:
last_exc = exc
if attempt < retries:
log.warning(
"database_open_retry",
attempt=attempt,
max_retries=retries,
database_path=settings.database_path,
)
import asyncio
await asyncio.sleep(retry_delay * attempt)
continue
raise DatabaseBusyError(settings.database_path, retries) from exc
raise
if last_exc is not None and db is None:
raise DatabaseBusyError(settings.database_path, retries)
try:
yield db
finally:
await db.close()
if db is not None:
await db.close()
async def get_http_session(

View File

@@ -473,6 +473,75 @@ class SetupAlreadyCompleteError(ConflictError):
super().__init__("Setup has already been completed.")
class DatabaseBusyError(ServiceUnavailableError):
"""Raised when the SQLite database is locked or busy after all retries."""
error_code: str = "database_busy"
def __init__(self, database_path: str, retries: int) -> None:
self.database_path = database_path
self.retries = retries
super().__init__(
f"Database is temporarily busy after {retries} retries."
)
def get_error_metadata(self) -> ErrorMetadata:
return {"database_path": self.database_path, "retries": self.retries}
class DatabasePermissionDeniedError(ServiceUnavailableError):
"""Raised when the database file cannot be accessed due to insufficient permissions."""
error_code: str = "database_permission_denied"
def __init__(self, database_path: str) -> None:
self.database_path = database_path
super().__init__("Insufficient permissions to access the database file.")
def get_error_metadata(self) -> ErrorMetadata:
return {"database_path": self.database_path}
class DatabasePathInvalidError(ServiceUnavailableError):
"""Raised when the database directory does not exist or the path is invalid."""
error_code: str = "database_path_invalid"
def __init__(self, database_path: str) -> None:
self.database_path = database_path
super().__init__("Database directory does not exist or path is invalid.")
def get_error_metadata(self) -> ErrorMetadata:
return {"database_path": self.database_path}
class DatabaseCorruptedError(ServiceUnavailableError):
"""Raised when the database file is corrupted."""
error_code: str = "database_corrupted"
def __init__(self, database_path: str) -> None:
self.database_path = database_path
super().__init__("Database file is corrupted.")
def get_error_metadata(self) -> ErrorMetadata:
return {"database_path": self.database_path}
class DatabaseUnavailableError(ServiceUnavailableError):
"""Raised for any other unexpected database error."""
error_code: str = "database_unavailable"
def __init__(self, database_path: str, error: str) -> None:
self.database_path = database_path
self.error = error
super().__init__(f"Database is not available: {error}")
def get_error_metadata(self) -> ErrorMetadata:
return {"database_path": self.database_path, "error": self.error}
class BlocklistSourceNotFoundError(NotFoundError):
"""Raised when a blocklist source is not found."""