fix(cli): show the control plane's reason without the HTTP envelope

This commit is contained in:
Hugo Durand
2026-09-22 16:27:39 -04:00
parent e42ab5d589
commit 1c5be315e4
4 changed files with 58 additions and 6 deletions
+4 -2
View File
@@ -1452,7 +1452,9 @@ def _resolve_or_create(
def _needs_a_listener(err: HostBackendError) -> bool:
return err.status_code == 400 and _LISTENER_REQUIRED_MARKER in err.message
return err.status_code == 400 and _LISTENER_REQUIRED_MARKER in (
err.detail or err.message
)
def _available_listeners(client: HostBackendClient) -> tuple[Listener, ...]:
@@ -1589,7 +1591,7 @@ class CustomerRegistrySource:
if _needs_a_listener(err):
raise click.UsageError(
"This workspace deploys through a listener. Re-run with "
f"--listener-id and --k8s-namespace.\n{err.message}"
f"--listener-id and --k8s-namespace.\n{err.detail or err.message}"
) from None
raise
return DeployOutcome(
+20 -3
View File
@@ -103,9 +103,24 @@ def _resources(payload: object) -> list[dict[str, Any]]:
class HostBackendError(click.ClickException):
"""Raised when the host backend returns an error response."""
def __init__(self, message: str, status_code: int | None = None):
def __init__(
self,
message: str,
status_code: int | None = None,
detail: str | None = None,
):
super().__init__(message)
self.status_code = status_code
self.detail = detail
def _error_detail(response: httpx.Response) -> str | None:
try:
body = response.json()
except ValueError:
return None
detail = body.get("detail") if isinstance(body, dict) else None
return detail if isinstance(detail, str) else None
class HostBackendClient:
@@ -153,10 +168,12 @@ class HostBackendClient:
resp = self._client.request(method, path, json=payload, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as err:
detail = err.response.text or str(err.response.status_code)
detail = _error_detail(err.response)
reason = detail or err.response.text or str(err.response.status_code)
raise HostBackendError(
f"{method} {path} failed with status {err.response.status_code}: {detail}",
f"{method} {path} failed with status {err.response.status_code}: {reason}",
status_code=err.response.status_code,
detail=detail,
) from None
except httpx.TransportError as err:
raise HostBackendError(str(err)) from None
@@ -105,7 +105,7 @@ class ControlPlaneDouble:
)
if (method, path) == ("POST", "/v2/deployments"):
if self.create_error is not None:
return httpx.Response(400, text=self.create_error)
return httpx.Response(400, json={"detail": self.create_error})
return httpx.Response(201, json={"id": CREATED_ID, "tenant_id": "tenant-1"})
if path.endswith("/push-token"):
if self.push_token_status != 200:
@@ -886,6 +886,8 @@ def test_a_control_plane_that_demands_a_listener_names_the_flags(
assert "--listener-id" in result.output
assert "--k8s-namespace" in result.output
assert "listener-1" in result.output
assert "{" not in result.output
assert "POST /v2/deployments failed" not in result.output
def test_listener_flags_without_push_to_make_no_call_at_all(
@@ -655,3 +655,34 @@ def test_list_deployments_sends_one_name_filter(call, expected_params):
)
assert seen == expected_params
@pytest.mark.parametrize(
("body", "expected"),
[
pytest.param(
{"detail": "Source configuration error: bad listener"},
"Source configuration error: bad listener",
id="fastapi_detail_is_unwrapped",
),
pytest.param(
{"detail": {"loc": ["body"], "msg": "nope"}},
None,
id="a_structured_detail_is_left_alone",
),
pytest.param({"other": "shape"}, None, id="an_unknown_shape_is_left_alone"),
],
)
def test_error_detail_is_readable(body, expected):
c = HostBackendClient(
"https://api.example.com",
"key",
transport=httpx.MockTransport(lambda req: httpx.Response(400, json=body)),
)
with pytest.raises(HostBackendError) as error:
c.get_deployment("dep-1")
assert error.value.detail == expected
if expected is not None:
assert error.value.message.endswith(expected)