Files
Deep Gori a1873e90a7 fix(core): make HTTP status errors catchable as ComposioError (#4543)
## 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>
2026-09-21 21:46:27 +04:00

334 lines
11 KiB
Python

"""
This module is a light wrapper around the auto-generated composio client.
"""
import contextvars
import os
import platform
import typing as t
from importlib.metadata import version
from uuid import uuid4
import typing_extensions as te
from composio_client import (
DEFAULT_MAX_RETRIES,
NOT_GIVEN,
APIError,
APIStatusError,
NotGiven,
_base_client,
)
from composio_client import Composio as BaseComposio
from httpx import URL, Client, Request, Response, Timeout
from composio.exceptions import ComposioError
from composio.utils.logging import WithLogger
ComposioAPIError = APIError
APIEnvironment = te.Literal["production", "staging", "local"]
_SDK_ERROR_CLASSES: t.Dict[t.Type[APIStatusError], t.Type[APIStatusError]] = {}
def _with_sdk_error_base(
error_class: t.Type[APIStatusError],
) -> t.Type[APIStatusError]:
"""
Return a subclass of a generated-client status error that also derives
from the SDK's ``ComposioError``.
The generated client has its own exception root, unrelated to
``composio.exceptions.ComposioError``, so HTTP failures such as an invalid
API key used to escape ``except ComposioError``. Keeping the generated
class as the first base preserves its constructor, ``status_code`` and
``isinstance`` checks, so existing ``except APIStatusError`` handlers keep
working unchanged.
"""
cached = _SDK_ERROR_CLASSES.get(error_class)
if cached is not None:
return cached
sdk_class = t.cast(
t.Type[APIStatusError],
type(
error_class.__name__,
(error_class, ComposioError),
{
"__module__": error_class.__module__,
"__reduce__": _reduce_sdk_error,
},
),
)
# setdefault keeps the first class if two threads race to build one.
return _SDK_ERROR_CLASSES.setdefault(error_class, sdk_class)
def _reduce_sdk_error(self: APIStatusError) -> t.Tuple[t.Any, ...]:
"""
Pickle support for the classes built by ``_with_sdk_error_base``.
They are not module attributes, so pickle cannot find them by name. Record
the generated class instead and rebuild the SDK subclass on load.
"""
error_class = type(self).__mro__[1]
return (
_rebuild_sdk_error,
(error_class, self.message, self.response, self.body),
self.__dict__,
)
def _rebuild_sdk_error(
error_class: t.Type[APIStatusError],
message: str,
response: Response,
body: object,
) -> APIStatusError:
return _with_sdk_error_base(error_class)(message, response=response, body=body)
def _get_python_implementation() -> str:
"""
Get the Python implementation name.
Returns:
String identifier for Python implementation (CPYTHON, PYPY, JYTHON, IRONPYTHON, etc.)
"""
impl = platform.python_implementation().upper()
return impl
def _detect_runtime_environment() -> str:
"""
Detect the runtime environment where the code is executing.
Returns a string identifier for the environment.
"""
# Check for Google Colab
try:
import google.colab # type: ignore # noqa: F401
return "GOOGLE_COLAB"
except ImportError:
pass
# Check for Jupyter/IPython
try:
shell = get_ipython().__class__.__name__ # type: ignore # noqa: F821
if shell == "ZMQInteractiveShell":
return "JUPYTER_NOTEBOOK"
elif shell == "TerminalInteractiveShell":
return "IPYTHON"
except NameError:
pass
# Check for AWS Lambda
if os.environ.get("AWS_LAMBDA_FUNCTION_NAME"):
return "AWS_LAMBDA"
# Check for Google Cloud Functions
if os.environ.get("FUNCTION_NAME") or os.environ.get("K_SERVICE"):
return "GOOGLE_CLOUD_FUNCTION"
# Check for Azure Functions
if os.environ.get("FUNCTIONS_WORKER_RUNTIME"):
return "AZURE_FUNCTION"
# Check for Kaggle
if os.environ.get("KAGGLE_KERNEL_RUN_TYPE"):
return "KAGGLE"
# Check for Replit
if os.environ.get("REPL_ID") or os.environ.get("REPLIT_DB_URL"):
return "REPLIT"
# Check for GitHub Actions
if os.environ.get("GITHUB_ACTIONS"):
return "GITHUB_ACTIONS"
# Check for GitLab CI
if os.environ.get("GITLAB_CI"):
return "GITLAB_CI"
# Check for CircleCI
if os.environ.get("CIRCLECI"):
return "CIRCLECI"
# Check for Jenkins
if os.environ.get("JENKINS_HOME"):
return "JENKINS"
# Check for Docker
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "DOCKER"
# Check if running in a container (generic)
try:
with open("/proc/1/cgroup", "r") as f:
if "docker" in f.read() or "containerd" in f.read():
return "CONTAINER"
except (FileNotFoundError, PermissionError):
pass
# Default to LOCAL for development environments
return "LOCAL"
class RequestContext(te.TypedDict):
id: te.NotRequired[t.Optional[str]]
provider: str
# TODO: Rename `Composio` to `HttpClient` in stainless generator
class HttpClient(BaseComposio, WithLogger):
"""
Wrapper around the auto-generated composio client.
"""
request_ctx: contextvars.ContextVar[RequestContext]
not_given = NOT_GIVEN
# Detect once at class initialization
_runtime_env: str = (
f"{_detect_runtime_environment()}_{_get_python_implementation()}"
)
def __init__(
self,
*,
provider: str,
api_key: t.Optional[str] = None,
environment: te.Union[NotGiven, APIEnvironment] = "production",
base_url: t.Optional[t.Union[str, URL, NotGiven]] = NOT_GIVEN,
timeout: t.Optional[t.Union[float, Timeout, NotGiven]] = NOT_GIVEN,
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: t.Optional[t.Mapping[str, str]] = None,
default_query: t.Optional[t.Mapping[str, object]] = None,
http_client: t.Optional[Client] = None,
_strict_response_validation: bool = False,
) -> None:
"""
Initialize the client.
:param provider: The provider to use for the client.
:param api_key: The API key to use for the client.
:param environment: The environment to use for the client.
:param base_url: The base URL to use for the client.
:param timeout: The timeout to use for the client.
:param max_retries: The maximum number of retries to use for the client.
:param default_headers: The default headers to use for the client.
:param default_query: The default query parameters to use for the client.
:param http_client: The HTTP client to use for the client.
"""
WithLogger.__init__(self)
BaseComposio.__init__(
self,
api_key=api_key,
environment=environment,
base_url=base_url,
timeout=timeout,
max_retries=max_retries,
default_headers=default_headers,
default_query=default_query,
http_client=http_client,
_strict_response_validation=_strict_response_validation,
)
# TOFIX: Verbosity wrapper impl
_base_client.log = self._logger # type: ignore
self.provider = provider
self.request_ctx = contextvars.ContextVar[RequestContext](
"request_ctx",
default={
"id": None,
"provider": provider,
},
)
# Lazily-built sibling client with retries disabled; see `without_retries`.
self._without_retries: t.Optional[te.Self] = None
def copy( # type: ignore[override]
self,
*,
_extra_kwargs: t.Mapping[str, t.Any] = {},
**kwargs: t.Any,
) -> te.Self:
"""
Clone the client, re-injecting the required ``provider`` keyword.
The Stainless-generated ``copy`` rebuilds the client via
``self.__class__(...)`` without passing ``provider``, which this subclass
requires — so the inherited ``copy``/``with_options`` raise ``TypeError``.
Threading ``provider`` through ``_extra_kwargs`` makes them work again
(e.g. ``with_options(max_retries=0)``).
"""
return super().copy( # type: ignore[misc]
_extra_kwargs={
"provider": self.provider,
# The generated `copy` does not re-pass `_strict_response_validation`,
# so without this the clone would silently fall back to the default
# (False) even when the original had it enabled — keeping the sibling
# a faithful copy that differs from the parent only in `max_retries`.
"_strict_response_validation": self._strict_response_validation,
**_extra_kwargs,
},
**kwargs,
)
# Re-alias `with_options` to this override. The base class binds
# `with_options = copy` at class-definition time, so without this it would
# still resolve to the base `copy` and miss the `provider` re-injection.
with_options = copy
@property
def without_retries(self) -> te.Self:
"""
A cached sibling client that never retries requests.
Used for non-idempotent writes (``tools.execute`` / ``tools.proxy``),
where a silent retry after a read timeout can duplicate a side effect
(e.g. send an email twice). Reads keep the default retry behaviour.
Scope: only ``tools.execute`` / ``tools.proxy`` route through this today.
Other non-idempotent writes (``auth_configs.create`` / ``update`` /
``delete``, ``mcp.update`` / ``delete``, ``connected_accounts.delete`` /
``refresh``, ``link.create``) keep the default retries — most are
naturally idempotent on retry, and the durable fix is backend-honoured
idempotency keys.
The sibling is cached rather than rebuilt per call so a fresh client is
not constructed on every execute/proxy (the hottest path); its options
never change, so one per client suffices.
"""
if self._without_retries is None:
self._without_retries = self.with_options(max_retries=0)
return self._without_retries
def _make_status_error(
self,
err_msg: str,
*,
body: object,
response: Response,
) -> APIStatusError:
"""
Build the generated client's status error so it is also a
``ComposioError``; see ``_with_sdk_error_base``.
"""
error = super()._make_status_error(err_msg, body=body, response=response)
return _with_sdk_error_base(type(error))(err_msg, response=response, body=body)
def _prepare_request(self, request: Request) -> None:
"""
Request interceptor to inject request id, provider, and SDK version.
"""
ctx = self.request_ctx.get()
request.headers["x-request-id"] = ctx.get("id") or uuid4().hex
request.headers["x-framework"] = ctx["provider"]
request.headers["x-source"] = "PYTHON_SDK"
request.headers["x-runtime"] = HttpClient._runtime_env
try:
request.headers["x-sdk-version"] = version("composio")
except Exception:
request.headers["x-sdk-version"] = "unknown"