Image showing Four Storage Backends, One Interface: Building a Pluggable Image Storage Layer in Python

Four Storage Backends, One Interface: Building a Pluggable Image Storage Layer in Python

affiliate best offer

[!note] 📚 Smart Assets Manager Series

  1. Why Storage Abstraction Matters
  2. Four Storage Backends, One Interface ← you are here
  3. The Unified API: Credit Reservation and Rate Limiting — April 1
  4. Testing Strategy: Unit Tests vs E2E Tests — April 3
  5. The 5 Edge Cases That Break Image APIs — April 8
  6. API Documentation: Swagger + Postman as Deliverables — April 10

In the previous post, I made the case for storage abstraction in image generation APIs. Here’s the implementation.

The core design: an abstract base class with two required methods, four concrete implementations, and one factory function that dispatches based on the request parameter. Every caller uses the same interface — only the factory knows which backend is active.


The Abstract Base Class

# backend/app/integrations/storage_manager.py

from abc import ABC, abstractmethod
from enum import Enum
import logging

logger = logging.getLogger(__name__)


class StorageType(str, Enum):
    """Supported storage backends — passed as a request parameter."""
    CLOUDINARY = "cloudinary"
    LOCAL = "local"
    S3 = "s3"
    DIRECT = "direct"


class StorageBackend(ABC):
    """Abstract base class for all image storage backends.

    Every backend must implement upload() and generate_signed_url().
    This contract is the only thing callers depend on — switching
    backends requires changing only the factory, not any caller code.
    """

    @abstractmethod
    def upload(self, image_bytes: bytes, filename: str, visibility: str = "public") -> str:
        """Store image bytes and return a URL or data URI.

        Args:
            image_bytes: Raw image data (PNG, JPEG, or WebP)
            filename: Suggested filename (backends may use or ignore this)
            visibility: 'public' for open URLs, 'private' for signed access only

        Returns:
            str: URL, signed URL, or base64 data URI depending on backend
        """
        ...

    @abstractmethod
    def generate_signed_url(self, asset_id: str, expiry_seconds: int = 86400) -> str:
        """Generate a time-limited signed URL for a private asset."""
        ...

    def cleanup_failed_assets(self, asset_ids: list[str]) -> None:
        """Best-effort cleanup of partial uploads after a batch failure.

        Called when a multi-size generation partially fails — some images
        were uploaded before the error, and those orphaned assets should
        be removed. Does not raise exceptions: failure to clean up is
        logged as a warning but never re-raised.
        """
        for asset_id in asset_ids:
            try:
                self._delete(asset_id)
            except Exception as e:
                logger.warning("Cleanup failed for asset %s: %s", asset_id, e)

    @abstractmethod
    def _delete(self, asset_id: str) -> None:
        """Delete a stored asset by its identifier (internal use only)."""
        ...

The Cloudinary Backend

import time
import cloudinary.uploader
import cloudinary.utils
from pathlib import Path


class CloudinaryStorage(StorageBackend):
    """Cloudinary storage backend.

    Supports public uploads (open CDN URL) and private uploads
    (accessible only via signed URLs with configurable expiry).
    """

    def upload(self, image_bytes: bytes, filename: str, visibility: str = "public") -> str:
        # Use 'authenticated' type for private assets so the URL
        # requires a signature to access — preventing hotlinking.
        upload_type = "authenticated" if visibility == "private" else "upload"

        result = cloudinary.uploader.upload(
            image_bytes,
            public_id=Path(filename).stem,
            resource_type="image",
            type=upload_type,
        )
        return result["secure_url"]

    def generate_signed_url(self, asset_id: str, expiry_seconds: int = 86400) -> str:
        # expiry_seconds defaults to 24 hours — long enough for most workflows,
        # short enough to expire before stale links circulate.
        expires_at = int(time.time()) + expiry_seconds
        signed_url, _ = cloudinary.utils.cloudinary_url(
            asset_id,
            type="authenticated",
            sign_url=True,
            expires_at=expires_at,
        )
        return signed_url

    def _delete(self, asset_id: str) -> None:
        cloudinary.uploader.destroy(asset_id, resource_type="image")

The Local File System Backend

from pathlib import Path


class LocalStorage(StorageBackend):
    """Local file system storage backend.

    Saves images to a local directory and returns localhost URLs.
    Intended for development and on-premise deployments where
    external CDN dependency is unwanted or unavailable.
    """

    BASE_PATH = Path("generated_assets")
    BASE_URL = "http://localhost:8000/assets"

    def upload(self, image_bytes: bytes, filename: str, visibility: str = "public") -> str:
        # Ensure the target directory exists before writing
        self.BASE_PATH.mkdir(parents=True, exist_ok=True)

        target = self.BASE_PATH / filename
        target.write_bytes(image_bytes)

        # Return a localhost URL — the caller is responsible for serving this path
        return f"{self.BASE_URL}/{filename}"

    def generate_signed_url(self, asset_id: str, expiry_seconds: int = 86400) -> str:
        # Local storage doesn't support signed URLs — return as-is
        # This is acceptable because local storage is used in trusted environments
        return asset_id

    def _delete(self, asset_id: str) -> None:
        path = self.BASE_PATH / Path(asset_id).name
        if path.exists():
            path.unlink()

The Direct Download Backend

import base64


class DirectDownloadStorage(StorageBackend):
    """Returns image bytes as a base64-encoded data URI.

    No external storage. No credentials required. No storage costs.
    The image is returned inline in the API response.

    Ideal for:
    - CI/CD pipelines (image is processed downstream, not stored)
    - Development and testing (no cloud setup needed)
    - API consumers who handle their own storage
    """

    # Map common byte signatures to MIME type suffixes
    _FORMAT_SIGNATURES = {
        b"\x89PNG": "png",
        b"\xff\xd8\xff": "jpeg",
        b"RIFF": "webp",
    }

    def upload(self, image_bytes: bytes, filename: str, visibility: str = "public") -> str:
        # Detect format from byte signature rather than file extension
        # (callers don't always provide correct extensions)
        image_format = "png"  # Safe default
        for signature, fmt in self._FORMAT_SIGNATURES.items():
            if image_bytes[: len(signature)] == signature:
                image_format = fmt
                break

        encoded = base64.b64encode(image_bytes).decode("utf-8")
        return f"data:image/{image_format};base64,{encoded}"

    def generate_signed_url(self, asset_id: str, expiry_seconds: int = 86400) -> str:
        # Data URIs don't expire — they're ephemeral by nature
        return asset_id

    def _delete(self, asset_id: str) -> None:
        pass  # Nothing to delete — data URIs exist only in the response

The Factory Function

def get_storage_backend(storage_type: str) -> StorageBackend:
    """Return the correct storage backend for the given type.

    This is the only place in the codebase that knows which class
    maps to which storage type. All callers depend on the interface,
    not the implementation.
    """
    backends = {
        StorageType.CLOUDINARY: CloudinaryStorage,
        StorageType.LOCAL: LocalStorage,
        StorageType.S3: S3Storage,  # Implementation follows same pattern
        StorageType.DIRECT: DirectDownloadStorage,
    }

    backend_class = backends.get(StorageType(storage_type))
    if not backend_class:
        raise ValueError(f"Unknown storage type: {storage_type}")

    return backend_class()

The Cleanup-on-Failure Pattern

One detail worth highlighting: cleanup_failed_assets() on the base class. When a batch generation (say, 16 sizes from a preset) partially fails — 7 succeed and upload, then the 8th raises an exception — the caller should clean up the 7 orphaned uploads before returning the error.

The base class provides this as a “best-effort” method: it tries to delete each asset, but if a deletion fails, it logs a warning and continues. It never raises exceptions, because a failed cleanup is a secondary problem — the primary error already happened.

This pattern is deliberately forgiving because the cleanup runs in an error context. Raising a new exception during error handling obscures the original error and makes debugging harder.


Key Takeaways

  • The abstract base class (2 abstract methods + 1 concrete cleanup method) is the entire interface. Callers depend only on this.
  • Direct download is the simplest backend and requires zero external dependencies — make it the development default.
  • Detect image format from byte signatures, not file extensions. Callers lie; bytes don’t.
  • Cleanup-on-failure should be best-effort and non-raising. Exceptions in error handlers hide original errors.

What’s Next

With storage handled, the next piece is the API layer: a unified endpoint that accepts all generation types, validates requests with Pydantic, and coordinates credit reservation to ensure users are never charged without delivery.

→ Next: The Unified API with Credit Reservation

← Previous: Why Storage Abstraction Matters

— Kékéli

Full Bright

Full Bright

A professional and sympathic business man.

Contact

Contact Us

To order one of our services, navigate to the order service page

Address

10 rue François 1er,
75008 Paris

Email Us

hello at bright-softwares dot com

Open Hours

Monday - Friday
9:00AM - 05:00PM