Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/fastapi_cloud_cli/commands/deploy/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
_get_app,
)
from fastapi_cloud_cli.commands.deploy.configure import _configure_app
from fastapi_cloud_cli.commands.deploy.upload import _cancel_upload, _upload_deployment
from fastapi_cloud_cli.commands.deploy.upload import (
DeploymentTooLargeError,
_cancel_upload,
_upload_deployment,
)
from fastapi_cloud_cli.commands.deploy.wait import _wait_for_deployment
from fastapi_cloud_cli.commands.login import _interactive_login
from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus
Expand Down Expand Up @@ -342,6 +346,19 @@ def deploy(
except KeyboardInterrupt:
_cancel_upload(client=client, deployment_id=deployment.id)
raise
except DeploymentTooLargeError as e:
_cancel_upload(client=client, deployment_id=deployment.id)

hint = (
"You can exclude files from the deployment "
"with a .fastapicloudignore file."
)

if toolkit.mode == "json":
toolkit.fail("invalid_input", str(e), hint=hint)

progress.set_error(f"{e}\n\n[dim]hint: {hint}[/]")
raise typer.Exit(1) from None

if will_wait:
logger.debug("Waiting for deployment to complete")
Expand Down
45 changes: 44 additions & 1 deletion src/fastapi_cloud_cli/commands/deploy/upload.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import logging
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import BinaryIO, cast

from httpx import Client
from httpx import Client, Response
from pydantic import BaseModel
from rich_toolkit.progress import Progress

Expand All @@ -16,6 +17,7 @@
class RequestUploadResponse(BaseModel):
url: str
fields: dict[str, str]
max_size_bytes: int


def _cancel_upload(client: APIClient, deployment_id: str) -> None:
Expand All @@ -39,6 +41,32 @@ def _format_size(size_in_bytes: int) -> str:
return f"{size_in_bytes} bytes"


class DeploymentTooLargeError(Exception):
def __init__(self, archive_size: int, max_size: int | None) -> None:
message = (
f"The deployment archive is {_format_size(archive_size)}, "
"which exceeds the maximum allowed size"
)

if max_size is not None:
message += f" of {_format_size(max_size)}"

super().__init__(f"{message}.")


def _get_s3_error(response: Response) -> tuple[str | None, int | None]:
"""Extract the error code and max allowed size from an S3 XML error response."""
try:
root = ET.fromstring(response.text)
except ET.ParseError:
return None, None

max_size_text = root.findtext("MaxSizeAllowed") or ""
max_size = int(max_size_text) if max_size_text.isdigit() else None

return root.findtext("Code"), max_size


def _upload_deployment(
fastapi_client: APIClient,
deployment_id: str,
Expand Down Expand Up @@ -67,6 +95,14 @@ def progress_callback(bytes_read: int) -> None:
upload_data = RequestUploadResponse.model_validate(response.json())
logger.debug("Received upload URL: %s", upload_data.url)

if archive_size > upload_data.max_size_bytes:
logger.debug(
"Archive size %s exceeds the maximum allowed size %s, skipping upload",
archive_size,
upload_data.max_size_bytes,
)
raise DeploymentTooLargeError(archive_size, upload_data.max_size_bytes)

logger.debug("Starting file upload to S3")
with Client() as s3_client:
with open(archive_path, "rb") as archive_file:
Expand All @@ -79,6 +115,13 @@ def progress_callback(bytes_read: int) -> None:
files={"file": cast(BinaryIO, archive_file_with_progress)},
)

if upload_response.is_error:
logger.debug("File upload failed with response: %s", upload_response.text)

error_code, max_size = _get_s3_error(upload_response)
if error_code == "EntityTooLarge":
raise DeploymentTooLargeError(archive_size, max_size)

upload_response.raise_for_status()
logger.debug("File upload completed successfully")

Expand Down
Loading
Loading