Skip to content
Merged
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
67 changes: 55 additions & 12 deletions src/gumloop/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import httpx

_FLAT_RESERVED_KEYS = frozenset({"error", "message", "error_description", "trace_id", "metadata"})


class GumloopError(Exception):
"""Base error for Gumloop SDK failures."""
Expand All @@ -21,23 +23,64 @@ 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
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 {}
if isinstance(self.error, dict):
self.code = self.error.get("code")
self.type = self.error.get("type")
self.param = self.error.get("param")
details = self.error.get("details")
else:
self.code = self.error if isinstance(self.error, str) else None
self.type = None
self.param = None
details = _flat_details(body)
self.details = details if isinstance(details, dict) else {}


def _flat_details(body: Any) -> dict[str, Any]:
"""Extract machine-readable context from a flat error body.

Prefer PublicError ``metadata``. Otherwise keep legacy top-level fields
such as ``minimum_tier`` / ``denied_keys`` (tier/policy denials).
"""
if not isinstance(body, dict):
return {}
metadata = body.get("metadata")
if isinstance(metadata, dict):
return metadata
return {key: value for key, value in body.items() if key not in _FLAT_RESERVED_KEYS}


def to_api_error(response: httpx.Response) -> APIStatusError:
"""Translate a non-success ``httpx.Response`` into :class:`APIStatusError`,
extracting the backend error envelope's ``message`` when present."""
"""Translate a non-success ``httpx.Response`` into :class:`APIStatusError`.

Supports:
- Nested developer API: ``{"error": {"code", "message", "details", ...}}``
- Flat PublicError: ``{"error": "code", "message": "...", "metadata": {...}}``
- OAuth RFC: ``{"error": "code", "error_description": "..."}``
- Legacy bare / enriched codes: ``{"error": "tier_required_pro", ...}``
"""
try:
body: Any = response.json()
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}"
)

fallback = f"Gumloop API returned HTTP {response.status_code}"
if not isinstance(body, dict):
return APIStatusError(fallback, status_code=response.status_code, body=body)

error = body.get("error")
top_level_message = body.get("message")
error_description = body.get("error_description")

if isinstance(error, dict):
message = str(error.get("message") or fallback)
elif isinstance(top_level_message, str) and top_level_message:
message = top_level_message
elif isinstance(error_description, str) and error_description:
message = error_description
elif isinstance(error, str) and error:
message = f"{fallback}: {error}"
else:
message = fallback

return APIStatusError(message, status_code=response.status_code, body=body)
20 changes: 20 additions & 0 deletions tests/cli/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ 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_public_error_explains_tier_and_does_not_save(cli_runner: CliRunner) -> None:
respx.get(f"{API_BASE}/models").mock(
return_value=httpx.Response(
403,
json={
"error": "subscription_tier_required",
"message": ("This feature isn't available on your current plan. Upgrade to continue."),
"metadata": {"minimum_tier": "pro"},
},
)
)

result = cli_runner.invoke(app, ["login", "--access-token", "acct_free"])

assert result.exit_code != 0
assert "This feature isn't available on your current plan" in result.output
assert load_credentials().access_token is None


@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
110 changes: 110 additions & 0 deletions tests/sdk/test_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

import httpx

from gumloop.errors import to_api_error


def test_to_api_error_reads_flat_public_error_message_and_code() -> None:
response = httpx.Response(
403,
json={
"error": "subscription_tier_required",
"message": "This feature isn't available on your current plan. Upgrade to continue.",
"metadata": {"minimum_tier": "pro"},
},
)

error = to_api_error(response)

assert error.status_code == 403
assert error.code == "subscription_tier_required"
assert str(error) == ("This feature isn't available on your current plan. Upgrade to continue.")
assert error.details == {"minimum_tier": "pro"}


def test_to_api_error_reads_nested_error_envelope() -> None:
response = httpx.Response(
403,
json={
"error": {
"code": "organization_sync_requires_pro",
"message": "Organization skill sync requires a Pro plan.",
"type": "permission_error",
"param": None,
"details": {},
}
},
)

error = to_api_error(response)

assert error.code == "organization_sync_requires_pro"
assert str(error) == "Organization skill sync requires a Pro plan."
assert error.type == "permission_error"


def test_to_api_error_reads_nested_details() -> None:
response = httpx.Response(
400,
json={
"error": {
"code": "skill_not_found",
"message": "Skill not found.",
"type": "invalid_request_error",
"param": "skill_ids",
"details": {"skill_ids": ["sk_does_not_exist"]},
}
},
)

error = to_api_error(response)

assert error.code == "skill_not_found"
assert error.param == "skill_ids"
assert error.details == {"skill_ids": ["sk_does_not_exist"]}


def test_to_api_error_exposes_legacy_bare_string_code() -> None:
response = httpx.Response(403, json={"error": "tier_required_pro"})

error = to_api_error(response)

assert error.code == "tier_required_pro"
assert str(error) == "Gumloop API returned HTTP 403: tier_required_pro"
assert error.details == {}


def test_to_api_error_preserves_legacy_top_level_context() -> None:
response = httpx.Response(
403,
json={
"error": "tier_required_pro",
"minimum_tier": "pro",
"denied_keys": ["gumloop_api"],
},
)

error = to_api_error(response)

assert error.code == "tier_required_pro"
assert error.details == {
"minimum_tier": "pro",
"denied_keys": ["gumloop_api"],
}


def test_to_api_error_reads_oauth_error_description() -> None:
response = httpx.Response(
401,
json={
"error": "invalid_token",
"error_description": "Missing subject",
},
)

error = to_api_error(response)

assert error.code == "invalid_token"
assert str(error) == "Missing subject"
assert error.details == {}