fix(composio): correct direct-mode v3 execute contract + classify 404/410 as action-not-found (#3219) (#3298)

This commit is contained in:
oxoxDev
2026-06-04 00:52:54 -04:00
committed by GitHub
parent 1fa7b2eef1
commit 46fad3a50a
9 changed files with 228 additions and 47 deletions
+48 -1
View File
@@ -14,6 +14,13 @@ pub enum ComposioErrorClass {
/// matches it). Distinct so the user gets reconnect guidance. See #2913.
TriggerPermission,
RateLimited,
/// Composio answered the execute call with an HTTP 404/410 — the action
/// endpoint or slug is unknown (or the endpoint is deprecated/Gone), NOT a
/// broken connection. Distinct so the user is told their integration is
/// still connected and is **not** advised to re-authenticate a healthy
/// account. See #3219 (direct-mode sent the lowercase toolkit slug to the
/// wrong execute path → 404/410 → misclassified as [`Self::ComposioPlatform`]).
ActionNotFound,
UpstreamProvider,
ComposioPlatform,
Gateway,
@@ -27,6 +34,7 @@ impl ComposioErrorClass {
Self::InsufficientScope => "insufficient_scope",
Self::TriggerPermission => "trigger_permission",
Self::RateLimited => "rate_limited",
Self::ActionNotFound => "action_not_found",
Self::UpstreamProvider => "upstream_provider",
Self::ComposioPlatform => "composio_platform",
Self::Gateway => "gateway",
@@ -37,7 +45,15 @@ impl ComposioErrorClass {
pub fn classify_composio_error(tool: &str, message: &str) -> ComposioErrorClass {
let lower = message.to_ascii_lowercase();
let class = if is_validation_shape(&lower) {
// EARLY status arm (#3219): a Composio HTTP 404/410 means the action
// endpoint/slug is unknown or deprecated — NOT a broken connection. It must
// win over the substring arms below, because the 404 body frequently carries
// the phrase "connection error, try to authenticate" that
// `is_composio_platform_shape` would otherwise match, telling the user to
// re-authenticate a perfectly healthy connection.
let class = if is_action_not_found_shape(&lower) {
ComposioErrorClass::ActionNotFound
} else if is_validation_shape(&lower) {
ComposioErrorClass::Validation
} else if is_insufficient_scope_shape(&lower) {
ComposioErrorClass::InsufficientScope
@@ -74,6 +90,7 @@ pub fn format_provider_error(tool: &str, raw: &str) -> String {
ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, detail),
ComposioErrorClass::TriggerPermission => format_trigger_permission_message(tool),
ComposioErrorClass::RateLimited => format_rate_limited_message(tool, detail),
ComposioErrorClass::ActionNotFound => format_action_not_found_message(tool),
ComposioErrorClass::UpstreamProvider => {
format!("`{tool}` failed at the connected provider: {detail}")
}
@@ -101,6 +118,7 @@ pub fn remap_transport_error(tool: &str, raw: &str) -> String {
ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, &detail),
ComposioErrorClass::TriggerPermission => format_trigger_permission_message(tool),
ComposioErrorClass::RateLimited => format_rate_limited_message(tool, &detail),
ComposioErrorClass::ActionNotFound => format_action_not_found_message(tool),
ComposioErrorClass::Gateway => format!(
"Temporary gateway error while calling `{tool}`: {}",
summarize_gateway(raw)
@@ -157,6 +175,23 @@ fn format_trigger_permission_message(tool: &str) -> String {
)
}
/// Build the action-not-found guidance (issue #3219).
///
/// Deliberately does **not** echo the raw provider `detail`: a Composio 404
/// body often literally reads "connection error, try to authenticate", and
/// surfacing that verbatim would re-introduce the exact misleading re-auth
/// nudge this class exists to suppress. The raw text is still captured in the
/// `tracing::debug!` line in [`classify_composio_error`] for diagnosis.
fn format_action_not_found_message(tool: &str) -> String {
let toolkit = derive_toolkit_slug(tool);
format!(
"`{tool}` couldn't run: Composio reported this action as not found. Your {toolkit} \
integration is still connected and working — this is not a sign-in problem. The action \
name is likely out of date or Composio's API changed; try again with the current action \
name, or report this if it keeps happening."
)
}
fn format_rate_limited_message(tool: &str, detail: &str) -> String {
format!(
"`{tool}` hit an upstream rate limit ({detail}). Wait a minute and retry, or reduce \
@@ -210,6 +245,17 @@ fn is_rate_limited_shape(lower: &str) -> bool {
|| lower.contains("429")
}
/// True when the message carries a Composio HTTP 404 or 410 status (#3219).
///
/// Both `response_error` shapes (`HTTP 404` and `HTTP 404: <body>`) and the
/// wrapped v3/v2 fallback string keep the literal `HTTP <code>` token, so a
/// substring scan is reliable. Scoped to 404 (action/endpoint unknown) and 410
/// (endpoint Gone/deprecated) on purpose — other 4xx/5xx keep their existing
/// validation / scope / rate-limit / gateway classifications.
fn is_action_not_found_shape(lower: &str) -> bool {
lower.contains("http 404") || lower.contains("http 410")
}
fn is_composio_platform_shape(lower: &str) -> bool {
lower.contains("connection error, try to authenticate")
|| lower.contains("not enabled")
@@ -232,6 +278,7 @@ fn is_embedded_provider_failure(lower: &str) -> bool {
|| is_insufficient_scope_shape(lower)
|| is_trigger_permission_shape(lower)
|| is_rate_limited_shape(lower)
|| is_action_not_found_shape(lower)
|| is_composio_platform_shape(lower)
|| lower.contains("composio")
|| lower.contains("google")
@@ -80,6 +80,81 @@ fn true_gateway_stays_gateway_class() {
);
}
// ── HTTP 404/410 action-not-found vs auth (issue #3219) ────────────────
#[test]
fn classifies_http_404_with_auth_phrase_as_action_not_found_not_platform() {
// The exact #3219 shape: a 404 whose body carries the misleading
// "connection error, try to authenticate" phrase. Status must win.
let msg = "HTTP 404: connection error, try to authenticate";
assert_eq!(
classify_composio_error("GMAIL_SEND_EMAIL", msg),
ComposioErrorClass::ActionNotFound
);
}
#[test]
fn classifies_http_410_gone_as_action_not_found() {
let msg = "HTTP 410: This endpoint is deprecated";
assert_eq!(
classify_composio_error("GOOGLEDOCS_UPDATE_DOCUMENT", msg),
ComposioErrorClass::ActionNotFound
);
}
#[test]
fn action_not_found_message_does_not_recommend_reauth() {
let mapped = format_provider_error(
"GMAIL_SEND_EMAIL",
"HTTP 404: connection error, try to authenticate",
);
let lower = mapped.to_lowercase();
assert!(mapped.contains("[composio:error:action_not_found]"));
assert!(lower.contains("still connected"));
// Must NOT echo the misleading provider phrase or nudge re-auth/reconnect.
assert!(
!lower.contains("authenticate"),
"must not surface the re-auth phrase: {mapped}"
);
assert!(
!lower.contains("reconnect"),
"must not tell the user to reconnect a healthy connection: {mapped}"
);
assert!(
!mapped.contains("Settings → Connections"),
"must not show the re-auth CTA: {mapped}"
);
}
#[test]
fn genuine_auth_phrase_without_http_status_stays_platform() {
// Same phrase, but with NO 4xx status — a real platform/connection issue
// must still classify as ComposioPlatform.
let msg = "connection error, try to authenticate";
assert_eq!(
classify_composio_error("GMAIL_SEND_EMAIL", msg),
ComposioErrorClass::ComposioPlatform
);
}
#[test]
fn wrapped_v3_v2_404_fallback_classifies_as_action_not_found() {
// The real transport string after both v3 and v2 fail — the wrapped form
// produced by `execute_action`, fed through `remap_transport_error`.
let raw = "Composio execute failed on v3 (Composio v3 action execution failed: \
HTTP 404: connection error, try to authenticate) and v2 fallback \
(Composio v2 action execution failed: HTTP 410: Gone)";
let mapped = remap_transport_error("GMAIL_SEND_EMAIL", raw);
assert!(
mapped.contains("[composio:error:action_not_found]"),
"wrapped 404/410 must map to action_not_found, got: {mapped}"
);
assert!(
!mapped.to_lowercase().contains("authenticate"),
"wrapped path must not surface the re-auth phrase: {mapped}"
);
}
// ── Trigger-permission denial (issue #2913) ───────────────────────────
#[test]
+22 -11
View File
@@ -362,10 +362,22 @@ impl ComposioTool {
entity_id: Option<&str>,
connected_account_ref: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let tool_slug = normalize_tool_slug(action_name);
// The Composio v3 action-execute contract keys off the UPPERCASE_SNAKE
// *action* slug (e.g. `GMAIL_SEND_EMAIL`) at `/tools/execute/{slug}`.
// The previous code lowercased + dashed it into the *toolkit* slug
// (`gmail-send-email`) and posted to the wrong `/tools/{slug}/execute`
// path, so every direct-mode execute 404'd (issue #3219). Pass the
// action slug through verbatim (trimmed only); the v2 fallback already
// used the same untransformed name.
let action_slug = action_name.trim();
match self
.execute_action_v3(&tool_slug, params.clone(), entity_id, connected_account_ref)
.execute_action_v3(
action_slug,
params.clone(),
entity_id,
connected_account_ref,
)
.await
{
Ok(result) => Ok(result),
@@ -379,12 +391,15 @@ impl ComposioTool {
}
fn build_execute_action_v3_request(
tool_slug: &str,
action_slug: &str,
params: serde_json::Value,
entity_id: Option<&str>,
connected_account_ref: Option<&str>,
) -> (String, serde_json::Value) {
let url = format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute");
// POST /api/v3/tools/execute/{ACTION_SLUG} — the action slug stays
// UPPERCASE_SNAKE (see `execute_action`). Path is `/tools/execute/{slug}`,
// NOT `/tools/{slug}/execute` (issue #3219).
let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{action_slug}");
let account_ref = connected_account_ref.and_then(|candidate| {
let trimmed_candidate = candidate.trim();
(!trimmed_candidate.is_empty()).then_some(trimmed_candidate)
@@ -406,18 +421,18 @@ impl ComposioTool {
async fn execute_action_v3(
&self,
tool_slug: &str,
action_slug: &str,
params: serde_json::Value,
entity_id: Option<&str>,
connected_account_ref: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let (_default_url, body) = Self::build_execute_action_v3_request(
tool_slug,
action_slug,
params,
entity_id,
connected_account_ref,
);
let url = format!("{}/tools/{tool_slug}/execute", self.base_v3);
let url = format!("{}/tools/execute/{action_slug}", self.base_v3);
self.ensure_request_url(&url)?;
@@ -885,10 +900,6 @@ fn normalize_entity_id(entity_id: &str) -> String {
}
}
fn normalize_tool_slug(action_name: &str) -> String {
action_name.trim().replace('_', "-").to_ascii_lowercase()
}
fn map_v3_tools_to_actions(items: Vec<ComposioV3Tool>) -> Vec<ComposioAction> {
items
.into_iter()
+67 -19
View File
@@ -184,18 +184,6 @@ fn normalize_entity_id_falls_back_to_default_when_blank() {
assert_eq!(normalize_entity_id("workspace-user"), "workspace-user");
}
#[test]
fn normalize_tool_slug_supports_legacy_action_name() {
assert_eq!(
normalize_tool_slug("GMAIL_FETCH_EMAILS"),
"gmail-fetch-emails"
);
assert_eq!(
normalize_tool_slug(" github-list-repos "),
"github-list-repos"
);
}
#[test]
fn extract_redirect_url_supports_v2_and_v3_shapes() {
let v2 = json!({"redirectUrl": "https://app.composio.dev/connect-v2"});
@@ -313,19 +301,24 @@ fn composio_api_base_url_is_v3() {
}
#[test]
fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() {
fn build_execute_action_v3_request_uses_execute_path_and_uppercase_action_slug() {
// #3219: v3 action execute is POST /tools/execute/{ACTION_SLUG} with the
// UPPERCASE_SNAKE action slug — NOT /tools/{lowercase-dashed}/execute.
let (url, body) = ComposioTool::build_execute_action_v3_request(
"gmail-send-email",
json!({"to": "test@example.com"}),
"GMAIL_SEND_EMAIL",
json!({"recipient_email": "test@example.com"}),
Some("workspace-user"),
Some("account-42"),
);
assert_eq!(
url,
"https://backend.composio.dev/api/v3/tools/gmail-send-email/execute"
"https://backend.composio.dev/api/v3/tools/execute/GMAIL_SEND_EMAIL"
);
assert_eq!(
body["arguments"]["recipient_email"],
json!("test@example.com")
);
assert_eq!(body["arguments"]["to"], json!("test@example.com"));
assert_eq!(body["user_id"], json!("workspace-user"));
assert_eq!(body["connected_account_id"], json!("account-42"));
}
@@ -333,7 +326,7 @@ fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() {
#[test]
fn build_execute_action_v3_request_drops_blank_optional_fields() {
let (url, body) = ComposioTool::build_execute_action_v3_request(
"github-list-repos",
"GITHUB_LIST_REPOSITORIES",
json!({}),
None,
Some(" "),
@@ -341,7 +334,7 @@ fn build_execute_action_v3_request_drops_blank_optional_fields() {
assert_eq!(
url,
"https://backend.composio.dev/api/v3/tools/github-list-repos/execute"
"https://backend.composio.dev/api/v3/tools/execute/GITHUB_LIST_REPOSITORIES"
);
assert_eq!(body["arguments"], json!({}));
assert!(body.get("connected_account_id").is_none());
@@ -488,6 +481,61 @@ async fn list_tool_schemas_v3_sends_repeated_tags_to_v3_tools_endpoint() {
assert_eq!(items[0].toolkit_slug.as_deref(), Some("github"));
}
// ── execute_action over HTTP (correct v3 path/slug/body reach the wire) ────
#[tokio::test]
async fn execute_action_v3_posts_uppercase_slug_to_execute_path() {
use axum::{extract::Path, routing::post, Json, Router};
use std::sync::Mutex;
// Capture the path slug + body the server actually received so we assert on
// the WIRE shape, not just the pure builder. Regression guard for #3219.
let captured: Arc<Mutex<Option<(String, serde_json::Value)>>> = Arc::new(Mutex::new(None));
let sink = captured.clone();
let app = Router::new().route(
"/tools/execute/{slug}",
post(
move |Path(slug): Path<String>, Json(body): Json<serde_json::Value>| {
let sink = sink.clone();
async move {
*sink.lock().unwrap() = Some((slug, body));
Json(json!({ "successful": true, "data": { "id": "msg_1" } }))
}
},
),
);
let base = start_mock_backend(app).await;
let tool = ComposioTool::new_with_v3_base("ck_test_direct", None, test_security(), base);
let result = tool
.execute_action(
"GMAIL_SEND_EMAIL",
json!({ "recipient_email": "a@b.com" }),
Some("workspace-user"),
Some("ca_42"),
)
.await
.expect("v3 execute should succeed against the mock");
assert_eq!(result["successful"], json!(true));
let (slug, body) = captured
.lock()
.unwrap()
.clone()
.expect("mock server should have observed the execute request");
// The action slug must reach the URL UPPERCASE_SNAKE — the toolkit-slug
// transform (gmail-send-email) was the root cause of the 404 in #3219.
assert_eq!(
slug, "GMAIL_SEND_EMAIL",
"must POST the uppercase action slug"
);
assert_eq!(body["arguments"]["recipient_email"], json!("a@b.com"));
assert_eq!(body["user_id"], json!("workspace-user"));
assert_eq!(body["connected_account_id"], json!("ca_42"));
}
// ── ensure_https ──────────────────────────────────────────────────────────
#[test]
@@ -164,7 +164,7 @@ async fn direct_composio_tool_uses_loopback_for_list_execute_connect_and_account
let execute_result = tool
.execute(json!({
"action": "execute",
"tool_slug": "gmail-fetch-emails",
"tool_slug": "GMAIL_FETCH_EMAILS",
"params": { "query": "newer_than:1d" },
"connected_account_id": "acct-gmail"
}))
@@ -204,7 +204,7 @@ async fn direct_composio_tool_uses_loopback_for_list_execute_connect_and_account
}));
assert!(requests.iter().any(|request| {
request.method == "POST"
&& request.path == "/api/v3/tools/gmail-fetch-emails/execute"
&& request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS"
&& request.body.pointer("/user_id") == Some(&json!(" entity-override "))
&& request.body.pointer("/connected_account_id") == Some(&json!("acct-gmail"))
}));
@@ -319,14 +319,14 @@ async fn composio_direct_handler(State(state): State<MockState>, request: Reques
]
}))
.into_response(),
(Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({
(Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({
"successful": true,
"data": {
"messages": [{ "id": "msg-direct", "subject": "hello" }]
}
}))
.into_response(),
(Method::POST, "/api/v3/tools/fallback-action/execute") => (
(Method::POST, "/api/v3/tools/execute/FALLBACK_ACTION") => (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": { "message": "temporary v3 outage" }
@@ -338,7 +338,7 @@ async fn composio_direct_handler(State(state): State<MockState>, request: Reques
"input": body.get("input").cloned().unwrap_or_else(|| json!({}))
}))
.into_response(),
(Method::POST, "/api/v3/tools/broken-action/execute") => (
(Method::POST, "/api/v3/tools/execute/BROKEN_ACTION") => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
@@ -282,7 +282,7 @@ async fn round25_direct_mode_ops_use_loopback_factory_for_tools_connections_and_
}));
assert!(requests.iter().any(|request| {
request.method == Method::POST
&& request.path == "/api/v3/tools/gmail-fetch-emails/execute"
&& request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS"
&& request.body["arguments"]["query"] == "label:INBOX"
&& request.body["user_id"] == "entity-round25"
}));
@@ -402,7 +402,7 @@ async fn composio_direct_handler(State(state): State<MockState>, request: Reques
}
}))
.into_response(),
(Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({
(Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({
"successful": true,
"data": {
"messages": [
@@ -411,7 +411,7 @@ async fn composio_direct_handler(State(state): State<MockState>, request: Reques
}
}))
.into_response(),
(Method::POST, "/api/v3/tools/gmail-send-email/execute") => Json(json!({
(Method::POST, "/api/v3/tools/execute/GMAIL_SEND_EMAIL") => Json(json!({
"successful": false,
"error": "provider rejected send",
"data": {
@@ -454,7 +454,7 @@ async fn round20_direct_composio_tool_covers_fallback_sanitizing_and_account_edg
}));
assert!(requests.iter().any(|request| {
request.method == Method::POST
&& request.path == "/api/v3/tools/gmail-fetch-emails/execute"
&& request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS"
&& request.body.pointer("/connected_account_id") == Some(&json!("acct-gmail"))
}));
}
@@ -780,13 +780,13 @@ async fn composio_direct_handler(State(state): State<MockState>, request: Reques
StatusCode::BAD_REQUEST,
"v2 broken list mentions connected_account_id and user_id",
),
(Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({
(Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({
"successful": true,
"data": { "messages": [{ "id": "msg-round20" }] },
"error": null
}))
.into_response(),
(Method::POST, "/api/v3/tools/broken-action/execute") => message_fail(
(Method::POST, "/api/v3/tools/execute/BROKEN_ACTION") => message_fail(
StatusCode::BAD_REQUEST,
"bad execute connected_account_id user_id entity_id",
),
@@ -91,7 +91,7 @@ async fn round24_composio_direct_covers_v3_v2_fallbacks_and_account_shapes() {
.iter()
.find(|request| {
request.method == Method::POST
&& request.path == "/api/v3/tools/gmail-send-email/execute"
&& request.path == "/api/v3/tools/execute/GMAIL_SEND_EMAIL"
})
.expect("v3 execute request");
assert_eq!(v3_execute.body["connected_account_id"], "conn-secret");
@@ -267,7 +267,7 @@ async fn composio_handler(State(state): State<MockState>, request: Request) -> R
]
}))
.into_response(),
(Method::POST, "/api/v3/tools/gmail-send-email/execute") => (
(Method::POST, "/api/v3/tools/execute/GMAIL_SEND_EMAIL") => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": {
@@ -320,7 +320,7 @@ async fn round26_composio_direct_tool_covers_connect_execute_and_error_fallbacks
let v3_execute = requests
.iter()
.find(|request| {
request.method == Method::POST && request.path == "/api/v3/tools/round26-action/execute"
request.method == Method::POST && request.path == "/api/v3/tools/execute/ROUND26_ACTION"
})
.expect("v3 execute request");
assert_eq!(v3_execute.body["user_id"], "entity-round26");
@@ -463,12 +463,12 @@ async fn composio_handler(State(state): State<MockState>, request: Request) -> R
]
}))
.into_response(),
(Method::POST, "/api/v3/tools/round26-action/execute") => Json(json!({
(Method::POST, "/api/v3/tools/execute/ROUND26_ACTION") => Json(json!({
"successful": true,
"data": { "message": "v3-execute-round26" }
}))
.into_response(),
(Method::POST, "/api/v3/tools/round26-v2-only/execute") => (
(Method::POST, "/api/v3/tools/execute/ROUND26_V2_ONLY") => (
StatusCode::BAD_GATEWAY,
Json(json!({ "message": "v3 execute unavailable" })),
)