feat(cli): record why a deployment create failed

`crewai deploy create` counts every attempt (`Create Crew Deployment`) and every
success (`Crew Deployment Created`), but the gap between them carried no cause:
among clients able to emit the success span, the CLI succeeds 96.7% of the time
and the run TUI 36.4%, and nothing said why. A third span, `Crew Deployment
Failed`, now fires for every failure after the attempt is counted, with a closed
vocabulary `reason` (api_4xx, api_5xx, invalid_response, network_error,
zip_error, user_declined, unexpected), the HTTP `status_code` when the API
answered, and the existing `source`. Never the error message.

The request path is factored into `_request_crew_creation`; every exception is
classified, reported and re-raised unchanged, so CLI and TUI behaviour is the
same as before. HTTP failures are classified before `_validate_response`, which
still prints and exits as it did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Joao Moura
2026-09-14 00:15:18 -07:00
parent 9393a47f31
commit 22ed8a638c
8 changed files with 374 additions and 18 deletions

View File

@@ -825,6 +825,199 @@ class TestDeployCommand(unittest.TestCase):
telemetry.create_crew_deployment_span.assert_called_once_with(source="cli")
telemetry.crew_deployment_created_span.assert_not_called()
# --- why a create failed (the Crew Deployment Failed span) ---------------
def _git_path(self, mock_input, mock_repository, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
@staticmethod
def _api_response(status_code: int, body: object) -> MagicMock:
response = MagicMock()
response.status_code = status_code
response.is_success = 200 <= status_code < 300
if isinstance(body, Exception):
response.json.side_effect = body
else:
response.json.return_value = body
return response
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_rejected_create_reports_the_api_class_and_status(
self, mock_input, mock_repository, mock_fetch_env
):
"""A 4xx names the class and carries the exact code; the message never leaves."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
422, {"name": ["has already been taken"]}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_4xx", source="cli", status_code=422
)
telemetry.crew_deployment_created_span.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_server_error_reports_api_5xx(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
503, {"error": "upstream unavailable"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_5xx", source="cli", status_code=503
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_non_json_success_body_reports_invalid_response(
self, mock_input, mock_repository, mock_fetch_env
):
"""_validate_response rejects it, so it is a failure with a 2xx attached."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
200, ValueError("not json")
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"invalid_response", source="cli", status_code=200
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_transport_failure_reports_network_error_and_propagates(
self, mock_input, mock_repository, mock_fetch_env
):
"""No response, so no status; the exception reaches the caller unchanged."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.side_effect = httpx.ConnectError("refused")
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(httpx.ConnectError, match="refused"):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"network_error", source="cli"
)
telemetry.crew_deployment_created_span.assert_not_called()
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_a_failed_archive_reports_zip_error(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
initialized_repository = MagicMock()
initialized_repository.origin_url.return_value = None
mock_repository.initialize.return_value = initialized_repository
mock_create_project_zip.side_effect = ValueError(
"No deployable project files were found."
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(ValueError, match="No deployable project files"):
self.deploy_command.create_crew(skip_validate=True, confirm=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"zip_error", source="cli"
)
self.mock_client.create_crew_from_zip.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_an_abort_at_the_prompt_reports_user_declined(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
mock_input.side_effect = KeyboardInterrupt
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(KeyboardInterrupt):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"user_declined", source="cli"
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_failure_from_the_run_tui_keeps_its_source(
self, mock_input, mock_repository, mock_fetch_env
):
"""The TUI succeeds far less often than the CLI; the split must survive."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
403, {"error": "forbidden"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(
skip_validate=True, confirm=True, source="tui"
)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_4xx", source="tui", status_code=403
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_successful_create_reports_no_failure(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
201, {"uuid": "new-uuid", "status": "created"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_not_called()
telemetry.crew_deployment_created_span.assert_called_once_with(
uuid="new-uuid", source="cli"
)
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")