From 26542a9794c54855114fb956aa1f314c867cc7ca Mon Sep 17 00:00:00 2001 From: KevVo Date: Fri, 7 Aug 2026 15:06:39 -0700 Subject: [PATCH 1/3] [TEST] Cover tier-gated 403 in CLI login and shared error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests for the `tier_required_pro` 403: - `test_login_403_explains_the_tier_and_still_does_not_save` extends the existing invalid-credential guard to 403. The current guard stubs 401, but the live API returns 403 for a refused credential, so that status was never exercised. Asserts the save guard still holds — non-zero exit, nothing persisted — while the message becomes actionable. - `test_tier_gated_403_on_a_normal_command_explains_itself` drives `agents list` to cover the shared error path, proving the hint reaches every command rather than only `login`, and that the machine-readable code stays in `--json`. --- tests/cli/test_login.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/cli/test_login.py b/tests/cli/test_login.py index 645cabf..bb7ff81 100644 --- a/tests/cli/test_login.py +++ b/tests/cli/test_login.py @@ -81,6 +81,35 @@ def test_login_with_invalid_credentials_exits_nonzero_and_does_not_save(cli_runn assert load_credentials().api_key is None +@respx.mock +def test_login_403_explains_the_tier_and_still_does_not_save(cli_runner: CliRunner) -> None: + # Extends test_login_with_invalid_credentials_...does_not_save to 403, which + # is the status the live API actually returns for a refused credential. + # The save guard is preserved; only the message becomes actionable. + respx.get(f"{API_BASE}/models").mock(return_value=httpx.Response(403, json={"error": "tier_required_pro"})) + + result = cli_runner.invoke(app, ["login", "--access-token", "acct_xyz"]) + + assert result.exit_code != 0 + assert load_credentials().access_token is None + assert "Pro plan or higher" in result.output + + +@respx.mock +def test_tier_gated_403_on_a_normal_command_explains_itself(cli_runner: CliRunner) -> None: + # The hint lives on the shared error path, so every command explains itself, + # not just `login`. + save_credentials(Credentials(access_token="acct_xyz", base_url=API_BASE)) + respx.get(f"{API_BASE}/agents").mock(return_value=httpx.Response(403, json={"error": "tier_required_pro"})) + + result = cli_runner.invoke(app, ["agents", "list", "--json"]) + + assert result.exit_code != 0 + assert "Pro plan or higher" in result.output + # The machine-readable code belongs in --json, not in the prose. + assert '"code": "tier_required_pro"' in result.output + + @respx.mock def test_login_against_custom_base_url_stores_it_in_keychain(cli_runner: CliRunner) -> None: custom = "https://example.com/api/v1" From 299167ee3282674ccb81f9d2e755de3756da183f Mon Sep 17 00:00:00 2001 From: KevVo Date: Fri, 7 Aug 2026 15:24:13 -0700 Subject: [PATCH 2/3] [FIX] Read the error code when the API returns it without a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API rejects some requests with the code alone, e.g. HTTP/2 403 {"error":"tier_required_pro"} Both `to_api_error` and `APIStatusError` assumed `body["error"]` was always a dict wrapping a `message`. Against this body the isinstance check failed, so the parser fell through to the generic status line and the code was discarded — it stayed in `.body`, unread. Users saw only "Gumloop API returned HTTP 403", and `.code` was left None. Handle the case where `error` is a string: append it to the message and use it as `.code`. before: Gumloop API returned HTTP 403 after: Gumloop API returned HTTP 403: tier_required_pro This is SDK-level, so any caller catching APIStatusError can now branch on `.code` for these responses, not just the CLI. --- src/gumloop/errors.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/gumloop/errors.py b/src/gumloop/errors.py index 0c8665e..9500737 100644 --- a/src/gumloop/errors.py +++ b/src/gumloop/errors.py @@ -21,7 +21,10 @@ def __init__(self, message: str, *, status_code: int, body: Any = None) -> None: self.status_code = status_code self.body = body self.error = body.get("error") if isinstance(body, dict) else None - self.code = self.error.get("code") if isinstance(self.error, dict) else None + if isinstance(self.error, dict): + self.code = self.error.get("code") + else: + self.code = self.error if isinstance(self.error, str) and self.error else None self.type = self.error.get("type") if isinstance(self.error, dict) else None self.param = self.error.get("param") if isinstance(self.error, dict) else None self.details = self.error.get("details", {}) if isinstance(self.error, dict) else {} @@ -35,9 +38,15 @@ def to_api_error(response: httpx.Response) -> APIStatusError: except ValueError: body = response.text error = body.get("error") if isinstance(body, dict) else None - message = ( - str(error.get("message") or f"Gumloop API returned HTTP {response.status_code}") - if isinstance(error, dict) - else f"Gumloop API returned HTTP {response.status_code}" - ) + generic = f"Gumloop API returned HTTP {response.status_code}" + if isinstance(error, dict): + message = str(error.get("message") or generic) + elif isinstance(error, str) and error: + # Backend also returns a flat {"error": "tier_required_pro"} shape; + # without this the code is dropped and the user only sees the status. + # Having the message include the error code to help the user understand what went wrong, + # even if they don't know the code. + message = f"{generic}: {error}" + else: + message = generic return APIStatusError(message, status_code=response.status_code, body=body) From d65a0a595752ae6521b9e8f94190e5b7607f4977 Mon Sep 17 00:00:00 2001 From: KevVo Date: Fri, 7 Aug 2026 15:41:48 -0700 Subject: [PATCH 3/3] [FIX] Explain known API error codes instead of printing a bare status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tier-gated account got "Error: Gumloop API returned HTTP 403", which says nothing about what was refused or how to resolve it. Github Issue file as: https://github.com/gumloop/gumloop-py/issues/35 Add `_ERROR_HINTS`, mapping a backend error code to plain language, and apply it in `exit_with_error`. Every command routes its errors through that function, so the hint reaches all of them rather than just `login`. When a hint exists it replaces the status line rather than appending to it — once we can name the actual problem, the raw status adds nothing for the user. The `--json` payload is unchanged apart from a new `hint` field: `message`, `code`, and `status_code` are still emitted for support and scripting. before: Error: Gumloop API returned HTTP 403 after: Error: This account needs the Pro plan or higher. Commands will keep failing until it is upgraded at https://www.gumloop.com/pricing -- or ask a workspace admin to upgrade it. --- src/gumloop/cli/errors.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/gumloop/cli/errors.py b/src/gumloop/cli/errors.py index b36792f..4d0363d 100644 --- a/src/gumloop/cli/errors.py +++ b/src/gumloop/cli/errors.py @@ -12,6 +12,36 @@ from gumloop.cli.console import error_console from gumloop.cli.console import print_json_error +# A set of backend error codes whose raw string tells the user nothing actionable. +# This lookup is used to provide a more helpful hint in the CLI output. +# Keyed on the flat {"error": ""} body the API returns. +_ERROR_HINTS = { + "tier_required_pro": ( + "This account needs the Pro plan or higher. Commands will keep failing " + "until it is upgraded at https://www.gumloop.com/pricing -- or ask a " + "workspace admin to upgrade it." + ), +} + + +def api_error_hint(error: APIStatusError) -> str | None: + """Return a plain-language explanation for a known backend error code. + + Some endpoints refuse a request with a bare code such as + ``tier_required_pro`` and no actionable message, leaving the CLI + nothing to show but an HTTP status. + + Args: + error: The API failure to explain. + + Returns: + A friendly message telling the user what went wrong and how to + fix it. Returns nothing if we do not have a message for this + error, and the caller shows the original error instead. + """ + code = error.code or (error.error if isinstance(error.error, str) else None) + return _ERROR_HINTS.get(code or "") + def exit_with_error(error: Exception, *, json_output: bool = False) -> NoReturn: if isinstance(error, AuthenticationError): @@ -19,6 +49,7 @@ def exit_with_error(error: Exception, *, json_output: bool = False) -> NoReturn: payload: dict[str, Any] = {"error": {"message": message, "type": "authentication_error"}} elif isinstance(error, APIStatusError): message = str(error) + hint = api_error_hint(error) payload = { "error": { "message": message, @@ -29,6 +60,9 @@ def exit_with_error(error: Exception, *, json_output: bool = False) -> NoReturn: "details": error.details, } } + if hint: + payload["error"]["hint"] = hint + message = hint elif isinstance(error, GumloopError): message = str(error) payload = {"error": {"message": message, "type": "gumloop_error"}}