From 1c5be315e49cc270dede18b0415bd9f48a4114be Mon Sep 17 00:00:00 2001 From: Hugo Durand Date: Tue, 22 Sep 2026 16:27:39 -0400 Subject: [PATCH] fix(cli): show the control plane's reason without the HTTP envelope --- libs/cli/langgraph_cli/deploy.py | 6 ++-- libs/cli/langgraph_cli/host_backend.py | 23 ++++++++++++-- .../unit_tests/cli/test_deploy_command.py | 4 ++- .../cli/tests/unit_tests/test_host_backend.py | 31 +++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/libs/cli/langgraph_cli/deploy.py b/libs/cli/langgraph_cli/deploy.py index 4020549e1..dd4e7e051 100644 --- a/libs/cli/langgraph_cli/deploy.py +++ b/libs/cli/langgraph_cli/deploy.py @@ -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( diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index 31dc652eb..b15325587 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -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 diff --git a/libs/cli/tests/unit_tests/cli/test_deploy_command.py b/libs/cli/tests/unit_tests/cli/test_deploy_command.py index bfcf9118b..9e08c7957 100644 --- a/libs/cli/tests/unit_tests/cli/test_deploy_command.py +++ b/libs/cli/tests/unit_tests/cli/test_deploy_command.py @@ -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( diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index c397e826f..c6216b1d1 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -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)