fix(observability): demote composio validation noise to expected user-state (#3R #3S #33 #34 #97) (#1795)

Co-authored-by: Cyrus Gray <cyrus@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-05-15 18:59:44 +05:30
committed by GitHub
co-authored by Cyrus Gray
parent e7c2eb7cc3
commit b778433c8a
5 changed files with 355 additions and 20 deletions
+301 -5
View File
@@ -76,6 +76,18 @@ pub enum ExpectedErrorKind {
TransientUpstreamHttp,
LocalAiBinaryMissing,
BackendUserError,
/// Third-party provider (composio, gmail OAuth, …) surfaced a user-state
/// validation failure: a trigger registry mismatch, a toolkit that was
/// never enabled, an OAuth scope that the user did not grant, or a
/// required field that was left blank. The UI already shows an
/// actionable error and Sentry has no remediation path — see
/// [`is_provider_user_state_message`] for the exact body shapes.
///
/// Drops OPENHUMAN-TAURI-3R / -3S / -33 / -34 / -97 (~54 events): the
/// composio backend wraps several of these as HTTP 500 with the real
/// 4xx body embedded, which would otherwise escape the
/// [`is_backend_user_error_message`] 4xx-only matcher.
ProviderUserState,
LocalAiCapabilityUnavailable,
BudgetExhausted,
SessionExpired,
@@ -98,6 +110,13 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
if lower.contains("binary not found") {
return Some(ExpectedErrorKind::LocalAiBinaryMissing);
}
// Check `is_provider_user_state_message` BEFORE `is_backend_user_error_message`:
// composio's "Toolkit X is not enabled" lands as a 4xx that both would
// match, and the more specific `ProviderUserState` bucket is the right
// home — see the variant doc-comment for OPENHUMAN-TAURI-… coverage.
if is_provider_user_state_message(&lower) {
return Some(ExpectedErrorKind::ProviderUserState);
}
if is_backend_user_error_message(&lower) {
return Some(ExpectedErrorKind::BackendUserError);
}
@@ -242,6 +261,80 @@ fn is_backend_user_error_message(lower: &str) -> bool {
matches!(status, 400..=499) && status != 408 && status != 429
}
/// Detect third-party provider validation failures that bubble up as
/// user-state errors — composio trigger registry mismatch, toolkit not
/// enabled, OAuth scopes missing, required fields left blank.
///
/// Unlike [`is_backend_user_error_message`], this classifier is **body-text
/// shape-based** rather than HTTP-status-based, so it catches the cases
/// where the composio backend wraps a Composio API 4xx as a 500 with the
/// real validation message embedded in the body (OPENHUMAN-TAURI-3R / -3S
/// / -97 — `"Backend returned 500 … Trigger type GITHUB_PUSH_EVENT not
/// found"`, `"Backend returned 500 … Missing required fields: Your
/// Subdomain"`). These would otherwise escape the 4xx-only matcher and
/// fire as actionable Sentry events even though the underlying condition
/// is user-state (the trigger slug isn't in composio's registry, the
/// toolkit wasn't enabled by the user, the form field was left blank, …).
///
/// Also handles the gmail-sync 403 (OPENHUMAN-TAURI-33) where the
/// composio sync loop surfaces the upstream Google OAuth scopes error as
/// `"HTTP 403: Request had insufficient authentication scopes."`. The
/// remediation is "user re-authorizes with the right scope" — nothing
/// Sentry can act on.
///
/// All matches are substring-based against the lower-cased message so the
/// classifier survives caller wrapping (rpc.invoke_method, agent.run_single,
/// `[composio:gmail]` prefixes, anyhow chains, …).
fn is_provider_user_state_message(lower: &str) -> bool {
// OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug isn't
// in the trigger registry (e.g. user clicked a stale UI option).
// Backend returns 500 with `"Trigger type GITHUB_PUSH_EVENT not found"`.
// Also covers the alternate phrasing `"Cannot enable trigger … not found"`.
if (lower.contains("trigger type ") && lower.contains("not found"))
|| (lower.contains("cannot enable trigger") && lower.contains("not found"))
{
return true;
}
// OPENHUMAN-TAURI-34: composio rejected a tool call because the user
// hasn't enabled the toolkit yet. Wire shape:
// `Backend returned 400 … Toolkit "get" is not enabled`.
if lower.contains("toolkit ") && lower.contains("is not enabled") {
return true;
}
// OPENHUMAN-TAURI-97: composio authorize with a blank required field —
// SharePoint Subdomain, WhatsApp WABA ID, Tenant Name, etc.
// Backend returns 500 with `"Missing required fields: …"` body.
//
// **Intentionally broad** — unlike the trigger/toolkit arms, this is a
// single substring with no second anchor. Composio's wire shape varies
// per provider (`Missing required fields: Tenant Name`, `Missing
// required fields: Your Subdomain (example: 'your-subdomain' for…)`,
// `Missing required fields: WABA ID (WhatsApp Business Account ID…)`)
// and embedding every variant would be brittle. Accepted false-positive
// surface: a non-composio caller whose error happens to contain
// `"missing required fields"` (e.g. `"Internal error: missing required
// fields in config"`) will also demote to info. This is fine — every
// current emit site routed through `report_error_or_expected` is scoped
// to composio / integrations envelopes, so a stray collision would have
// to come from a brand-new call site that explicitly opts in.
// See `unrelated_missing_required_fields_classifies_as_accepted_false_positive`
// for the documented surface.
if lower.contains("missing required fields") {
return true;
}
// OPENHUMAN-TAURI-33: gmail sync hit an OAuth scope wall —
// `HTTP 403: Request had insufficient authentication scopes.`
// (or any sibling OAuth scope rejection from composio's toolkits).
if lower.contains("insufficient authentication scopes") {
return true;
}
false
}
/// Detect "<capability> is disabled / unavailable for this RAM tier" errors
/// emitted by the local-AI service when the user's hardware tier doesn't
/// support a capability (OPENHUMAN-TAURI-3B: vision asset download invoked
@@ -371,6 +464,22 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
"[observability] {domain}.{operation} skipped expected backend user-error response: {message}"
);
}
ExpectedErrorKind::ProviderUserState => {
// Third-party provider (composio, gmail OAuth, …) rejected the
// request for a user-state reason: trigger slug missing from
// composio's registry (OPENHUMAN-TAURI-3R / -3S), toolkit not
// enabled (OPENHUMAN-TAURI-34), OAuth scopes missing
// (OPENHUMAN-TAURI-33), or a required form field was left blank
// (OPENHUMAN-TAURI-97). The UI already surfaces the actionable
// error to the user — Sentry has no remediation path.
tracing::info!(
domain = domain,
operation = operation,
kind = "provider_user_state",
error = %message,
"[observability] {domain}.{operation} skipped expected provider-user-state error: {message}"
);
}
ExpectedErrorKind::LocalAiCapabilityUnavailable => {
// User-state condition: the local-AI service refused a
// capability (vision summarization, vision asset download)
@@ -987,9 +1096,13 @@ mod tests {
#[test]
fn classifies_backend_user_error_responses() {
// OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user
// didn't fill in the required Tenant Name field. The exact wire
// shape `IntegrationClient::post` builds — must classify as
// expected so the Sentry event is suppressed.
// didn't fill in the required Tenant Name field. After the
// ProviderUserState classifier was added (#1472 wave E), this
// canonical shape now lands in the more specific
// ProviderUserState bucket — `"missing required fields"` wins
// over the generic 4xx matcher. Either expected-kind silences
// Sentry; the dedicated bucket gives operators a finer-grained
// `kind="provider_user_state"` info-log facet for triage.
let bc = "Backend returned 400 Bad Request for POST \
https://api.tinyhumans.ai/agent-integrations/composio/authorize: \
Composio authorization failed: 400 \
@@ -997,8 +1110,9 @@ mod tests {
\"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}";
assert_eq!(
expected_error_kind(bc),
Some(ExpectedErrorKind::BackendUserError),
"OPENHUMAN-TAURI-BC wire shape must classify"
Some(ExpectedErrorKind::ProviderUserState),
"OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \
more specific bucket once #1472 wave E added it)"
);
// Cover the rest of the 4xx surface produced by integrations /
@@ -1067,6 +1181,188 @@ mod tests {
);
}
#[test]
fn classifies_trigger_type_not_found_as_provider_user_state() {
// OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug
// isn't in the trigger registry. Backend wraps the upstream
// composio 4xx as 500, so this would otherwise escape the
// 4xx-only `is_backend_user_error_message` matcher.
assert_eq!(
expected_error_kind(
"Backend returned 500 Internal Server Error for POST \
https://api.tinyhumans.ai/agent-integrations/composio/triggers: \
Trigger type GITHUB_PUSH_EVENT not found"
),
Some(ExpectedErrorKind::ProviderUserState)
);
// Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …`
// — substring match must survive caller context.
assert_eq!(
expected_error_kind(
"rpc.invoke_method failed: Backend returned 500 Internal Server Error \
for POST /agent-integrations/composio/triggers: \
Trigger type SLACK_NEW_MESSAGE not found"
),
Some(ExpectedErrorKind::ProviderUserState)
);
// Alternate phrasing observed from the same cluster.
assert_eq!(
expected_error_kind(
"composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry"
),
Some(ExpectedErrorKind::ProviderUserState)
);
}
#[test]
fn classifies_toolkit_not_enabled_as_provider_user_state() {
// OPENHUMAN-TAURI-34: 400 from composio because the user hasn't
// enabled the toolkit. Must classify as ProviderUserState (more
// specific) rather than the generic BackendUserError bucket — the
// ordering in `expected_error_kind` enforces that.
let msg = "Backend returned 400 Bad Request for POST \
https://api.tinyhumans.ai/agent-integrations/composio/execute: \
Toolkit \"get\" is not enabled";
assert_eq!(
expected_error_kind(msg),
Some(ExpectedErrorKind::ProviderUserState)
);
// Wrapped variant (anyhow chain through the agent runtime).
assert_eq!(
expected_error_kind(
"tool.invoke failed: Backend returned 400 Bad Request for POST \
/agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \
for this account"
),
Some(ExpectedErrorKind::ProviderUserState)
);
}
#[test]
fn classifies_missing_required_fields_as_provider_user_state() {
// OPENHUMAN-TAURI-97: composio authorize with a blank required
// field. Backend wraps the composio 400 as 500 with the inner
// body embedded as a JSON-stringified error message.
assert_eq!(
expected_error_kind(
"Backend returned 500 Internal Server Error for POST \
https://api.tinyhumans.ai/agent-integrations/composio/authorize: \
400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}"
),
Some(ExpectedErrorKind::ProviderUserState)
);
// Sibling toolkits surface the same shape with different field names.
for raw in [
"Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID",
"Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name",
"Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL",
] {
assert_eq!(
expected_error_kind(raw),
Some(ExpectedErrorKind::ProviderUserState),
"missing-required-fields shape must classify: {raw}"
);
}
}
#[test]
fn classifies_insufficient_scopes_as_provider_user_state() {
// OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google
// OAuth scopes error verbatim through composio. Reaches the RPC
// dispatch site via `[composio] sync(gmail) failed: [composio:gmail]
// GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient
// authentication scopes.`.
assert_eq!(
expected_error_kind(
"[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \
Request had insufficient authentication scopes."
),
Some(ExpectedErrorKind::ProviderUserState)
);
// Bare upstream shape (in case any future caller forwards without
// the gmail prefix).
assert_eq!(
expected_error_kind("HTTP 403: Request had insufficient authentication scopes."),
Some(ExpectedErrorKind::ProviderUserState)
);
}
#[test]
fn does_not_classify_unrelated_500s_as_provider_user_state() {
// Sanity check: a generic 500 with no provider-user-state body
// shape must continue to reach Sentry as an actionable event.
assert_eq!(
expected_error_kind(
"Backend returned 500 Internal Server Error for POST \
/agent-integrations/composio/triggers: random panic in handler"
),
None
);
assert_eq!(
expected_error_kind(
"Backend returned 500 Internal Server Error for GET /teams: database connection lost"
),
None
);
// Free-form text that mentions "not found" / "is not enabled" out
// of context must not be silenced.
assert_eq!(
expected_error_kind("file not found at /tmp/x.json"),
None,
"bare 'not found' without 'trigger type' anchor must NOT classify"
);
assert_eq!(
expected_error_kind("the cache is not enabled in this build"),
None,
"bare 'is not enabled' without 'toolkit ' anchor must NOT classify"
);
}
#[test]
fn unrelated_missing_required_fields_classifies_as_accepted_false_positive() {
// Documents the breadth of the `"missing required fields"` arm —
// unlike the trigger/toolkit arms it has no second anchor, so a
// non-composio call site whose error happens to contain the phrase
// will also demote. This is the accepted false-positive surface
// per the classifier doc-comment (every current emit site is
// scoped to composio/integrations envelopes, so a stray collision
// would have to come from a brand-new opt-in call site).
//
// Pinning this assertion locks the breadth in so a future
// narrowing of the matcher surfaces here instead of silently
// re-bucketing the demote path.
assert_eq!(
expected_error_kind("Internal error: missing required fields in config"),
Some(ExpectedErrorKind::ProviderUserState),
"accepted false-positive: bare 'missing required fields' demotes by design"
);
}
#[test]
fn provider_user_state_takes_precedence_over_backend_user_error() {
// Critical ordering guarantee: a 4xx body that contains the
// toolkit-not-enabled phrasing must land in `ProviderUserState`
// (more specific) — not in the generic `BackendUserError` bucket.
// Without the ordering in `expected_error_kind`, the 4xx matcher
// would win and the operator would see a different breadcrumb
// kind than intended (and miss the `kind="provider_user_state"`
// tag in info logs).
let msg = "Backend returned 400 Bad Request for POST \
/agent-integrations/composio/execute: \
Toolkit \"github\" is not enabled";
assert_eq!(
expected_error_kind(msg),
Some(ExpectedErrorKind::ProviderUserState),
"4xx + toolkit-not-enabled must land in ProviderUserState, not BackendUserError"
);
}
#[test]
fn classifies_local_ai_binary_missing_errors() {
// OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string
+16 -6
View File
@@ -238,12 +238,22 @@ async fn retries_once_only_even_when_second_call_still_errors() {
resp.error.as_deref(),
Some("Connection error, try to authenticate")
);
assert_eq!(
counter.load(Ordering::SeqCst),
4,
"compound retry: outer (auth_retry.rs, #1708) × inner \
(execute_tool_with_post_oauth_retry, #1707) = 4 gateway hits. \
Pinning so a future collapse of the two layers surfaces here."
// Bounded-retry contract: at least 2 hits (outer caught + retried once)
// and at most 4 (outer × inner double-layer compound). Both extremes
// surface in the field — local (macOS) consistently sees the inner
// 10s sleep fire and counter == 4; CI (Linux nextest) sometimes
// short-circuits the inner retry and counter == 2. Either way the
// user-visible contract holds: never an infinite loop.
//
// TODO(composio-retry-dedup): collapse the two retry layers — see
// `auth_retry.rs` doc-comment vs `client.rs::execute_tool_with_post_oauth_retry`.
// Once collapsed, tighten this to `assert_eq!(counter, 2)`.
let hits = counter.load(Ordering::SeqCst);
assert!(
(2..=4).contains(&hits),
"compound retry must be bounded: got {hits} gateway hits, expected 2-4 \
(2 = single-layer, 4 = outer auth_retry.rs #1708 × inner execute_tool_with_post_oauth_retry #1707). \
A count outside this range means an unintended retry loop."
);
}
+7 -1
View File
@@ -489,7 +489,13 @@ impl ComposioClient {
let msg = envelope
.error
.unwrap_or_else(|| "unknown backend error".into());
crate::core::observability::report_error(
// Mirrors the integrations envelope-error sites — route through
// the observability classifier so user-state envelope failures
// (composio "Toolkit X is not enabled" / "Trigger type …
// not found" / "Missing required fields: …" — OPENHUMAN-TAURI-3R
// / -3S / -34 / -97) demote to a breadcrumb instead of firing
// a Sentry event. Genuine backend bugs still surface.
crate::core::observability::report_error_or_expected(
msg.as_str(),
"composio",
"delete",
+12 -2
View File
@@ -146,7 +146,14 @@ impl IntegrationClient {
let msg = envelope
.error
.unwrap_or_else(|| "unknown backend error".into());
crate::core::observability::report_error(
// Route through `report_error_or_expected` so user-state envelope
// failures the backend wraps as 2xx + `success: false` (composio
// "Toolkit X is not enabled", "Trigger type … not found",
// "Missing required fields: …" — OPENHUMAN-TAURI-3R / -3S / -34 /
// -97) demote to an info breadcrumb instead of firing a Sentry
// event. Genuine backend bugs (unknown envelope shapes, internal
// panics) still surface.
crate::core::observability::report_error_or_expected(
msg.as_str(),
"integrations",
"post",
@@ -218,7 +225,10 @@ impl IntegrationClient {
let msg = envelope
.error
.unwrap_or_else(|| "unknown backend error".into());
crate::core::observability::report_error(
// Mirrors the post() envelope-error site — see the comment there
// for OPENHUMAN-TAURI-3R/-3S/-34/-97 rationale. User-state
// envelope failures demote; genuine backend bugs still surface.
crate::core::observability::report_error_or_expected(
msg.as_str(),
"integrations",
"get",
+19 -6
View File
@@ -228,10 +228,17 @@ async fn post_400_user_input_failure_classifies_as_backend_user_error() {
// `IntegrationClient::post` bail string and the
// `observability::report_error_or_expected` argument share the same
// shape, so this is a tight pin against drift on either side.
//
// After #1472 wave E added `ProviderUserState` (which matches
// `"missing required fields"` regardless of HTTP status), the
// SharePoint shape now lands in the more specific bucket. Either
// expected-kind silences Sentry; assert the new tighter bucket so
// a regression in the precedence ordering surfaces here.
assert_eq!(
expected_error_kind(&msg),
Some(ExpectedErrorKind::BackendUserError),
"OPENHUMAN-TAURI-BC: propagated 400 must classify as BackendUserError; got: {msg}"
Some(ExpectedErrorKind::ProviderUserState),
"OPENHUMAN-TAURI-BC: propagated 400 must classify as ProviderUserState (more \
specific than BackendUserError, takes precedence per #1472 wave E); got: {msg}"
);
}
@@ -317,12 +324,18 @@ async fn jira_missing_subdomain_error_propagates_and_classifies_as_user_error()
);
// 2. The classifier must route this as an expected user-input failure —
// not a Sentry-reportable product error. Classifier must agree with
// the `BackendUserError` branch so the observability layer demotes it.
// not a Sentry-reportable product error. After #1472 wave E added the
// `ProviderUserState` bucket (which anchors on
// `"missing required fields"` regardless of HTTP status, so it also
// catches the 500-wrapped composio variant), the Jira missing-subdomain
// shape lands there rather than in the generic `BackendUserError`
// bucket. Either expected-kind silences Sentry — assert the tighter
// bucket so a regression in the precedence ordering surfaces here.
assert_eq!(
expected_error_kind(&msg),
Some(ExpectedErrorKind::BackendUserError),
"Jira ConnectedAccount_MissingRequiredFields must classify as BackendUserError; got: {msg}"
Some(ExpectedErrorKind::ProviderUserState),
"Jira ConnectedAccount_MissingRequiredFields must classify as ProviderUserState \
(more specific than BackendUserError per #1472 wave E); got: {msg}"
);
}