Skip to content

Commit dae005c

Browse files
authored
fix(core): raise tool not found only on 404/400 (#4459)
This PR: - closes [PRDE-1613](https://linear.app/composio/issue/PRDE-1613) - maps only 404/400 from `tools.retrieve` to `ComposioToolNotFoundError`; every other failure (401 invalid API key, 5xx, network) now raises the new `ComposioToolFetchError` with the client error kept as `cause` - `tools.get(userId, slug)` and `tools.execute` inherit the corrected mapping since they call `getRawComposioToolBySlug` - fixes `toolkits.get(slug)`, whose 404/400 check compared against the OpenAI `APIError` class instead of the Composio client one, so `ComposioToolkitNotFoundError` never fired - Python parity: `get_raw_composio_tool_by_slug` raises `ToolNotFoundError` (now a `NotFoundError` subclass) on 404/400 and re-raises any other `composio_client` error unchanged - adds unit tests on both sides for 404, 400, 401 and non-API failures; verified live against the API with an invalid key on both SDKs ## Context An unauthenticated call to `tools.getRawComposioToolBySlug` returned `error.name === "ComposioToolNotFoundError"` while `error.cause.status` was 401. The catch block wrapped every error except cancellation as not-found, which predates the `@composio/client@beta` swap. Intended to be back-ported to `main` after merging to `next`. https://claude.ai/code/session_017HtbhwMAKcfebo8HyXWa5s
2 parents 85e33f6 + 8bb1d29 commit dae005c

9 files changed

Lines changed: 232 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@composio/core': patch
3+
---
4+
5+
Stop reporting every failed tool lookup as `ComposioToolNotFoundError`. `tools.getRawComposioToolBySlug`, and the `tools.get` / `tools.execute` paths that call it, now raise `ComposioToolNotFoundError` only when the API answers 404 or 400. Any other failure, such as an invalid API key (401), a server error, or a network fault, raises the new `ComposioToolFetchError` with the client error preserved as `cause`. `toolkits.get(slug)` now applies its 404/400 check against the Composio client's `APIError` instead of the OpenAI one, so an unknown toolkit raises `ComposioToolkitNotFoundError` as documented.

python/composio/core/models/tools.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77

88
import typing_extensions as te
9-
from composio_client import omit
9+
from composio_client import APIStatusError, omit
1010
from pydantic import BaseModel as PydanticBaseModel
1111

1212
from composio.client import HttpClient
@@ -28,7 +28,11 @@
2828
from composio.core.provider.base import BaseProvider, ExecuteToolFn
2929
from composio.core.provider.none_agentic import NonAgenticProvider
3030
from composio.core.types import ToolkitVersionParam
31-
from composio.exceptions import InvalidParams, ToolVersionRequiredError
31+
from composio.exceptions import (
32+
InvalidParams,
33+
ToolNotFoundError,
34+
ToolVersionRequiredError,
35+
)
3236
from composio.utils.pydantic import none_to_omit
3337
from composio.utils.toolkit_version import get_toolkit_version
3438
from composio.utils.upload_dir_allowlist import resolve_effective_upload_allowlist
@@ -199,13 +203,21 @@ def __init__(
199203
def get_raw_composio_tool_by_slug(self, slug: str) -> Tool:
200204
"""
201205
Returns schema for the given tool slug.
206+
207+
:raises ToolNotFoundError: when the backend reports the slug as unknown
208+
(404, or 400 for a malformed slug). Any other client error, such as
209+
an invalid API key, is re-raised unchanged.
202210
"""
203-
return _normalize_tool(
204-
self._client.tools.retrieve(
211+
try:
212+
response = self._client.tools.retrieve(
205213
tool_slug=slug,
206214
toolkit_versions=none_to_omit(self._toolkit_versions),
207-
),
208-
)
215+
)
216+
except APIStatusError as error:
217+
if error.status_code in (400, 404):
218+
raise ToolNotFoundError(f"Tool with slug {slug} not found") from error
219+
raise
220+
return _normalize_tool(response)
209221

210222
def get_raw_composio_tools(
211223
self,

python/composio/exceptions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,13 @@ class InvalidExecuteFunctionError(ComposioError):
449449
pass
450450

451451

452-
class ToolNotFoundError(ComposioError):
453-
pass
452+
class ToolNotFoundError(NotFoundError):
453+
"""Raised when a tool slug does not exist.
454+
455+
Mirrors the TypeScript SDK's ``ComposioToolNotFoundError``. Other failures
456+
while fetching a tool (invalid API key, server or network errors) are not
457+
translated and surface as the underlying ``composio_client`` error.
458+
"""
454459

455460

456461
class InvalidModifier(ComposioError):
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Error mapping for ``Tools.get_raw_composio_tool_by_slug``.
2+
3+
Only an unknown slug becomes ``ToolNotFoundError``. Other client failures,
4+
such as an invalid API key, must surface unchanged so callers can tell a
5+
missing tool from a rejected request (parity with the TypeScript SDK).
6+
"""
7+
8+
from unittest.mock import Mock
9+
10+
import httpx
11+
import pytest
12+
from composio_client import AuthenticationError, BadRequestError, NotFoundError
13+
14+
from composio import exceptions
15+
from composio.core.models.tools import Tools
16+
from tests.conftest import mock_http_client
17+
18+
19+
def _status_error(cls, status: int):
20+
request = httpx.Request("GET", "https://backend.composio.dev/api/v3/tools/X")
21+
response = httpx.Response(status, request=request)
22+
return cls("error", response=response, body=None)
23+
24+
25+
@pytest.fixture
26+
def mock_client() -> Mock:
27+
return mock_http_client()
28+
29+
30+
@pytest.fixture
31+
def tools(mock_client: Mock) -> Tools:
32+
return Tools(client=mock_client, provider=Mock())
33+
34+
35+
def test_unknown_slug_raises_tool_not_found(tools: Tools, mock_client: Mock) -> None:
36+
not_found = _status_error(NotFoundError, 404)
37+
mock_client.tools.retrieve.side_effect = not_found
38+
39+
with pytest.raises(exceptions.ToolNotFoundError) as exc_info:
40+
tools.get_raw_composio_tool_by_slug("NONEXISTENT_TOOL")
41+
42+
assert exc_info.value.__cause__ is not_found
43+
assert isinstance(exc_info.value, exceptions.NotFoundError)
44+
assert "NONEXISTENT_TOOL" in str(exc_info.value)
45+
46+
47+
def test_malformed_slug_raises_tool_not_found(tools: Tools, mock_client: Mock) -> None:
48+
mock_client.tools.retrieve.side_effect = _status_error(BadRequestError, 400)
49+
50+
with pytest.raises(exceptions.ToolNotFoundError):
51+
tools.get_raw_composio_tool_by_slug("malformed slug")
52+
53+
54+
def test_invalid_api_key_is_not_tool_not_found(tools: Tools, mock_client: Mock) -> None:
55+
unauthorized = _status_error(AuthenticationError, 401)
56+
mock_client.tools.retrieve.side_effect = unauthorized
57+
58+
with pytest.raises(AuthenticationError) as exc_info:
59+
tools.get_raw_composio_tool_by_slug("SLACK_FETCH_CONVERSATION_HISTORY")
60+
61+
assert exc_info.value is unauthorized
62+
assert exc_info.value.status_code == 401
63+
64+
65+
def test_execute_surfaces_auth_error_from_schema_fetch(
66+
tools: Tools, mock_client: Mock
67+
) -> None:
68+
mock_client.tools.retrieve.side_effect = _status_error(AuthenticationError, 401)
69+
70+
with pytest.raises(AuthenticationError):
71+
tools.execute("SLACK_FETCH_CONVERSATION_HISTORY", {}, user_id="user")
72+
73+
mock_client.tools.execute.assert_not_called()

ts/packages/core/src/errors/ToolErrors.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ComposioConnectedAccountNotFoundError } from './ConnectedAccountsErrors
55
export const ToolErrorCodes = {
66
TOOLSET_NOT_DEFINED: 'TOOLSET_NOT_DEFINED',
77
TOOL_NOT_FOUND: 'TOOL_NOT_FOUND',
8+
TOOL_FETCH_ERROR: 'TOOL_FETCH_ERROR',
89
INVALID_MODIFIER: 'INVALID_MODIFIER',
910
TOOL_EXECUTION_ERROR: 'TOOL_EXECUTION_ERROR',
1011
INVALID_TOOL_ARGUMENTS: 'INVALID_TOOL_ARGUMENTS',
@@ -45,6 +46,29 @@ export class ComposioToolNotFoundError extends ComposioError {
4546
}
4647
}
4748

49+
/**
50+
* Error thrown when a tool schema could not be fetched for a reason other than
51+
* the tool not existing (for example an invalid API key, a server error, or a
52+
* network failure). The underlying client error is preserved as `cause`.
53+
*/
54+
export class ComposioToolFetchError extends ComposioError {
55+
constructor(
56+
message: string = 'Failed to fetch tool',
57+
options: Omit<ComposioErrorOptions, 'code' | 'statusCode'> = {}
58+
) {
59+
super(message, {
60+
...options,
61+
code: ToolErrorCodes.TOOL_FETCH_ERROR,
62+
possibleFixes: options.possibleFixes || [
63+
'Ensure the tool slug is valid',
64+
'Ensure you are using the correct API key',
65+
'Ensure you are using the correct API endpoint / Base URL and it is working',
66+
],
67+
});
68+
this.name = 'ComposioToolFetchError';
69+
}
70+
}
71+
4872
export class ComposioInvalidModifierError extends ComposioError {
4973
constructor(
5074
message: string = 'Invalid modifier',

ts/packages/core/src/models/Toolkits.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ComposioClient from '@composio/client';
1+
import ComposioClient, { APIError } from '@composio/client';
22
import {
33
ToolkitListParams,
44
ToolKitListResponse,
@@ -16,7 +16,6 @@ import { ConnectionRequest } from '../types/connectionRequest.types';
1616
import { telemetry } from '../telemetry/Telemetry';
1717
import { AuthSchemeType } from '../types/authConfigs.types';
1818
import logger from '../utils/logger';
19-
import { APIError } from 'openai';
2019
import {
2120
transformToolkitListResponse,
2221
transformToolkitRetrieveCategoriesResponse,

ts/packages/core/src/models/Tools.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ComposioClient from '@composio/client';
1+
import ComposioClient, { APIError } from '@composio/client';
22
import { FileToolModifier } from '#file_tool_modifier';
33
import {
44
Tool,
@@ -42,6 +42,7 @@ import logger from '../utils/logger';
4242
import { ExecuteToolFn, GlobalExecuteToolFn } from '../types/provider.types';
4343
import {
4444
ComposioInvalidModifierError,
45+
ComposioToolFetchError,
4546
ComposioToolNotFoundError,
4647
ComposioProviderNotDefinedError,
4748
ComposioToolVersionRequiredError,
@@ -710,7 +711,17 @@ export class Tools<
710711
if (error instanceof ComposioRequestCancelledError) {
711712
throw error;
712713
}
713-
throw new ComposioToolNotFoundError(`Unable to retrieve tool with slug ${slug}`, {
714+
// The tools endpoint reports an unknown slug as 404 (or 400 for a
715+
// malformed one). Anything else (401, 5xx, network) is not "not found",
716+
// so keep the client error reachable as `cause` under a generic error.
717+
if (error instanceof APIError && (error.status === 404 || error.status === 400)) {
718+
throw new ComposioToolNotFoundError(`Tool with slug ${slug} not found`, {
719+
meta: { slug },
720+
cause: error,
721+
});
722+
}
723+
throw new ComposioToolFetchError(`Unable to retrieve tool with slug ${slug}`, {
724+
meta: { slug },
714725
cause: error,
715726
});
716727
}

ts/packages/core/test/models/toolkits.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { Toolkits } from '../../src/models/Toolkits';
33
import ComposioClient from '@composio/client';
44
import { telemetry } from '../../src/telemetry/Telemetry';
55
import { ComposioAuthConfigNotFoundError } from '../../src/errors/AuthConfigErrors';
6+
import {
7+
ComposioToolkitFetchError,
8+
ComposioToolkitNotFoundError,
9+
} from '../../src/errors/ToolkitErrors';
610
import { AuthSchemeTypes } from '../../src/types/authConfigs.types';
711
import { APIError } from '@composio/client';
812
import type { ToolkitListParams } from '../../src/types/toolkit.types';
@@ -233,10 +237,33 @@ describe('Toolkits', () => {
233237
await expect(promise).rejects.toThrowError('Failed to fetch toolkits');
234238
});
235239

236-
it('should throw ComposioToolkitNotFoundError when toolkit not found', async () => {
237-
mockClient.toolkits.retrieve.mockRejectedValue(
238-
new Error('Toolkit with slug non-existent not found')
240+
it('should throw ComposioToolkitNotFoundError when the API returns 404', async () => {
241+
const notFound = new ComposioClient.NotFoundError(404, undefined, undefined, new Headers());
242+
mockClient.toolkits.retrieve.mockRejectedValueOnce(notFound);
243+
244+
const error = await toolkits.get('non-existent').catch(e => e);
245+
246+
expect(error).toBeInstanceOf(ComposioToolkitNotFoundError);
247+
expect(error.cause).toBe(notFound);
248+
});
249+
250+
it('should not report an invalid API key (401) as toolkit not found', async () => {
251+
const unauthorized = new ComposioClient.AuthenticationError(
252+
401,
253+
undefined,
254+
undefined,
255+
new Headers()
239256
);
257+
mockClient.toolkits.retrieve.mockRejectedValueOnce(unauthorized);
258+
259+
const error = await toolkits.get('github').catch(e => e);
260+
261+
expect(error).toBeInstanceOf(ComposioToolkitFetchError);
262+
expect(error.cause).toBe(unauthorized);
263+
});
264+
265+
it('should throw ComposioToolkitFetchError for non-API failures', async () => {
266+
mockClient.toolkits.retrieve.mockRejectedValueOnce(new Error('socket hang up'));
240267

241268
const promise = toolkits.get('non-existent');
242269
await expect(promise).rejects.toThrowError("Couldn't fetch Toolkit with slug: non-existent");
@@ -530,7 +557,8 @@ describe('Toolkits', () => {
530557

531558
const promise = toolkits.authorize('user-123', 'non-existent');
532559

533-
await expect(promise).rejects.toThrow("Couldn't fetch Toolkit with slug: non-existent");
560+
await expect(promise).rejects.toThrow(ComposioToolkitNotFoundError);
561+
await expect(promise).rejects.toThrow('Toolkit with slug non-existent not found');
534562
expect(mockClient.authConfigs.list).not.toHaveBeenCalled();
535563
expect(mockClient.authConfigs.create).not.toHaveBeenCalled();
536564
expect(mockClient.connectedAccounts.create).not.toHaveBeenCalled();

ts/packages/core/test/tools/tools.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import ComposioClient from '@composio/client';
23
import { mockClient } from '../utils/mocks/client.mock';
34
import { toolMocks } from '../utils/mocks/data.mock';
45
import { Tool, ToolListParams, ToolExecuteParams } from '../../src/types/tool.types';
@@ -12,6 +13,7 @@ import {
1213
} from '../utils/toolExecuteUtils';
1314
import { MockProvider } from '../utils/mocks/provider.mock';
1415
import { ValidationError } from '../../src/errors/ValidationErrors';
16+
import { ComposioToolFetchError, ComposioToolNotFoundError } from '../../src/errors/ToolErrors';
1517

1618
// Minimal structural shape for a ComposioError-like value (possibly wrapping
1719
// another error as its `cause`), used to narrow `catch (error: unknown)`
@@ -455,14 +457,67 @@ describe('Tools', () => {
455457
expect(result.slug).toEqual(toolMocks.transformedTool.slug);
456458
});
457459

458-
it('should throw an error if tool is not found', async () => {
460+
it('should throw ComposioToolNotFoundError when the API returns 404', async () => {
459461
const slug = 'NONEXISTENT_TOOL';
462+
const notFound = new ComposioClient.NotFoundError(404, undefined, undefined, new Headers());
460463

461-
mockClient.tools.retrieve.mockRejectedValue(null);
464+
mockClient.tools.retrieve.mockRejectedValueOnce(notFound);
462465

463-
await expect(context.tools.getRawComposioToolBySlug(slug)).rejects.toThrow(
464-
`Unable to retrieve tool with slug ${slug}`
466+
const error = await context.tools.getRawComposioToolBySlug(slug).catch(e => e);
467+
468+
expect(error).toBeInstanceOf(ComposioToolNotFoundError);
469+
expect(error.message).toBe(`Tool with slug ${slug} not found`);
470+
expect(error.cause).toBe(notFound);
471+
});
472+
473+
it('should throw ComposioToolNotFoundError when the API returns 400', async () => {
474+
const slug = 'malformed slug';
475+
const badRequest = new ComposioClient.BadRequestError(
476+
400,
477+
undefined,
478+
undefined,
479+
new Headers()
480+
);
481+
482+
mockClient.tools.retrieve.mockRejectedValueOnce(badRequest);
483+
484+
const error = await context.tools.getRawComposioToolBySlug(slug).catch(e => e);
485+
486+
expect(error).toBeInstanceOf(ComposioToolNotFoundError);
487+
expect(error.cause).toBe(badRequest);
488+
});
489+
490+
it('should not report an invalid API key (401) as tool not found', async () => {
491+
const slug = 'SLACK_FETCH_CONVERSATION_HISTORY';
492+
const unauthorized = new ComposioClient.AuthenticationError(
493+
401,
494+
{ error: { message: 'Invalid API key', code: 801, status: 401 } },
495+
undefined,
496+
new Headers()
465497
);
498+
499+
mockClient.tools.retrieve.mockRejectedValueOnce(unauthorized);
500+
501+
const error = await context.tools.getRawComposioToolBySlug(slug).catch(e => e);
502+
503+
expect(error).toBeInstanceOf(ComposioToolFetchError);
504+
expect(error).not.toBeInstanceOf(ComposioToolNotFoundError);
505+
expect(error.name).toBe('ComposioToolFetchError');
506+
expect(error.message).toBe(`Unable to retrieve tool with slug ${slug}`);
507+
expect(error.cause).toBe(unauthorized);
508+
expect(error.cause.status).toBe(401);
509+
});
510+
511+
it('should wrap non-API failures in ComposioToolFetchError', async () => {
512+
const slug = 'TOOL_SLUG';
513+
const networkError = new Error('socket hang up');
514+
515+
mockClient.tools.retrieve.mockRejectedValueOnce(networkError);
516+
517+
const error = await context.tools.getRawComposioToolBySlug(slug).catch(e => e);
518+
519+
expect(error).toBeInstanceOf(ComposioToolFetchError);
520+
expect(error.cause).toBe(networkError);
466521
});
467522

468523
it('should apply schema modifiers when provided', async () => {

0 commit comments

Comments
 (0)