From 60e4c0b5293e355c8d3663ca584ff3a69434c45d Mon Sep 17 00:00:00 2001 From: Marco Burro Date: Thu, 16 Jul 2026 13:10:59 +0200 Subject: [PATCH] =?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/command.py | 19 +- .../commands/deploy/upload.py | 45 ++- tests/test_cli_deploy.py | 285 ++++++++++++++++-- 3 files changed, 329 insertions(+), 20 deletions(-) diff --git a/src/fastapi_cloud_cli/commands/deploy/command.py b/src/fastapi_cloud_cli/commands/deploy/command.py index 89dcafa..8fb5a4f 100644 --- a/src/fastapi_cloud_cli/commands/deploy/command.py +++ b/src/fastapi_cloud_cli/commands/deploy/command.py @@ -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 @@ -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") diff --git a/src/fastapi_cloud_cli/commands/deploy/upload.py b/src/fastapi_cloud_cli/commands/deploy/upload.py index 10b8c48..7c2e828 100644 --- a/src/fastapi_cloud_cli/commands/deploy/upload.py +++ b/src/fastapi_cloud_cli/commands/deploy/upload.py @@ -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 @@ -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: @@ -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, @@ -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: @@ -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") diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index 8039070..15abdf8 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -208,7 +208,11 @@ def test_deploy_json_uses_configured_app_and_skips_waiting_by_default( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -294,7 +298,11 @@ def test_deploy_json_includes_large_file_warnings( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -823,7 +831,11 @@ def test_updates_app_directory_via_api_when_changed( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( @@ -896,7 +908,11 @@ def test_does_not_update_app_directory_when_unchanged( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload-complete").mock( @@ -972,6 +988,7 @@ def test_exits_successfully_when_deployment_is_done( json={ "url": "http://test.com", "fields": {"key": "value"}, + "max_size_bytes": 1048576000, }, ) ) @@ -1038,7 +1055,11 @@ def test_exits_successfully_when_deployment_is_done_when_app_is_configured( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -1105,7 +1126,11 @@ def test_exits_with_error_when_deployment_fails_to_build( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -1161,7 +1186,11 @@ def test_shows_error_when_deployment_build_fails( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -1246,6 +1275,7 @@ def _deploy_without_waiting(respx_mock: respx.MockRouter, tmp_path: Path) -> Res json={ "url": "http://test.com", "fields": {"key": "value"}, + "max_size_bytes": 1048576000, }, ) ) @@ -1363,7 +1393,12 @@ def test_shows_error_message_on_build_exception( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -1409,7 +1444,12 @@ def test_shows_error_message_on_build_log_http_error( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -1455,7 +1495,12 @@ def test_short_wait_messages( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -1539,7 +1584,12 @@ def test_long_wait_messages( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -1672,6 +1722,175 @@ 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"}, + "max_size_bytes": 1048576000, + }, + ) + ) + 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 of 1000.00 MB" 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 of 1000.00 MB" in error["message"] + assert error["hint"] == ( + "You can exclude files from the deployment with a .fastapicloudignore file." + ) + assert upload_cancelled_route.called + + +@pytest.mark.respx +def test_deploy_skips_upload_when_archive_exceeds_max_size( + logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter +) -> None: + app_data = _get_random_app() + app_id = app_data["id"] + deployment_data = _get_random_deployment(app_id=app_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) + ) + # a 10 bytes limit, so any archive is over it; no S3 route is mocked, + # any upload attempt would fail the test + respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( + return_value=Response( + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 10, + }, + ) + ) + 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 of 10 bytes" in output + assert ".fastapicloudignore" in output + assert upload_cancelled_route.called + + +@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 @@ -1701,7 +1920,11 @@ def test_deploy_successfully_with_token( ).mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -1836,7 +2059,11 @@ def test_upload_deployment_progress( respx_mock.post(f"/deployments/{deployment_id}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -1895,7 +2122,11 @@ def test_deploy_with_app_id_arg( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -1947,7 +2178,11 @@ def test_deploy_with_app_id_from_env_var( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -2004,7 +2239,11 @@ def test_deploy_with_app_id_matching_local_config( respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( 200, - json={"url": "http://test.com", "fields": {"key": "value"}}, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) @@ -2153,7 +2392,12 @@ def _setup_deployment_mocks( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock( @@ -2325,7 +2569,12 @@ def test_ctrl_c_during_build_streaming_shows_cancelled( ) respx_mock.post(f"/deployments/{deployment_data['id']}/upload").mock( return_value=Response( - 200, json={"url": "http://test.com", "fields": {"key": "value"}} + 200, + json={ + "url": "http://test.com", + "fields": {"key": "value"}, + "max_size_bytes": 1048576000, + }, ) ) respx_mock.post("http://test.com", data={"key": "value"}).mock(