From 88eea9678abc606082359faf3314191e64b48ad3 Mon Sep 17 00:00:00 2001 From: Marco Burro Date: Thu, 16 Jul 2026 13:10:59 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Handle=20archive=20size=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commands/deploy/cloud.py | 26 ++- .../commands/deploy/command.py | 36 ++- .../commands/deploy/upload.py | 29 ++- tests/test_cli_deploy.py | 208 ++++++++++++++++++ 4 files changed, 290 insertions(+), 9 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy/cloud.py b/src/fastapi_cloud_cli/commands/deploy/cloud.py index 46684f1e..d02d9f55 100644 --- a/src/fastapi_cloud_cli/commands/deploy/cloud.py +++ b/src/fastapi_cloud_cli/commands/deploy/cloud.py @@ -1,6 +1,14 @@ from pydantic import BaseModel -from fastapi_cloud_cli.utils.api import APIClient, DeploymentStatus +from fastapi_cloud_cli.utils.api import ( + APIClient, + DeploymentStatus, + _get_response_error_message, +) + + +class ArchiveTooLargeError(Exception): + pass class Team(BaseModel): @@ -57,8 +65,20 @@ def _create_app( return AppResponse.model_validate(response.json()) -def _create_deployment(client: APIClient, app_id: str) -> CreateDeploymentResponse: - response = client.post(f"/apps/{app_id}/deployments/") +def _create_deployment( + client: APIClient, app_id: str, archive_size_bytes: int +) -> CreateDeploymentResponse: + response = client.post( + f"/apps/{app_id}/deployments/", + json={"archive_size_bytes": archive_size_bytes}, + ) + + if response.status_code == 413: + raise ArchiveTooLargeError( + _get_response_error_message(response) + or "The app source code exceeds the maximum allowed size." + ) + response.raise_for_status() return CreateDeploymentResponse.model_validate(response.json()) diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index 89dcafa8..163859c8 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -1,20 +1,25 @@ import logging import tempfile from pathlib import Path -from typing import Annotated, Any, cast +from typing import Annotated, Any, NoReturn, cast import typer from pydantic import BaseModel +from rich_toolkit.progress import Progress from fastapi_cloud_cli.commands.deploy.archive import _get_large_files, archive from fastapi_cloud_cli.commands.deploy.cloud import ( AppResponse, + ArchiveTooLargeError, CreateDeploymentResponse, _create_deployment, _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 ( + _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 @@ -47,6 +52,18 @@ def _get_deploy_output(deployment: CreateDeploymentResponse) -> DeployOutput: ) +def _fail_archive_too_large( + toolkit: FastAPIRichToolkit, progress: Progress, message: str +) -> NoReturn: + hint = "You can exclude files from the deployment with a .fastapicloudignore file." + + if toolkit.mode == "json": + toolkit.fail("invalid_input", message, hint=hint) + + progress.set_error(f"{message}\n\n[dim]hint: {hint}[/]") + raise typer.Exit(1) from None + + def _get_large_file_warnings( large_files: list[tuple[Path, int]], *, @@ -313,6 +330,7 @@ def deploy( logger.debug("Creating archive for deployment") archive_path = Path(temp_dir) / "archive.tar" archive(path_to_deploy, archive_path) + archive_size = archive_path.stat().st_size with ( toolkit.progress( @@ -324,7 +342,15 @@ def deploy( client.handle_http_errors(progress, toolkit=toolkit), ): logger.debug("Creating deployment for app: %s", app.id) - deployment = _create_deployment(client=client, app_id=app.id) + + try: + deployment = _create_deployment( + client=client, + app_id=app.id, + archive_size_bytes=archive_size, + ) + except ArchiveTooLargeError as e: + _fail_archive_too_large(toolkit, progress, str(e)) try: progress.log( @@ -335,6 +361,7 @@ def deploy( fastapi_client=client, deployment_id=deployment.id, archive_path=archive_path, + archive_size=archive_size, progress=progress, ) @@ -342,6 +369,9 @@ def deploy( except KeyboardInterrupt: _cancel_upload(client=client, deployment_id=deployment.id) raise + except ArchiveTooLargeError as e: + _cancel_upload(client=client, deployment_id=deployment.id) + _fail_archive_too_large(toolkit, progress, str(e)) if will_wait: logger.debug("Waiting for deployment to complete") diff --git a/src/fastapi_cloud_cli/commands/deploy/upload.py b/src/fastapi_cloud_cli/commands/deploy/upload.py index 10b8c482..f2096993 100644 --- a/src/fastapi_cloud_cli/commands/deploy/upload.py +++ b/src/fastapi_cloud_cli/commands/deploy/upload.py @@ -1,12 +1,16 @@ 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 -from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse +from fastapi_cloud_cli.commands.deploy.cloud import ( + ArchiveTooLargeError, + CreateDeploymentResponse, +) from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.progress_file import ProgressFile @@ -39,13 +43,23 @@ def _format_size(size_in_bytes: int) -> str: return f"{size_in_bytes} bytes" +def _get_s3_error_code(response: Response) -> str | None: + """Extract the error code from an S3 XML error response.""" + try: + root = ET.fromstring(response.text) + except ET.ParseError: + return None + + return root.findtext("Code") + + def _upload_deployment( fastapi_client: APIClient, deployment_id: str, archive_path: Path, + archive_size: int, progress: Progress, ) -> CreateDeploymentResponse: - archive_size = archive_path.stat().st_size archive_size_str = _format_size(archive_size) progress.log(f"Uploading deployment ({archive_size_str})...") @@ -79,6 +93,15 @@ 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) + + if _get_s3_error_code(upload_response) == "EntityTooLarge": + raise ArchiveTooLargeError( + f"The deployment archive is {archive_size_str}, " + "which exceeds the maximum allowed size." + ) + upload_response.raise_for_status() logger.debug("File upload completed successfully") diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 80390703..a54d297a 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1672,6 +1672,214 @@ def test_cancel_upload_swallows_exceptions( assert "HTTPStatusError" not in result.output +S3_ENTITY_TOO_LARGE_RESPONSE = ( + '' + "EntityTooLarge" + "Your proposed upload exceeds the maximum allowed size" + "1048580024" + "1048576000" + "M4MJM31KD5AHTGJEabc123" +) + + +def _mock_deploy_until_upload( + respx_mock: respx.MockRouter, + tmp_path: Path, + app_data: RandomApp, + deployment_data: dict[str, str], + upload_response: Response, +) -> None: + app_id = app_data["id"] + + config_path = tmp_path / ".fastapicloud" / "cloud.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') + + respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) + respx_mock.post(f"/apps/{app_id}/deployments/").mock( + return_value=Response(201, json=deployment_data) + ) + respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( + return_value=Response( + 200, + json={"url": "http://test.com", "fields": {"key": "value"}}, + ) + ) + respx_mock.post("http://test.com", data={"key": "value"}).mock( + return_value=upload_response + ) + + +@pytest.mark.respx +def test_deploy_shows_error_when_archive_is_too_large( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + _mock_deploy_until_upload( + respx_mock, + tmp_path, + app_data, + deployment_data, + Response(400, text=S3_ENTITY_TOO_LARGE_RESPONSE), + ) + upload_cancelled_route = respx_mock.post( + f"/deployments/{deployment_data['id']}/upload-cancelled" + ).mock(return_value=Response(200)) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + output = " ".join(result.output.split()) + + assert result.exit_code == 1 + assert "exceeds the maximum allowed size" in output + assert ".fastapicloudignore" in output + assert "Something went wrong" not in output + assert upload_cancelled_route.called + + +@pytest.mark.respx +def test_deploy_json_shows_error_when_archive_is_too_large( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + _mock_deploy_until_upload( + respx_mock, + tmp_path, + app_data, + deployment_data, + Response(400, text=S3_ENTITY_TOO_LARGE_RESPONSE), + ) + upload_cancelled_route = respx_mock.post( + f"/deployments/{deployment_data['id']}/upload-cancelled" + ).mock(return_value=Response(200)) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + + error = json.loads(result.stdout)["error"] + assert error["code"] == "invalid_input" + assert "exceeds the maximum allowed size" in error["message"] + assert error["hint"] == ( + "You can exclude files from the deployment with a .fastapicloudignore file." + ) + assert upload_cancelled_route.called + + +def _mock_deploy_until_deployment_creation( + respx_mock: respx.MockRouter, + tmp_path: Path, + app_data: RandomApp, + creation_response: Response, +) -> respx.Route: + app_id = app_data["id"] + + config_path = tmp_path / ".fastapicloud" / "cloud.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f'{{"app_id": "{app_id}", "team_id": "some-team-id"}}') + + respx_mock.get(f"/apps/{app_id}").mock(return_value=Response(200, json=app_data)) + + return respx_mock.post(f"/apps/{app_id}/deployments/").mock( + return_value=creation_response + ) + + +@pytest.mark.respx +def test_deploy_shows_error_when_creation_rejects_archive_size( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + + # no upload routes are mocked, any upload attempt would fail the test + create_deployment_route = _mock_deploy_until_deployment_creation( + respx_mock, + tmp_path, + app_data, + Response( + 413, + json={ + "detail": "App source code exceeds the maximum allowed size of 1000.0 MB" + }, + ), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + output = " ".join(result.output.split()) + + assert result.exit_code == 1 + assert "App source code exceeds the maximum allowed size of 1000.0 MB" in output + assert ".fastapicloudignore" in output + assert "Something went wrong" not in output + + request_body = json.loads(create_deployment_route.calls.last.request.content) + assert request_body["archive_size_bytes"] > 0 + + +@pytest.mark.respx +def test_deploy_json_shows_error_when_creation_rejects_archive_size( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + + _mock_deploy_until_deployment_creation( + respx_mock, + tmp_path, + app_data, + Response( + 413, + json={ + "detail": "App source code exceeds the maximum allowed size of 1000.0 MB" + }, + ), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy", "--json"]) + + assert result.exit_code == 1 + + error = json.loads(result.stdout)["error"] + assert error["code"] == "invalid_input" + assert ( + error["message"] + == "App source code exceeds the maximum allowed size of 1000.0 MB" + ) + assert error["hint"] == ( + "You can exclude files from the deployment with a .fastapicloudignore file." + ) + + +@pytest.mark.respx +def test_deploy_shows_generic_error_for_other_upload_failures( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + deployment_data = _get_random_deployment(app_id=app_data["id"]) + + _mock_deploy_until_upload( + respx_mock, + tmp_path, + app_data, + deployment_data, + Response(400, text="not an xml body"), + ) + + with changing_dir(tmp_path): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 1 + assert "Something went wrong" in result.output + + @pytest.mark.respx def test_deploy_successfully_with_token( logged_out_cli: None, tmp_path: Path, respx_mock: respx.MockRouter From f4d1bfed2128dada4f7f02e7542ec687a7acc5bf Mon Sep 17 00:00:00 2001 From: Marco Burro Date: Tue, 28 Jul 2026 15:20:32 +0200 Subject: [PATCH 2/2] Address PR comments --- .../commands/deploy/command.py | 32 +++----- .../commands/deploy/upload.py | 24 +----- src/fastapi_cloud_cli/utils/api.py | 3 + tests/test_cli_deploy.py | 74 +------------------ 4 files changed, 16 insertions(+), 117 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index 163859c8..dbc2e139 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -1,11 +1,10 @@ import logging import tempfile from pathlib import Path -from typing import Annotated, Any, NoReturn, cast +from typing import Annotated, Any, cast import typer from pydantic import BaseModel -from rich_toolkit.progress import Progress from fastapi_cloud_cli.commands.deploy.archive import _get_large_files, archive from fastapi_cloud_cli.commands.deploy.cloud import ( @@ -16,10 +15,7 @@ _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 _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 @@ -52,18 +48,6 @@ def _get_deploy_output(deployment: CreateDeploymentResponse) -> DeployOutput: ) -def _fail_archive_too_large( - toolkit: FastAPIRichToolkit, progress: Progress, message: str -) -> NoReturn: - hint = "You can exclude files from the deployment with a .fastapicloudignore file." - - if toolkit.mode == "json": - toolkit.fail("invalid_input", message, hint=hint) - - progress.set_error(f"{message}\n\n[dim]hint: {hint}[/]") - raise typer.Exit(1) from None - - def _get_large_file_warnings( large_files: list[tuple[Path, int]], *, @@ -350,7 +334,14 @@ def deploy( archive_size_bytes=archive_size, ) except ArchiveTooLargeError as e: - _fail_archive_too_large(toolkit, progress, str(e)) + toolkit.fail( + "invalid_input", + str(e), + hint=( + "You can exclude files from the deployment " + "with a .fastapicloudignore file." + ), + ) try: progress.log( @@ -369,9 +360,6 @@ def deploy( except KeyboardInterrupt: _cancel_upload(client=client, deployment_id=deployment.id) raise - except ArchiveTooLargeError as e: - _cancel_upload(client=client, deployment_id=deployment.id) - _fail_archive_too_large(toolkit, progress, str(e)) if will_wait: logger.debug("Waiting for deployment to complete") diff --git a/src/fastapi_cloud_cli/commands/deploy/upload.py b/src/fastapi_cloud_cli/commands/deploy/upload.py index f2096993..2fcd1d69 100644 --- a/src/fastapi_cloud_cli/commands/deploy/upload.py +++ b/src/fastapi_cloud_cli/commands/deploy/upload.py @@ -1,16 +1,12 @@ import logging -import xml.etree.ElementTree as ET from pathlib import Path from typing import BinaryIO, cast -from httpx import Client, Response +from httpx import Client from pydantic import BaseModel from rich_toolkit.progress import Progress -from fastapi_cloud_cli.commands.deploy.cloud import ( - ArchiveTooLargeError, - CreateDeploymentResponse, -) +from fastapi_cloud_cli.commands.deploy.cloud import CreateDeploymentResponse from fastapi_cloud_cli.utils.api import APIClient from fastapi_cloud_cli.utils.progress_file import ProgressFile @@ -43,16 +39,6 @@ def _format_size(size_in_bytes: int) -> str: return f"{size_in_bytes} bytes" -def _get_s3_error_code(response: Response) -> str | None: - """Extract the error code from an S3 XML error response.""" - try: - root = ET.fromstring(response.text) - except ET.ParseError: - return None - - return root.findtext("Code") - - def _upload_deployment( fastapi_client: APIClient, deployment_id: str, @@ -96,12 +82,6 @@ def progress_callback(bytes_read: int) -> None: if upload_response.is_error: logger.debug("File upload failed with response: %s", upload_response.text) - if _get_s3_error_code(upload_response) == "EntityTooLarge": - raise ArchiveTooLargeError( - f"The deployment archive is {archive_size_str}, " - "which exceeds the maximum allowed size." - ) - upload_response.raise_for_status() logger.debug("File upload completed successfully") diff --git a/src/fastapi_cloud_cli/utils/api.py b/src/fastapi_cloud_cli/utils/api.py index 35bf364e..ec021eff 100644 --- a/src/fastapi_cloud_cli/utils/api.py +++ b/src/fastapi_cloud_cli/utils/api.py @@ -146,6 +146,7 @@ class DeploymentStatus(str, Enum): building = "building" extracting = "extracting" extracting_failed = "extracting_failed" + extracting_failed_archive_too_large = "extracting_failed_archive_too_large" building_image = "building_image" building_image_failed = "building_image_failed" deploying = "deploying" @@ -166,6 +167,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: cls.building: "Building", cls.extracting: "Extracting Upload", cls.extracting_failed: "Extraction Failed", + cls.extracting_failed_archive_too_large: "Archive Too Large", cls.building_image: "Building Image", cls.building_image_failed: "Build Failed", cls.deploying: "Deploying Image", @@ -186,6 +188,7 @@ def to_human_readable(cls, status: "DeploymentStatus") -> str: DeploymentStatus.deploying_failed, DeploymentStatus.building_image_failed, DeploymentStatus.extracting_failed, + DeploymentStatus.extracting_failed_archive_too_large, } TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index a54d297a..72be2349 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -1672,16 +1672,6 @@ def test_cancel_upload_swallows_exceptions( assert "HTTPStatusError" not in result.output -S3_ENTITY_TOO_LARGE_RESPONSE = ( - '' - "EntityTooLarge" - "Your proposed upload exceeds the maximum allowed size" - "1048580024" - "1048576000" - "M4MJM31KD5AHTGJEabc123" -) - - def _mock_deploy_until_upload( respx_mock: respx.MockRouter, tmp_path: Path, @@ -1710,68 +1700,6 @@ def _mock_deploy_until_upload( ) -@pytest.mark.respx -def test_deploy_shows_error_when_archive_is_too_large( - logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter -) -> None: - app_data = _get_random_app() - deployment_data = _get_random_deployment(app_id=app_data["id"]) - - _mock_deploy_until_upload( - respx_mock, - tmp_path, - app_data, - deployment_data, - Response(400, text=S3_ENTITY_TOO_LARGE_RESPONSE), - ) - upload_cancelled_route = respx_mock.post( - f"/deployments/{deployment_data['id']}/upload-cancelled" - ).mock(return_value=Response(200)) - - with changing_dir(tmp_path): - result = runner.invoke(app, ["deploy"]) - - output = " ".join(result.output.split()) - - assert result.exit_code == 1 - assert "exceeds the maximum allowed size" in output - assert ".fastapicloudignore" in output - assert "Something went wrong" not in output - assert upload_cancelled_route.called - - -@pytest.mark.respx -def test_deploy_json_shows_error_when_archive_is_too_large( - logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter -) -> None: - app_data = _get_random_app() - deployment_data = _get_random_deployment(app_id=app_data["id"]) - - _mock_deploy_until_upload( - respx_mock, - tmp_path, - app_data, - deployment_data, - Response(400, text=S3_ENTITY_TOO_LARGE_RESPONSE), - ) - upload_cancelled_route = respx_mock.post( - f"/deployments/{deployment_data['id']}/upload-cancelled" - ).mock(return_value=Response(200)) - - with changing_dir(tmp_path): - result = runner.invoke(app, ["deploy", "--json"]) - - assert result.exit_code == 1 - - error = json.loads(result.stdout)["error"] - assert error["code"] == "invalid_input" - assert "exceeds the maximum allowed size" in error["message"] - assert error["hint"] == ( - "You can exclude files from the deployment with a .fastapicloudignore file." - ) - assert upload_cancelled_route.called - - def _mock_deploy_until_deployment_creation( respx_mock: respx.MockRouter, tmp_path: Path, @@ -1859,7 +1787,7 @@ def test_deploy_json_shows_error_when_creation_rejects_archive_size( @pytest.mark.respx -def test_deploy_shows_generic_error_for_other_upload_failures( +def test_deploy_shows_error_when_upload_fails( logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter ) -> None: app_data = _get_random_app()