From d2f346295997f14237bc452b043ee21820370835 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sat, 24 Oct 2026 19:10:33 +0100 Subject: [PATCH 1/2] fix(no-ticket): show an error when the API host returns an HTML page A TLS-intercepting proxy, a captive portal, or a wrong --api-host can return an HTML page with status 200. The SDK turned the page into empty models, and each command then stopped with an unexpected error and sent an error report. The REST client now rejects a 2xx HTML response with an API error, so commands show the normal "Failed to ..." message and exit with code 1. Fixes CLOUDSMITH-CLI-D Fixes CLOUDSMITH-CLI-F Fixes CLOUDSMITH-CLI-N Fixes CLOUDSMITH-CLI-W Fixes CLOUDSMITH-CLI-V Fixes CLOUDSMITH-CLI-A Fixes CLOUDSMITH-CLI-T Fixes CLOUDSMITH-CLI-S Fixes CLOUDSMITH-CLI-Q Fixes CLOUDSMITH-CLI-P Fixes CLOUDSMITH-CLI-R Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++ .../cli/tests/commands/test_entitlements.py | 30 +++++++++++++++++++ cloudsmith_cli/core/rest.py | 14 +++++++++ cloudsmith_cli/core/tests/test_rest.py | 17 +++++++++++ 4 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0127743c..86c4628f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - After a SAML login, `cloudsmith auth` saves the Workspace to `config.ini`. It writes to the `[profile:NAME]` section of the selected profile, or to `[default]` when you do not select a profile. The CLI writes to the file or directory that `--config-file` gives, or else to your user-level `config.ini`. If the `config.ini` does not exist, the CLI creates it. +### Fixed + +- When the API host returns an HTML page instead of an API response, the CLI now shows an error and exits with code 1. Examples are a proxy login page, a captive portal, or an `--api-host` that points to a website. Before, commands such as `entitlements list`, `check service`, and `metadata list` stopped with an unexpected error and sent an error report. + ## [1.30.0] - 2026-10-22 ### Added diff --git a/cloudsmith_cli/cli/tests/commands/test_entitlements.py b/cloudsmith_cli/cli/tests/commands/test_entitlements.py index 3e61748a..8c4e82b4 100644 --- a/cloudsmith_cli/cli/tests/commands/test_entitlements.py +++ b/cloudsmith_cli/cli/tests/commands/test_entitlements.py @@ -1,9 +1,13 @@ +import httpretty +import httpretty.core import pytest from ....core.pagination import PageInfo from ...commands.entitlements import list_ as list_entitlements from ...commands.entitlements import print_entitlements +from ...commands.main import main from ...config import Options +from .conftest import FAKE_API_HOST @pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var") @@ -24,6 +28,32 @@ def test_entitlements_list_with_show_all(runner, organization, tmp_repository): assert "Invalid value for '--show-all'" not in result.output +@httpretty.activate(allow_net_connect=False) +def test_entitlements_list_html_response_fails_cleanly(runner, monkeypatch): + """A proxy login page instead of the API gives an error, not a crash.""" + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + httpretty.register_uri( + httpretty.GET, + "https://api.example.com/entitlements/o/r/", + body="Proxy login", + content_type="text/html", + ) + + result = runner.invoke( + main, + ["entitlements", "list", "o/r", "-k", "key", "--api-host", FAKE_API_HOST], + ) + + assert result.return_value == 1 + assert "Failed to get list of entitlements!" in result.output + assert "HTML page" in result.output + + def _make_entitlement(name): return { "name": name, diff --git a/cloudsmith_cli/core/rest.py b/cloudsmith_cli/core/rest.py index 4b6f6219..8915c57a 100644 --- a/cloudsmith_cli/core/rest.py +++ b/cloudsmith_cli/core/rest.py @@ -135,4 +135,18 @@ def request( if not 200 <= rest_resp.status <= 299: raise ApiException(http_resp=rest_resp) + _raise_for_html(rest_resp) + return rest_resp + + +def _raise_for_html(rest_resp: RestResponse) -> None: + """Reject an HTML page, such as a proxy login page, that has a 2xx status.""" + content_type = rest_resp.getheader("Content-Type") or "" + media_type = content_type.split(";", 1)[0].strip().lower() + if media_type == "text/html": + raise ApiException( + status=0, + reason="The API host returned an HTML page, not an API response. " + "Check the API host and your proxy settings.", + ) diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 519a5d79..87748da1 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -3,6 +3,7 @@ import httpretty import pytest import requests +from cloudsmith_api.rest import ApiException from urllib3.exceptions import MaxRetryError, SSLError from ..api.init import initialise_api @@ -57,6 +58,22 @@ def test_implicit_retry_for_status_codes(self): assert len(httpretty.latest_requests()) == 6 assert r.status == 200 + @httpretty.activate(allow_net_connect=False) + def test_html_page_is_not_an_api_response(self): + """A proxy login page with status 200 raises an API error with status 0.""" + initialise_api() + client = RestClient(backoff_factor=0) + url = "https://test.site" + httpretty.register_uri( + "GET", url, body="Proxy login", content_type="text/html" + ) + + with pytest.raises(ApiException) as raised: + client.request("GET", url) + + assert raised.value.status == 0 + assert "HTML page" in raised.value.reason + class TestCreateRequestsSession: @pytest.fixture(autouse=True) From 633934841da8994066b7d62d67cf9ea1fe546f9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:13:11 +0000 Subject: [PATCH 2/2] Check status and HTML before decoding the response body Co-authored-by: BartoszBlizniak <55028730+BartoszBlizniak@users.noreply.github.com> --- cloudsmith_cli/core/rest.py | 6 +++--- cloudsmith_cli/core/tests/test_rest.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/core/rest.py b/cloudsmith_cli/core/rest.py index 8915c57a..0a5de72b 100644 --- a/cloudsmith_cli/core/rest.py +++ b/cloudsmith_cli/core/rest.py @@ -129,14 +129,14 @@ def request( resp.encoding = resp.apparent_encoding or "utf-8" rest_resp = RestResponse(resp) - if _preload_content: - logger.debug("response body: %s", rest_resp.data) - if not 200 <= rest_resp.status <= 299: raise ApiException(http_resp=rest_resp) _raise_for_html(rest_resp) + if _preload_content: + logger.debug("response body: %s", rest_resp.data) + return rest_resp diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 87748da1..1f4bc66d 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -74,6 +74,25 @@ def test_html_page_is_not_an_api_response(self): assert raised.value.status == 0 assert "HTML page" in raised.value.reason + @httpretty.activate(allow_net_connect=False) + def test_non_utf8_html_page_is_not_an_api_response(self): + """An HTML page that is not UTF-8 raises an API error, not a decode error.""" + initialise_api() + client = RestClient(backoff_factor=0) + url = "https://test.site" + httpretty.register_uri( + "GET", + url, + body="Connexion au proxy requise: é".encode("iso-8859-1"), + content_type="text/html; charset=iso-8859-1", + ) + + with pytest.raises(ApiException) as raised: + client.request("GET", url) + + assert raised.value.status == 0 + assert "HTML page" in raised.value.reason + class TestCreateRequestsSession: @pytest.fixture(autouse=True)