Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/gumloop/cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,44 @@
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": "<code>"} 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):
message = "Not authenticated. Run `gumloop login` to sign in."
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,
Expand All @@ -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"}}
Expand Down
21 changes: 15 additions & 6 deletions src/gumloop/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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)
29 changes: 29 additions & 0 deletions tests/cli/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down