Source code for derp.storage.exceptions

"""Custom exceptions for storage integration.

The hierarchy is rooted at ``StorageError``. The S3 client normalises
``botocore.ClientError`` into these types so callers don't need to import
botocore to handle common failures (missing key, missing bucket, denied).

Conventions:

* Object-level lookups that don't find anything raise
  ``StorageObjectNotFoundError``. ``delete_file`` is the exception — S3
  deletes are idempotent, so it returns successfully for a missing key
  rather than raising.
* Bucket-level operations on a missing bucket raise
  ``StorageBucketNotFoundError``.
* 403 / ``AccessDenied`` collapses to ``StorageAccessDeniedError``.
* ``delete_files`` partial failures surface as
  ``StoragePartialDeleteError`` carrying the per-key reasons.
* Anything else from the backend (network, 5xx, unrecognised 4xx) is
  wrapped in ``StorageBackendError`` with the original exception chained.
"""

from __future__ import annotations


[docs] class StorageError(Exception): """Base exception for all storage errors."""
[docs] def __init__(self, message: str, code: str | None = None): super().__init__(message) self.message = message self.code = code or "storage_error"
[docs] class StorageNotConnectedError(StorageError): """Raised when storage client is used before connect()."""
[docs] def __init__(self, message: str = "Storage not connected. Call connect() first."): super().__init__(message, code="storage_not_connected")
[docs] class StorageObjectNotFoundError(StorageError): """Raised when an S3 object lookup targets a missing key. The bucket and key are exposed as attributes for logging. """
[docs] def __init__(self, bucket: str, key: str, message: str | None = None): super().__init__( message or f"Object {bucket!r}/{key!r} not found", code="storage_object_not_found", ) self.bucket = bucket self.key = key
[docs] class StorageBucketNotFoundError(StorageError): """Raised when an operation targets a missing bucket."""
[docs] def __init__(self, bucket: str, message: str | None = None): super().__init__( message or f"Bucket {bucket!r} not found", code="storage_bucket_not_found", ) self.bucket = bucket
[docs] class StorageAccessDeniedError(StorageError): """Raised when S3 rejects a call with AccessDenied / 403."""
[docs] def __init__(self, message: str = "Access denied by storage backend"): super().__init__(message, code="storage_access_denied")
[docs] class StoragePartialDeleteError(StorageError): """Raised when ``delete_objects`` reports per-key failures. The ``errors`` attribute is a list of ``{"key", "code", "message"}`` dicts mirroring the S3 response. The ``deleted`` attribute lists keys that succeeded in the same batch. """
[docs] def __init__(self, errors: list[dict[str, str]], deleted: list[str]): super().__init__( f"{len(errors)} of {len(errors) + len(deleted)} deletes failed", code="storage_partial_delete", ) self.errors = errors self.deleted = deleted
[docs] class StorageBackendError(StorageError): """Wraps an unrecognised backend failure (network, 5xx, unknown 4xx). Always chains the original exception via ``raise ... from exc`` so the backend-specific type and traceback are preserved for debugging. The ``code`` attribute carries the S3 error code where available. """
[docs] def __init__(self, message: str = "Storage backend error", code: str | None = None): super().__init__(message, code=code or "storage_backend_error")