Skip to content
Open
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
51 changes: 36 additions & 15 deletions packages/actions/src/connectors/composio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface ComposioConnectedAccount {
}

const MAX_PAGES = 50;
const DEFAULT_TIMEOUT_MS = 30_000;

/** Auth configs change on dashboard timescales; thread mounts must not
* re-walk them. */
Expand Down Expand Up @@ -165,9 +166,14 @@ export function composioConnector(config: {
entityId?: (ctx: RunContext) => string;
apps?: string[];
baseUrl?: string;
timeoutMs?: number;
}): Connector {
const baseUrl = config.baseUrl ?? "https://backend.composio.dev";
let normalizedToRaw = new Map<string, { raw: string; toolkit: string }>();
const timeoutMs =
config.timeoutMs !== undefined && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 && config.timeoutMs <= 2_147_483_647
? config.timeoutMs
: DEFAULT_TIMEOUT_MS;

async function composioFetch(
path: string,
Expand All @@ -176,23 +182,38 @@ export function composioConnector(config: {
const url = joinUrl(baseUrl, path);
for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value);
debugConnectorHttp("composio", options.method ?? "GET", path);
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
"x-api-key": config.apiKey,
accept: "application/json",
...(options.body === undefined ? {} : { "content-type": "application/json" }),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
});
const text = await response.text();
let payload: unknown;
const controller = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);

try {
payload = text ? JSON.parse(text) : {};
} catch {
throw new Error(`Composio ${path} response was not valid JSON (${response.status})`);
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
"x-api-key": config.apiKey,
accept: "application/json",
...(options.body === undefined ? {} : { "content-type": "application/json" }),
},
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
signal: controller.signal,
});
const text = await response.text();
let payload: unknown;
try {
payload = text ? JSON.parse(text) : {};
} catch {
throw new Error(`Composio ${path} response was not valid JSON (${response.status})`);
}
return { ok: response.ok, status: response.status, payload };
} catch (error) {
if (timedOut) throw new Error(`Composio ${path} request timed out after ${timeoutMs}ms`);
throw error;
} finally {
clearTimeout(timeout);
}
return { ok: response.ok, status: response.status, payload };
}

/** Walk a cursor-paginated Composio listing to completion (fail-closed on
Expand Down
19 changes: 19 additions & 0 deletions packages/actions/tests/connectors/connectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ describe("composioConnector", () => {
await expect(connector.descriptors()).rejects.toThrow("Composio pagination loop");
});

it("times out when a response stalls before sending headers", async () => {
const server = await startServer(() => new Promise(() => {}));
closers.push(server.close);

const connector = composioConnector({ apiKey: "secret", baseUrl: server.url, apps: ["gmail"], timeoutMs: 20 });
await expect(connector.descriptors()).rejects.toThrow("Composio /api/v3.1/tools request timed out after 20ms");
});

it("keeps the timeout active while reading the response body", async () => {
const server = await startServer((_req, res) => {
res.setHeader("content-type", "application/json");
res.flushHeaders();
});
closers.push(server.close);

const connector = composioConnector({ apiKey: "secret", baseUrl: server.url, apps: ["gmail"], timeoutMs: 20 });
await expect(connector.descriptors()).rejects.toThrow("Composio /api/v3.1/tools request timed out after 20ms");
});

it("bare (no apps) is LAZY: descriptors() loads nothing and fetches nothing", async () => {
// Connection-scoped tool loading (spec 2026-07-20): the old unscoped
// full-catalog walk is gone. Discovery rides the toolkit index; schemas
Expand Down