Skip to content

Commit 278da15

Browse files
seligj95Copilot
andcommitted
[App Service] Fix #29290: Improve error messages for az webapp deploy --src-url failures
When `az webapp deploy --src-url` receives non-2xx HTTP responses, `send_raw_request` raises `HTTPError` before the deploy-specific error handling code is reached. This results in bare error messages like "Bad Request" with no actionable guidance. This change adds a `_send_deploy_request` wrapper that catches HTTP errors on the --src-url ARM deploy path and provides clear, actionable messages: - 400 Bad Request: troubleshooting guidance for URL accessibility, SAS tokens, and artifact type mismatches - 404 Not Found: guidance to verify resource group, app name, and slot - 409 Conflict: message about in-progress deployments The wrapper uses the server-provided reason phrase and includes any response body details when available. Unhandled status codes re-raise the original HTTPError unchanged. Fixes #29290 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dc9fdb3 commit 278da15

3 files changed

Lines changed: 166 additions & 6 deletions

File tree

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,11 @@ cmd_coverage/*
123123

124124
# Ignore test results
125125
test_results.xml
126+
127+
# Squad agent framework (local only — never commit)
128+
.squad/
129+
.squad-workstream
130+
.github/agents/squad.agent.md
131+
.github/workflows/squad-*.yml
132+
.github/workflows/sync-squad-labels.yml
133+
.copilot/

src/azure-cli/azure/cli/command_modules/appservice/custom.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9781,6 +9781,43 @@ def _warmup_kudu_and_get_cookie_internal(params):
97819781
return None
97829782

97839783

9784+
def _send_deploy_request(cli_ctx, deploy_url, body):
9785+
"""Wrapper around send_raw_request for --src-url deployments that provides
9786+
actionable error messages instead of bare HTTP status codes."""
9787+
from azure.cli.core.azclierror import HTTPError
9788+
try:
9789+
return send_raw_request(cli_ctx, "PUT", deploy_url, body=body)
9790+
except HTTPError as ex:
9791+
resp = ex.response
9792+
status_code = resp.status_code if resp is not None else None
9793+
response_text = resp.text if resp is not None else ""
9794+
reason = getattr(resp, 'reason', None) or "Unknown"
9795+
error_detail = f" Details: {response_text}" if response_text else ""
9796+
if status_code == 400:
9797+
raise CLIError(
9798+
f"Deployment from URL failed with status 400 ({reason}).{error_detail}\n"
9799+
"Possible causes:\n"
9800+
" - The source URL is not publicly accessible or the SAS token has expired\n"
9801+
" - The URL does not point to a valid deployment artifact\n"
9802+
" - The artifact type does not match the file content (e.g., --type zip for a non-zip file)\n"
9803+
"Please verify the URL is accessible and the artifact type is correct."
9804+
) from ex
9805+
if status_code == 404:
9806+
raise ResourceNotFoundError(
9807+
f"Deployment from URL failed with status 404 ({reason}).{error_detail}\n"
9808+
"The target app or OneDeploy endpoint could not be found. "
9809+
"Verify that the resource group, app name, and slot (if any) are correct, "
9810+
"and that the app still exists."
9811+
) from ex
9812+
if status_code == 409:
9813+
raise ValidationError(
9814+
f"Deployment from URL failed with status 409 ({reason}).{error_detail}\n"
9815+
"Another deployment is currently in progress. Please wait for the existing "
9816+
"deployment to complete before starting a new one."
9817+
) from ex
9818+
raise
9819+
9820+
97849821
def _make_onedeploy_request(params):
97859822
import requests
97869823
from azure.cli.core.util import should_disable_connection_verify
@@ -9828,16 +9865,16 @@ def _make_onedeploy_request(params):
98289865
if cookies is None:
98299866
logger.info("Failed to fetch affinity cookie for Kudu. "
98309867
"Deployment will proceed without pre-warming a Kudu instance.")
9831-
response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body)
9868+
response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body)
98329869
else:
98339870
deploy_arm_url = _build_onedeploy_url(params, cookies.get("ARRAffinity"))
9834-
response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_arm_url, body=body)
9871+
response = _send_deploy_request(params.cmd.cli_ctx, deploy_arm_url, body)
98359872
except Exception as ex: # pylint: disable=broad-except
98369873
logger.info("Failed to deploy using instances endpoint. "
98379874
"Deployment will proceed without pre-warming a Kudu instance. Ex: %s", ex)
9838-
response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body)
9875+
response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body)
98399876
else:
9840-
response = send_raw_request(params.cmd.cli_ctx, "PUT", deploy_url, body=body)
9877+
response = _send_deploy_request(params.cmd.cli_ctx, deploy_url, body)
98419878
poll_async_deployment_for_debugging = False
98429879

98439880
# check the status of deployment

src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from knack.util import CLIError
1313
from azure.cli.core.azclierror import (InvalidArgumentValueError,
1414
MutuallyExclusiveArgumentError,
15-
AzureResponseError)
15+
AzureResponseError,
16+
ResourceNotFoundError,
17+
ValidationError)
1618
from azure.cli.command_modules.appservice.custom import (set_deployment_user,
1719
update_git_token, add_hostname,
1820
update_site_configs,
@@ -33,7 +35,8 @@
3335
add_github_actions,
3436
update_app_settings,
3537
update_application_settings_polling,
36-
update_webapp)
38+
update_webapp,
39+
_send_deploy_request)
3740

3841
# pylint: disable=line-too-long
3942
from azure.cli.core.profiles import ResourceType
@@ -639,6 +642,118 @@ def test_update_webapp_platform_release_channel_latest(self):
639642
self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest")
640643

641644

645+
class TestSendDeployRequest(unittest.TestCase):
646+
"""Tests for _send_deploy_request wrapper that provides actionable error messages."""
647+
648+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
649+
def test_success_passes_through(self, send_raw_request_mock):
650+
"""Successful responses (200/202) should pass through unchanged."""
651+
cli_ctx = _get_test_cmd().cli_ctx
652+
response = mock.MagicMock()
653+
response.status_code = 202
654+
send_raw_request_mock.return_value = response
655+
656+
result = _send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
657+
658+
self.assertEqual(result, response)
659+
send_raw_request_mock.assert_called_once_with(
660+
cli_ctx, "PUT", 'https://management.azure.com/deploy', body='{"properties":{}}'
661+
)
662+
663+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
664+
def test_400_with_empty_body_gives_actionable_error(self, send_raw_request_mock):
665+
"""HTTP 400 with empty body should produce a helpful error message instead of bare 'Bad Request'."""
666+
from azure.cli.core.azclierror import HTTPError
667+
cli_ctx = _get_test_cmd().cli_ctx
668+
669+
response = mock.MagicMock()
670+
response.status_code = 400
671+
response.reason = "Bad Request"
672+
response.text = ""
673+
send_raw_request_mock.side_effect = HTTPError("Bad Request", response)
674+
675+
with self.assertRaises(CLIError) as ctx:
676+
_send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
677+
678+
error_msg = str(ctx.exception)
679+
self.assertIn("Deployment from URL failed with status 400 (Bad Request)", error_msg)
680+
self.assertIn("source URL is not publicly accessible", error_msg)
681+
self.assertIn("SAS token has expired", error_msg)
682+
self.assertIn("artifact type does not match", error_msg)
683+
684+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
685+
def test_400_with_response_body_includes_details(self, send_raw_request_mock):
686+
"""HTTP 400 with a response body should include the details in the error."""
687+
from azure.cli.core.azclierror import HTTPError
688+
cli_ctx = _get_test_cmd().cli_ctx
689+
690+
response = mock.MagicMock()
691+
response.status_code = 400
692+
response.reason = "Bad Request"
693+
response.text = "Invalid package URI"
694+
send_raw_request_mock.side_effect = HTTPError("Bad Request(Invalid package URI)", response)
695+
696+
with self.assertRaises(CLIError) as ctx:
697+
_send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
698+
699+
error_msg = str(ctx.exception)
700+
self.assertIn("Deployment from URL failed with status 400", error_msg)
701+
self.assertIn("Invalid package URI", error_msg)
702+
703+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
704+
def test_404_gives_not_found_error(self, send_raw_request_mock):
705+
"""HTTP 404 should raise ResourceNotFoundError with guidance."""
706+
from azure.cli.core.azclierror import HTTPError
707+
cli_ctx = _get_test_cmd().cli_ctx
708+
709+
response = mock.MagicMock()
710+
response.status_code = 404
711+
response.reason = "Not Found"
712+
response.text = ""
713+
send_raw_request_mock.side_effect = HTTPError("Not Found", response)
714+
715+
with self.assertRaises(ResourceNotFoundError) as ctx:
716+
_send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
717+
718+
error_msg = str(ctx.exception)
719+
self.assertIn("Deployment from URL failed with status 404", error_msg)
720+
self.assertIn("app or OneDeploy endpoint could not be found", error_msg)
721+
722+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
723+
def test_409_gives_conflict_error(self, send_raw_request_mock):
724+
"""HTTP 409 should raise ValidationError about in-progress deployment."""
725+
from azure.cli.core.azclierror import HTTPError
726+
cli_ctx = _get_test_cmd().cli_ctx
727+
728+
response = mock.MagicMock()
729+
response.status_code = 409
730+
response.reason = "Conflict"
731+
response.text = ""
732+
send_raw_request_mock.side_effect = HTTPError("Conflict", response)
733+
734+
with self.assertRaises(ValidationError) as ctx:
735+
_send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
736+
737+
error_msg = str(ctx.exception)
738+
self.assertIn("Deployment from URL failed with status 409", error_msg)
739+
self.assertIn("Another deployment is currently in progress", error_msg)
740+
741+
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
742+
def test_unhandled_error_reraises(self, send_raw_request_mock):
743+
"""Unhandled HTTP errors (e.g., 500) should re-raise without modification."""
744+
from azure.cli.core.azclierror import HTTPError
745+
cli_ctx = _get_test_cmd().cli_ctx
746+
747+
response = mock.MagicMock()
748+
response.status_code = 500
749+
response.reason = "Internal Server Error"
750+
response.text = "Internal Server Error"
751+
send_raw_request_mock.side_effect = HTTPError("Internal Server Error", response)
752+
753+
with self.assertRaises(HTTPError):
754+
_send_deploy_request(cli_ctx, 'https://management.azure.com/deploy', '{"properties":{}}')
755+
756+
642757
class FakedResponse: # pylint: disable=too-few-public-methods
643758
def __init__(self, status_code):
644759
self.status_code = status_code

0 commit comments

Comments
 (0)