Skip to content

Commit c8ce7de

Browse files
authored
Merge pull request #108 from ntdatt812/fix/composio-surface-error-body
fix(composio): surface the error body instead of discarding it
2 parents 68b5a27 + 6338d17 commit c8ce7de

2 files changed

Lines changed: 174 additions & 8 deletions

File tree

crates/tinymemory-core/src/sync/pipelines/composio/client.rs

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ impl ComposioClient {
186186
.map_err(|error| anyhow::anyhow!("Composio direct transport error: {error}"))?;
187187
let status = response.status();
188188
if !status.is_success() {
189-
let _ = response.bytes().await;
190-
anyhow::bail!("Composio direct request failed with HTTP {status}");
189+
let body = response.text().await.unwrap_or_default();
190+
anyhow::bail!(describe_failure("direct", status, &body));
191191
}
192192
let raw: serde_json::Value = decode_response(response, "direct").await?;
193193
Ok(decode_direct_response(raw))
@@ -218,8 +218,8 @@ impl ComposioClient {
218218
.map_err(|error| anyhow::anyhow!("Composio proxy transport error: {error}"))?;
219219
let status = response.status();
220220
if !status.is_success() {
221-
let _ = response.bytes().await;
222-
anyhow::bail!("Composio proxy request failed with HTTP {status}");
221+
let body = response.text().await.unwrap_or_default();
222+
anyhow::bail!(describe_failure("proxy", status, &body));
223223
}
224224
let raw: serde_json::Value = response
225225
.json()
@@ -287,17 +287,102 @@ fn retryable_provider_error(error: Option<&str>) -> bool {
287287
/// needle that both status-bail messages also matched.
288288
fn retryable_transport_error(error: &anyhow::Error) -> bool {
289289
let message = error.to_string();
290+
// Anchored on the status clause this module actually emits. A bare
291+
// "HTTP 429" needle would now be forgeable: the failure message carries the
292+
// response body, and a body that merely mentions another status must not
293+
// turn a permanent 400 into a retry.
290294
[
291-
"HTTP 429",
292-
"HTTP 502",
293-
"HTTP 503",
294-
"HTTP 504",
295+
"failed with HTTP 429",
296+
"failed with HTTP 502",
297+
"failed with HTTP 503",
298+
"failed with HTTP 504",
295299
"transport error",
296300
]
297301
.iter()
298302
.any(|needle| message.contains(needle))
299303
}
300304

305+
/// Longest body snippet echoed for a response whose shape we do not recognise.
306+
const FAILURE_BODY_LIMIT: usize = 400;
307+
308+
/// Describe a non-2xx Composio response, using the body rather than throwing it away.
309+
///
310+
/// Composio answers with a structured error whose `message` and `suggested_fix`
311+
/// name the actual problem and how to correct it — an entity-id mismatch says
312+
/// which id to use instead. Discarding it left callers with a bare status line
313+
/// and no route to a fix.
314+
///
315+
/// The `failed with HTTP {status}` clause is load-bearing: [`retryable_transport_error`]
316+
/// keys off it, so it stays first and stays verbatim.
317+
///
318+
/// Only the known error fields are surfaced. An unrecognised body is truncated
319+
/// instead of echoed whole, so an unexpected payload cannot pour arbitrary
320+
/// content into logs.
321+
fn describe_failure(surface: &str, status: reqwest::StatusCode, body: &str) -> String {
322+
let head = format!("Composio {surface} request failed with HTTP {status}");
323+
match failure_detail(body) {
324+
Some(detail) => format!("{head}: {detail}"),
325+
None => head,
326+
}
327+
}
328+
329+
/// Pull the human-meaningful part out of a Composio error body.
330+
fn failure_detail(body: &str) -> Option<String> {
331+
let trimmed = body.trim();
332+
if trimmed.is_empty() {
333+
return None;
334+
}
335+
336+
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
337+
if let Some(detail) = structured_detail(&parsed) {
338+
return Some(detail);
339+
}
340+
}
341+
342+
Some(truncate(trimmed, FAILURE_BODY_LIMIT))
343+
}
344+
345+
/// `{"error": {"message": .., "slug": .., "suggested_fix": ..}}`, or a bare
346+
/// `{"error": "..."}`.
347+
fn structured_detail(parsed: &serde_json::Value) -> Option<String> {
348+
let error = parsed.get("error")?;
349+
350+
if let Some(text) = error.as_str() {
351+
let text = text.trim();
352+
return (!text.is_empty()).then(|| truncate(text, FAILURE_BODY_LIMIT));
353+
}
354+
355+
let field = |name: &str| {
356+
error
357+
.get(name)
358+
.and_then(serde_json::Value::as_str)
359+
.map(str::trim)
360+
.filter(|value| !value.is_empty())
361+
};
362+
363+
let message = field("message")?;
364+
let mut detail = truncate(message, FAILURE_BODY_LIMIT);
365+
if let Some(slug) = field("slug") {
366+
detail.push_str(&format!(" [{slug}]"));
367+
}
368+
if let Some(fix) = field("suggested_fix") {
369+
detail.push_str(&format!(
370+
" — suggested fix: {}",
371+
truncate(fix, FAILURE_BODY_LIMIT)
372+
));
373+
}
374+
Some(detail)
375+
}
376+
377+
/// Cut on a char boundary so a multi-byte body cannot panic the error path.
378+
fn truncate(value: &str, limit: usize) -> String {
379+
if value.chars().count() <= limit {
380+
return value.to_owned();
381+
}
382+
let kept: String = value.chars().take(limit).collect();
383+
format!("{kept}…")
384+
}
385+
301386
async fn decode_response(
302387
response: reqwest::Response,
303388
mode: &str,

crates/tinymemory-core/src/sync/pipelines/composio/client_tests.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,84 @@ fn flat_proxy_response_remains_supported() {
8080
assert!(response.successful);
8181
assert_eq!(response.data["items"], serde_json::json!([1]));
8282
}
83+
84+
/// The failure message now carries the response body, so a body that merely
85+
/// mentions another status must not turn a permanent failure into a retry.
86+
/// This is the hazard the needles were tightened against.
87+
#[test]
88+
fn a_surfaced_body_cannot_forge_a_retryable_status() {
89+
let retry = |m: &str| retryable_transport_error(&anyhow::anyhow!("{m}"));
90+
assert!(!retry(
91+
"Composio direct request failed with HTTP 400 Bad Request: upstream said HTTP 503"
92+
));
93+
assert!(!retry(
94+
"Composio proxy request failed with HTTP 401 Unauthorized: retry after HTTP 429"
95+
));
96+
// The real ones still classify.
97+
assert!(retry(
98+
"Composio direct request failed with HTTP 429 Too Many Requests: slow down"
99+
));
100+
}
101+
102+
/// Composio's structured error names the problem and how to fix it. This is the
103+
/// payload from the report, verbatim.
104+
#[test]
105+
fn a_structured_error_body_reaches_the_message() {
106+
let body = r#"{"error":{"message":"Connected account user ID does not match the provided user ID.","code":1812,"slug":"ActionExecute_ConnectedAccountEntityIdMismatch","status":400,"suggested_fix":"The connected_account_id you provided belongs to a different entity."}}"#;
107+
let message = describe_failure("direct", reqwest::StatusCode::BAD_REQUEST, body);
108+
109+
assert!(
110+
message.starts_with("Composio direct request failed with HTTP 400"),
111+
"the status clause must stay first and verbatim: {message}"
112+
);
113+
assert!(message.contains("Connected account user ID does not match"));
114+
assert!(message.contains("ActionExecute_ConnectedAccountEntityIdMismatch"));
115+
assert!(
116+
message.contains("belongs to a different entity"),
117+
"the suggested fix is the part that turns a dead end into an action: {message}"
118+
);
119+
}
120+
121+
/// A bare `{"error": "..."}` string body is the other shape Composio returns.
122+
#[test]
123+
fn a_bare_error_string_body_reaches_the_message() {
124+
let body = r#"{"error":"You have exceeded your credits limit.","tag":"NO_MORE_CREDITS"}"#;
125+
let message = describe_failure("proxy", reqwest::StatusCode::PAYMENT_REQUIRED, body);
126+
assert!(message.contains("exceeded your credits limit"), "{message}");
127+
}
128+
129+
/// An unrecognised body is echoed but bounded, so an unexpected payload cannot
130+
/// pour arbitrary content into the logs.
131+
#[test]
132+
fn an_unrecognised_body_is_truncated() {
133+
let body = "x".repeat(5_000);
134+
let message = describe_failure("direct", reqwest::StatusCode::BAD_GATEWAY, &body);
135+
assert!(
136+
message.contains('…'),
137+
"expected an elision marker: {message}"
138+
);
139+
assert!(
140+
message.chars().count() < 600,
141+
"the message grew to {} chars",
142+
message.chars().count()
143+
);
144+
}
145+
146+
/// Truncation must cut on a char boundary — a multi-byte body must not panic
147+
/// the error path.
148+
#[test]
149+
fn truncation_survives_multibyte_bodies() {
150+
let body = "é".repeat(5_000);
151+
let message = describe_failure("direct", reqwest::StatusCode::BAD_GATEWAY, &body);
152+
assert!(message.contains('…'), "{message}");
153+
}
154+
155+
/// No body, no change: the status line stands on its own as before.
156+
#[test]
157+
fn an_empty_body_leaves_the_status_line_alone() {
158+
let message = describe_failure("direct", reqwest::StatusCode::NOT_FOUND, " ");
159+
assert_eq!(
160+
message,
161+
"Composio direct request failed with HTTP 404 Not Found"
162+
);
163+
}

0 commit comments

Comments
 (0)