mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
a1873e90a7
## Summary In the Python SDK, HTTP failures from the generated client escape `except ComposioError`: `composio_client` has its own exception root, unrelated to `composio.exceptions.ComposioError`. An invalid API key, a 429 or a 500 therefore bypasses a handler written against the SDK's base error, while the TypeScript SDK covers the equivalent case (#4459). Opened this so there's something concrete to look at alongside the issue. Happy to rework it or close it if you'd prefer a different approach. Fixes #4537 ## Changes - `HttpClient` overrides `_make_status_error`, the single place the generated client builds status errors, and returns each error as a subclass of **both** the generated class and `ComposioError` (one cached subclass per generated class). - New `python/tests/test_client_errors.py` covering every mapped status plus an unmapped one, class reuse, and the SDK's existing `ToolNotFoundError` mapping. I went with this rather than wrapping errors at call sites, which is what I suggested on the issue, because it keeps every existing handler working: - `except ComposioError` now catches HTTP failures. - `except composio_client.AuthenticationError` / `APIStatusError` still work, with `status_code`, `response` and `body` unchanged. - The SDK's own mappings are untouched: `get_raw_composio_tool_by_slug` still raises `ToolNotFoundError` on 400/404 and re-raises other client errors unchanged, per its docstring and `test_tool_retrieval_errors.py`. The same holds for `TriggerTypeNotFound`. - No call sites change, so every endpoint is covered, including ones added later. On relying on a private method: `_make_status_error` is the hook the generated base client declares (`raise NotImplementedError()`) and calls for every status error, and `HttpClient` already overrides `_prepare_request` from the same base. `composio-client` is pinned exactly, and the new tests run through a real `HttpClient`, so a generator change that altered the hook would fail CI at the version bump rather than silently regress. Left alone: - **Transport-level errors.** `APIConnectionError` and `APITimeoutError` are raised by the base client without going through `_make_status_error`, so an HTTP timeout still escapes `except ComposioError`. (The issue said timeouts were already covered; that was true only for the SDK's own `ComposioSDKTimeoutError` from `wait_for_connection`, not for HTTP timeouts.) Happy to follow up if you want those covered too. - **The existing `composio.client.ComposioAPIError` alias** still points at the generated `APIError`, unchanged. The name I floated on the issue would have collided with it, and this approach doesn't need a new public class. ## Type of change - [x] Bug fix - [ ] New feature - [ ] Refactor/Chore - [ ] Documentation - [ ] Breaking change ## How Has This Been Tested? `python/tests/test_client_errors.py` runs a real `HttpClient` against an `httpx.MockTransport`. Each status (400, 401, 403, 404, 409, 422, 429, 500, and an unmapped 418) raises an error that is both a `ComposioError` and the expected generated class, with `status_code` preserved. Without the fix, 12 of the 13 new tests fail. From `python/`, Python 3.12, `composio-client==1.43.0`: ``` $ pytest tests/ -q 2072 passed, 1 skipped $ ruff check --config config/ruff.toml composio tests All checks passed! $ ruff format --config config/ruff.toml --check composio/client/__init__.py tests/test_client_errors.py 2 files already formatted $ mypy --config-file config/mypy.ini composio Success: no issues found in 58 source files ``` Live check against production with an invalid key: ```python from composio import Composio from composio.exceptions import ComposioError try: Composio(api_key="ak_invalid").create(user_id="u") except ComposioError as e: print(type(e), e.status_code) ``` On `next` this raises `composio_client.AuthenticationError`, which escapes the handler. On this branch the handler catches it, and it is still an `AuthenticationError` with status 401. ## Checklist - [x] I have read the Code of Conduct and this PR adheres to it - [x] I ran linters/tests locally and they passed - [ ] I updated documentation as needed (no docs change; the public API is unchanged) - [x] I added tests or explain why not applicable - [ ] I added a changeset if this change affects published packages (Python-only change; CONTRIBUTING asks for changesets on published TypeScript packages) ## Additional context Found while integrating the Python SDK into [Inferra](https://github.com/deepgori/inferra), where a GitHub-issue filer caught `ComposioError` and missed the invalid-key path. --------- Co-authored-by: jkomyno <alberto@composio.dev> Co-authored-by: Alberto Schiabel <jkomyno@users.noreply.github.com>
180 lines
6.0 KiB
Python
180 lines
6.0 KiB
Python
"""HTTP status errors from the generated client must be ``ComposioError``s.
|
|
|
|
The generated client (``composio_client``) has its own exception root, so an
|
|
invalid API key or any other 4xx/5xx response used to escape
|
|
``except ComposioError`` (issue #4537). ``HttpClient`` now builds those errors
|
|
as subclasses of both the generated class and ``ComposioError``, so SDK callers
|
|
can catch one root while existing ``except APIStatusError`` handlers, including
|
|
the SDK's own, keep working.
|
|
"""
|
|
|
|
import copy
|
|
import pickle
|
|
import typing as t
|
|
from unittest.mock import Mock
|
|
|
|
import composio_client
|
|
import httpx
|
|
import pytest
|
|
|
|
from composio import exceptions
|
|
from composio.client import HttpClient
|
|
from composio.core.models.base import allow_tracking
|
|
from composio.core.models.tools import Tools
|
|
from composio.core.models.triggers import Triggers
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def disable_telemetry():
|
|
"""Disable telemetry for all tests to prevent thread issues."""
|
|
token = allow_tracking.set(False)
|
|
yield
|
|
allow_tracking.reset(token)
|
|
|
|
|
|
def _client_returning(
|
|
status: int,
|
|
*,
|
|
max_retries: int = 0,
|
|
calls: t.Optional[t.List[httpx.Request]] = None,
|
|
) -> HttpClient:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if calls is not None:
|
|
calls.append(request)
|
|
return httpx.Response(
|
|
status,
|
|
json={"error": {"message": "nope"}},
|
|
# Keep retry backoff short in tests.
|
|
headers={"retry-after-ms": "1"},
|
|
)
|
|
|
|
return HttpClient(
|
|
provider="test",
|
|
api_key="sk-test",
|
|
base_url="https://backend.invalid",
|
|
max_retries=max_retries,
|
|
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "client_error"),
|
|
[
|
|
(400, composio_client.BadRequestError),
|
|
(401, composio_client.AuthenticationError),
|
|
(403, composio_client.PermissionDeniedError),
|
|
(404, composio_client.NotFoundError),
|
|
(409, composio_client.ConflictError),
|
|
(422, composio_client.UnprocessableEntityError),
|
|
(429, composio_client.RateLimitError),
|
|
(500, composio_client.InternalServerError),
|
|
(418, composio_client.APIStatusError),
|
|
],
|
|
)
|
|
def test_status_errors_are_composio_errors(
|
|
status: int, client_error: t.Type[composio_client.APIStatusError]
|
|
) -> None:
|
|
client = _client_returning(status)
|
|
|
|
with pytest.raises(exceptions.ComposioError) as exc_info:
|
|
client.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
|
|
error = exc_info.value
|
|
assert isinstance(error, client_error)
|
|
assert isinstance(error, composio_client.APIStatusError)
|
|
assert error.status_code == status
|
|
assert error.message
|
|
|
|
|
|
def test_invalid_api_key_is_catchable_as_composio_error() -> None:
|
|
client = _client_returning(401)
|
|
|
|
try:
|
|
client.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
except exceptions.ComposioError as error:
|
|
assert isinstance(error, composio_client.AuthenticationError)
|
|
else:
|
|
pytest.fail("expected the 401 to raise")
|
|
|
|
|
|
def test_error_classes_are_reused_across_requests() -> None:
|
|
client = _client_returning(401)
|
|
raised = []
|
|
for _ in range(2):
|
|
with pytest.raises(composio_client.AuthenticationError) as exc_info:
|
|
client.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
raised.append(type(exc_info.value))
|
|
|
|
assert raised[0] is raised[1]
|
|
assert raised[0].__name__ == "AuthenticationError"
|
|
|
|
|
|
def test_tool_not_found_mapping_still_applies() -> None:
|
|
tools = Tools(client=_client_returning(404), provider=Mock())
|
|
|
|
with pytest.raises(exceptions.ToolNotFoundError) as exc_info:
|
|
tools.get_raw_composio_tool_by_slug("NONEXISTENT_TOOL")
|
|
|
|
assert isinstance(exc_info.value.__cause__, composio_client.NotFoundError)
|
|
|
|
|
|
def test_schema_fetch_auth_error_is_a_composio_error() -> None:
|
|
tools = Tools(client=_client_returning(401), provider=Mock())
|
|
|
|
with pytest.raises(composio_client.AuthenticationError) as exc_info:
|
|
tools.get_raw_composio_tool_by_slug("SLACK_FETCH_CONVERSATION_HISTORY")
|
|
|
|
assert isinstance(exc_info.value, exceptions.ComposioError)
|
|
assert not isinstance(exc_info.value, exceptions.ToolNotFoundError)
|
|
|
|
|
|
def test_trigger_type_not_found_mapping_still_applies() -> None:
|
|
triggers = Triggers(client=_client_returning(404))
|
|
|
|
with pytest.raises(exceptions.TriggerTypeNotFound) as exc_info:
|
|
triggers.create(slug="NONEXISTENT_TRIGGER", user_id="user")
|
|
|
|
assert isinstance(exc_info.value.__cause__, composio_client.NotFoundError)
|
|
|
|
|
|
def test_without_retries_client_raises_composio_error() -> None:
|
|
client = _client_returning(401)
|
|
|
|
with pytest.raises(exceptions.ComposioError) as exc_info:
|
|
client.without_retries.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
|
|
assert isinstance(exc_info.value, composio_client.AuthenticationError)
|
|
|
|
|
|
def test_error_after_exhausted_retries_is_composio_error() -> None:
|
|
calls: t.List[httpx.Request] = []
|
|
client = _client_returning(503, max_retries=2, calls=calls)
|
|
|
|
with pytest.raises(exceptions.ComposioError) as exc_info:
|
|
client.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
|
|
assert isinstance(exc_info.value, composio_client.InternalServerError)
|
|
assert len(calls) == 3
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"clone",
|
|
[lambda error: pickle.loads(pickle.dumps(error)), copy.deepcopy],
|
|
ids=["pickle", "deepcopy"],
|
|
)
|
|
def test_status_errors_survive_serialization(
|
|
clone: t.Callable[[BaseException], BaseException],
|
|
) -> None:
|
|
client = _client_returning(401)
|
|
with pytest.raises(exceptions.ComposioError) as exc_info:
|
|
client.tools.retrieve(tool_slug="GITHUB_CREATE_AN_ISSUE")
|
|
error = exc_info.value
|
|
|
|
restored = clone(error)
|
|
|
|
assert type(restored) is type(error)
|
|
assert isinstance(restored, composio_client.AuthenticationError)
|
|
assert isinstance(restored, exceptions.ComposioError)
|
|
assert t.cast(composio_client.APIStatusError, restored).status_code == 401
|
|
assert str(restored) == str(error)
|