mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
7344 lines
351 KiB
Rust
7344 lines
351 KiB
Rust
//! Centralised error reporting for the core, plus a Sentry
|
||
//! `before_send` filters that drop deterministic provider noise:
|
||
//! per-attempt transient-upstream failures, budget-exhausted user-state,
|
||
//! and transient updater failures.
|
||
//!
|
||
//! Wraps `tracing::error!` (which the global subscriber forwards to Sentry via
|
||
//! `sentry-tracing`) inside a `sentry::with_scope` so each captured event
|
||
//! carries consistent tags identifying the failing domain/operation plus any
|
||
//! callsite-specific context (session id, request id, tool name, …).
|
||
//!
|
||
//! Why this helper exists: errors that bubble up as `Result::Err` without ever
|
||
//! being logged at error level never reach Sentry. The agent-turn path is the
|
||
//! canonical example — `run_single` used to publish a `DomainEvent::AgentError`
|
||
//! and return `Err(_)`, but Sentry never saw it. Funnel error sites through
|
||
//! `report_error` so they show up tagged and grep-friendly in Sentry.
|
||
|
||
use std::fmt::Display;
|
||
|
||
/// A `(key, value)` pair attached as a Sentry tag. Tags are short, indexed,
|
||
/// and filterable in the Sentry UI — prefer them over free-form fields for
|
||
/// anything you'd want to facet on (`error_kind`, `tool_name`, `method`).
|
||
pub type Tag<'a> = (&'a str, &'a str);
|
||
|
||
/// HTTP status codes that the reliable-provider layer already handles via
|
||
/// retry + fallback, so per-attempt Sentry reports add noise without signal:
|
||
///
|
||
/// - **408** Request Timeout
|
||
/// - **429** Too Many Requests
|
||
/// - **502** Bad Gateway
|
||
/// - **503** Service Unavailable
|
||
/// - **504** Gateway Timeout
|
||
///
|
||
/// Single source of truth for both the call-site classifier
|
||
/// (`openhuman::inference::provider::ops::should_report_provider_http_failure`) and the
|
||
/// `before_send` filter (`is_transient_provider_http_failure`). Update here
|
||
/// and both sites pick it up — keeps the two layers from drifting.
|
||
pub const TRANSIENT_PROVIDER_HTTP_STATUSES: &[u16] = &[408, 429, 502, 503, 504, 520];
|
||
|
||
/// HTTP status codes that represent transient backend / integration transport
|
||
/// failures rather than application bugs. Keep this as strings because Sentry
|
||
/// tags are strings, and the before_send classifiers match tag values exactly.
|
||
pub const TRANSIENT_HTTP_STATUSES: &[&str] = &["408", "429", "502", "503", "504", "520"];
|
||
|
||
/// Transport-layer phrases observed from reqwest / hyper for temporary
|
||
/// upstream interruptions. Keep these specific so rare configuration failures
|
||
/// still reach Sentry.
|
||
pub const TRANSIENT_TRANSPORT_PHRASES: &[&str] = &[
|
||
"timeout",
|
||
"operation timed out",
|
||
"connection forcibly closed",
|
||
"connection reset",
|
||
"tls handshake eof",
|
||
"error sending request",
|
||
];
|
||
|
||
/// HTTP statuses from updater probes that are expected GitHub/network noise:
|
||
/// unauthenticated GitHub API rate-limit / policy 403s plus gateway/server
|
||
/// hiccups. Scoped to updater domains/messages by [`is_updater_transient_event`].
|
||
const UPDATER_TRANSIENT_HTTP_STATUSES: &[u16] = &[403, 500, 502, 503, 504];
|
||
|
||
/// Message fragments observed from Tauri/core updater transient failures.
|
||
/// Keep these updater-specific so unrelated GitHub or generic transport
|
||
/// failures still reach Sentry.
|
||
///
|
||
const UPDATER_TRANSIENT_MESSAGE_PHRASES: &[&str] = &[
|
||
"failed to check for updates: error sending request",
|
||
"github api error: 403",
|
||
"github api error: 5",
|
||
"error sending request for url (https://github.com/tinyhumansai/openhuman/releases/",
|
||
"update endpoint did not respond with a successful status code",
|
||
];
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ExpectedErrorKind {
|
||
LocalAiDisabled,
|
||
ApiKeyMissing,
|
||
NetworkUnreachable,
|
||
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,
|
||
/// A user-configured custom cloud provider (`custom_openai` → DeepSeek
|
||
/// / OpenRouter / Moonshot / …) rejected the request because of the
|
||
/// user's **model / parameter configuration**: an OpenHuman abstract
|
||
/// tier alias leaked to a provider that only speaks its native ids
|
||
/// (#2079), an unknown / stale model pin (#2202), or a model-specific
|
||
/// temperature constraint (#2076 — Moonshot Kimi K2). The provider
|
||
/// HTTP layer (`providers::ops::api_error`) already demotes its own
|
||
/// per-attempt event; this catches the *re-report* when the same
|
||
/// error is raised again by `agent.run_single` /
|
||
/// `web_channel.run_chat_task` under `domain=agent` / `web_channel`.
|
||
/// Deterministic user-config state surfaced in the UI — Sentry has no
|
||
/// remediation path (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH, ~273
|
||
/// events). See
|
||
/// [`crate::openhuman::inference::provider::is_provider_config_rejection_message`]
|
||
/// for the polarity contract and exact body shapes.
|
||
ProviderConfigRejection,
|
||
LocalAiCapabilityUnavailable,
|
||
BudgetExhausted,
|
||
SessionExpired,
|
||
/// Boot-window failure where the in-process core HTTP listener
|
||
/// (`127.0.0.1:<port>`) is not yet accepting connections, so a sibling
|
||
/// component (frontend RPC relay, agent-integrations client) sees a TCP
|
||
/// connect refused. The condition self-resolves once the core finishes
|
||
/// binding — typically within a few seconds of app launch — and no retry
|
||
/// on the calling side can do better than waiting it out.
|
||
///
|
||
/// Distinct from [`ExpectedErrorKind::NetworkUnreachable`] (which covers
|
||
/// real user-environment network problems — VPN drop, captive portal,
|
||
/// ISP block) because:
|
||
///
|
||
/// - The remediation is internal lifecycle (the core's own startup), not
|
||
/// user action. Sentry has nothing to act on either way, but conflating
|
||
/// the two buckets makes "which class of transport failure is
|
||
/// spiking?" un-answerable.
|
||
/// - Loopback URLs (`127.0.0.1:` / `localhost:`) carry no PII, so the
|
||
/// demoted breadcrumb can stay sparse (debug level, metadata-only
|
||
/// fields) instead of warn-level with the full body included.
|
||
///
|
||
LoopbackUnavailable,
|
||
PromptInjectionBlocked,
|
||
ContextWindowExceeded,
|
||
/// The memory-store chunk DB's per-path circuit breaker is currently open
|
||
/// because too many consecutive SQLite init attempts failed. This is the
|
||
/// breaker doing its job — it opened *after* the underlying transient
|
||
/// SQLite I/O errors (typically Windows `xShmMap` / `unable to open
|
||
/// database file` against `chunks.db`, see `is_sqlite_io_transient` /
|
||
/// `is_io_open_error`) hit a threshold, and it self-resolves once the
|
||
/// reset window elapses and a subsequent init succeeds.
|
||
///
|
||
MemoryStoreBreakerOpen,
|
||
/// WhatsApp structured-ingest write hit a transient SQLite file lock
|
||
/// (`SQLITE_BUSY` / `SQLITE_LOCKED`) after exhausting the local retry
|
||
/// budget. This is an expected local-contention condition (typically on
|
||
/// Windows when another process briefly holds a file lock) and the
|
||
/// scanner retries on the next tick, so Sentry has no immediate
|
||
/// remediation path.
|
||
///
|
||
/// Anchored narrowly to the whatsapp ingest failure envelope plus the
|
||
/// SQLite lock text, so unrelated DB lock errors in other domains still
|
||
/// reach Sentry.
|
||
WhatsAppDataSqliteBusy,
|
||
/// Host disk is full — the filesystem returned `ENOSPC` to a write,
|
||
/// `mkdir`, or `open` syscall. The user cannot recover from this without
|
||
/// freeing space on their machine, and Sentry has no remediation path
|
||
/// because the failing path is bound to the user's local FS. Surfaces
|
||
/// from many call sites once the disk fills up (auth profile lock
|
||
/// creation, SQLite WAL grows, log rotation, `tokio::fs::write` for
|
||
/// state snapshots) — every one of them emits the same canonical errno
|
||
/// rendering.
|
||
DiskFull,
|
||
/// A user-supplied filesystem path failed an RPC-level validation
|
||
/// check — e.g. `openhuman.vault_create` was called with a
|
||
/// `root_path` that doesn't exist or points at a file rather than a
|
||
/// directory. The UI already shows the typed error to the user, and
|
||
/// Sentry has no remediation path (we can't `mkdir -p` a folder the
|
||
/// user hasn't actually picked yet). User-supplied paths can also
|
||
/// embed PII fragments (the home-directory segment leaks the OS
|
||
/// username), so demoting these out of the Sentry event stream is a
|
||
/// small privacy win on top of the noise reduction.
|
||
///
|
||
/// Drops Sentry TAURI-RUST-4QH (`root_path is not a directory:
|
||
/// /Users/<user>/Documents/<vault>`, observed on
|
||
/// `openhuman@0.56.0`) and preempts the symmetric
|
||
/// `hosted path is not a directory:` shape from
|
||
/// `openhuman::http_host::path_utils` once it starts surfacing.
|
||
/// See [`is_filesystem_user_path_invalid_message`] for the polarity
|
||
/// contract — the safety-guard variant in `skills::ops_install`
|
||
/// (`{path} is not a directory — refusing to remove`) is
|
||
/// deliberately not matched because that's an `rm -rf` invariant
|
||
/// violation, not user input.
|
||
FilesystemUserPathInvalid,
|
||
/// The provider/model completed a turn with a completely empty body
|
||
/// (`text_chars=0 thinking_chars=0 tool_calls=0`), so the agent harness
|
||
/// bailed with the user-facing `"The model returned an empty response.
|
||
/// Please try again."` string
|
||
/// (`agent::harness::session::turn`). This is a model/user-config
|
||
/// condition — a quirky or broken local fine-tune that returns nothing,
|
||
/// a provider that dropped the stream — not a code bug. The UI already
|
||
/// surfaces the typed error and the user can retry; Sentry has no
|
||
/// remediation path.
|
||
///
|
||
/// `agent::run_single` already suppresses the **agent-layer** Sentry
|
||
/// event for this condition via the typed
|
||
/// `AgentError::EmptyProviderResponse` + `AgentError::skips_sentry()`
|
||
/// (PR #2790, TAURI-RUST-4JX). But `channels::providers::web::
|
||
/// run_chat_task` **re-reports** the same failure under
|
||
/// `domain=web_channel operation=run_chat_task` after the typed error
|
||
/// has been flattened to a `String` at the native-bus boundary — so the
|
||
/// typed suppression can't reach it and it escapes as a fresh Sentry
|
||
/// event (TAURI-RUST-4Z1). This string classifier closes that second
|
||
/// emit site, mirroring how `MaxIterationsExceeded` is handled at both
|
||
/// layers. See [`is_empty_provider_response_message`].
|
||
///
|
||
/// Although the immediate trigger is the `web_channel.run_chat_task`
|
||
/// re-report, this classifier runs in the central `expected_error_kind`
|
||
/// dispatcher, so any caller of `report_error_or_expected`
|
||
/// (`channels/runtime/dispatch.rs`, `channels/runtime/supervision.rs`,
|
||
/// any future channel provider) whose error chain contains `"model
|
||
/// returned an empty response"` is also demoted — no per-channel typed
|
||
/// suppression needed.
|
||
EmptyProviderResponse,
|
||
/// Channel supervisor (`channels::runtime::supervision::spawn_supervised_listener`)
|
||
/// caught a transient error from a channel listener and restarted it. The
|
||
/// wrapper shape `"Channel <name> error: <inner>; restarting"` is the
|
||
/// signature; the underlying inner error can be anything — reqwest transport
|
||
/// errors, OS-localized WSAETIMEDOUT messages, TLS handshake failures, gateway
|
||
/// disconnect strings — all of which are self-resolving via the supervisor's
|
||
/// own backoff/retry loop. Sustained outages still surface via
|
||
/// `health.bus` / `FAIL_ESCALATE_THRESHOLD` (separate path, not affected by
|
||
/// this kind).
|
||
///
|
||
/// Drops Sentry TAURI-RUST-15 (~11.4 k events Discord gateway) and -BB
|
||
/// (~815 events Chinese-Windows variant) where the English-only
|
||
/// `is_network_unreachable_message` anchors miss the inner OS message.
|
||
ChannelSupervisorRestart,
|
||
ConfigLoadTimedOut,
|
||
/// A config-file READ failed because the OS refused access to a file that
|
||
/// exists (ACL-denied, held open by another process, OneDrive placeholder).
|
||
/// Unpreventable user-environment state — zero local lever to make the file
|
||
/// readable. Demoted only for the access-denied / locked io kinds; a
|
||
/// `NotFound` after the `exists()` check stays a paging defect. See
|
||
/// [`is_config_read_io_failure_message`]. Drops TAURI-RUST-DME
|
||
/// (`inference_downloads_progress` re-reads config every poll → 36k events /
|
||
/// 1 Windows user).
|
||
ConfigReadIoFailure,
|
||
/// The subconscious engine's SQLite schema init couldn't open its database
|
||
/// file at all — a host-filesystem condition, not a code bug. Two canonical
|
||
/// renderings, both bound to the user's local FS:
|
||
///
|
||
/// - `SQLITE_CANTOPEN` (14): `unable to open the database file` — the
|
||
/// `subconscious/` dir or DB file isn't writable/openable (permissions,
|
||
/// a vanished mount, a read-only volume).
|
||
/// - `SQLITE_IOERR_SHMMAP` (4618): `I/O error within the xShmMap method` —
|
||
/// the filesystem can't back WAL's mmap'd `-shm` segment (network mounts,
|
||
/// FUSE, some sandboxed/synced macOS paths).
|
||
///
|
||
/// The `xShmMap` case is now *prevented* at the source by
|
||
/// `subconscious::store::apply_journal_mode`, which degrades WAL to a
|
||
/// rollback journal that needs no shared memory (issue #3231). This kind
|
||
/// demotes the *residual* genuine `CANTOPEN` failures — where even opening
|
||
/// the file fails — which the user must resolve locally (fix permissions,
|
||
/// remount, free the volume) and which Sentry has no remediation path for.
|
||
///
|
||
/// Anchored to the subconscious schema/open envelope plus the SQLite
|
||
/// cant-open / shared-memory IO text, so transient `database is locked`
|
||
/// contention (handled by the store's busy-retry loop) and unrelated DB
|
||
/// failures in other domains still reach Sentry.
|
||
SubconsciousSchemaUnavailable,
|
||
/// The user invoked "Import Codex CLI login" (Settings → AI → Codex auth)
|
||
/// but the Codex CLI auth at `~/.codex/auth.json` is absent or unusable:
|
||
/// the file doesn't exist (the user never ran `codex login`), can't be
|
||
/// parsed, or carries no tokens / no access token. The import RPC already
|
||
/// returns an actionable error string ("Run `codex login` first, then try
|
||
/// Codex auth again.") that the frontend surfaces inline
|
||
/// (`AIPanel.tsx` → `setCodexAuthError`), and Sentry has no remediation
|
||
/// path — we can't run `codex login` for the user. The error strings also
|
||
/// embed the absolute `~/.codex/auth.json` path (home dir / username), so
|
||
/// demoting these out of the event stream is a privacy win on top of the
|
||
/// noise reduction (mirror `FilesystemUserPathInvalid` / `DiskFull`).
|
||
///
|
||
/// Drops Sentry TAURI-RUST-83A (~430 events / 40 users on
|
||
/// `openhuman@0.57.13`). Anchored to the `codex cli auth` /
|
||
/// `.codex/auth.json` envelope produced by
|
||
/// [`crate::openhuman::inference::openai_oauth::store::import_codex_cli_auth_from_path`]
|
||
/// — a genuine keyring/persist failure in `upsert_profile` carries neither
|
||
/// anchor, so a real defect in the import code still reaches Sentry.
|
||
CodexCliAuthUnavailable,
|
||
/// The managed backend (#870) stamped a stable `errorCode` on this
|
||
/// inference error response — so the backend **owns** it (it already paged
|
||
/// its own 5xx, or the code is expected user-state: rate limit, out of
|
||
/// credits, upstream unavailable, model/routing misconfig, payload too
|
||
/// large, context overflow, user-param rejection). The provider HTTP layer
|
||
/// (`api_error`) already demotes its own per-attempt event; this catches
|
||
/// the **re-report** when the same flattened error is raised again under
|
||
/// `domain=web_channel` / `agent` (the path
|
||
/// `channels::providers::web::run_chat_task` →
|
||
/// `report_error_or_expected`). The FE surfaces actionable copy via
|
||
/// `classify_inference_error`; Sentry must not double-report (F2/F4).
|
||
///
|
||
/// The single exception — a backend-flagged **malformed** `BAD_REQUEST` —
|
||
/// is NOT classified here (it is a client-built payload the backend
|
||
/// couldn't parse, and the FE *does* page for it, F8). See
|
||
/// [`crate::openhuman::inference::provider::backend_error_code_skips_sentry`].
|
||
BackendErrorCodeOwned,
|
||
/// `approval_decide` (`src/openhuman/approval/rpc.rs`) resolved a request_id
|
||
/// whose pending row was **already decided, lazily expired, or superseded**
|
||
/// — `store::decide` updated 0 rows because `decided_at` was already set,
|
||
/// and `store::get_decision` confirms a persisted decision exists. The
|
||
/// inline-approvals design spec
|
||
/// (`docs/superpowers/specs/2026-05-23-telegram-inline-approvals-design.md`)
|
||
/// classifies "no pending approval found" as a **benign** outcome: the
|
||
/// frontend `ApprovalRequestCard.decide` and the Telegram callback both call
|
||
/// `approval_decide` without server-confirmed dedupe, so double-taps, two
|
||
/// operators racing, and expiry-while-live all land here harmlessly.
|
||
///
|
||
/// Drops the benign half of Sentry TAURI-RUST-5EH (~1,995 events / 8 users
|
||
/// on `openhuman@0.57.53`). Anchored to the benign `"no pending approval
|
||
/// found"` wording emitted ONLY when `get_decision` confirms the row was
|
||
/// resolved; a genuine **never-registered** id raises the distinct
|
||
/// `"no pending approval ever registered"` string (no anchor) so a real
|
||
/// lost-registration defect still reaches Sentry.
|
||
ApprovalNoPendingRace,
|
||
/// A remote MCP server answered the connect handshake with HTTP 401 — it
|
||
/// needs OAuth sign-in, not a code fix. `McpHttpClient::read_response`
|
||
/// (`src/openhuman/mcp_client/client.rs`) raises the typed
|
||
/// [`crate::openhuman::mcp_client::McpUnauthorizedError`], and
|
||
/// `mcp_registry::connections::connect` already classifies it and stores a
|
||
/// `needs_auth` flag so the UI prompts the user to authenticate (the
|
||
/// `needs_auth` UX shipped in #3733 / #3719). But `mcp_clients_connect`
|
||
/// still returns `Err(e.to_string())`, which propagates to the RPC
|
||
/// dispatcher (`jsonrpc` → `report_error_or_expected`) where no arm matched
|
||
/// it — so the same user-state condition the UI already handles was being
|
||
/// captured as a full Sentry error (TAURI-RUST-CGP: ~1.2k events / 79 users
|
||
/// on `openhuman@0.57.53`). This arm closes that ship gap: the connect-time
|
||
/// 401 is preventable user-state with no Sentry-actionable signal, so demote
|
||
/// it to info. Anchored on the canonical `McpUnauthorizedError` Display body
|
||
/// (`"MCP unauthorized for "` + `"(HTTP 401"`) so an unrelated MCP transport
|
||
/// failure still reaches Sentry.
|
||
McpServerNeedsAuth,
|
||
}
|
||
|
||
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
|
||
let lower = message.to_ascii_lowercase();
|
||
// F2/F4: a managed-backend `errorCode` (#870) means the backend owns this
|
||
// error — it already paged its own 5xx, or the code is expected user-state.
|
||
// Trust it FIRST, before the substring matchers, so a managed 500
|
||
// `INTERNAL_ERROR` (which no substring matcher below would otherwise demote)
|
||
// stops double-reporting. The one exception — a backend-flagged malformed
|
||
// `BAD_REQUEST` — is excluded by `managed_error_skips_sentry` and falls
|
||
// through to the matchers / capture so the FE still pages (F8). The
|
||
// decision is gated on the managed-backend envelope so a BYO payload
|
||
// carrying an `errorCode`-shaped field is not wrongly suppressed
|
||
// (CodeRabbit).
|
||
if crate::openhuman::inference::provider::managed_error_skips_sentry(message) {
|
||
return Some(ExpectedErrorKind::BackendErrorCodeOwned);
|
||
}
|
||
// A managed-backend client-guard-leak code (`PAYLOAD_TOO_LARGE` /
|
||
// `CONTEXT_LENGTH_EXCEEDED`) must PAGE — the client enforces these limits
|
||
// before sending, so a backend rejection is our guard leaking. Force
|
||
// capture (return `None`) here, BEFORE the substring matchers below: a real
|
||
// managed `CONTEXT_LENGTH_EXCEEDED` body carries "context length
|
||
// exceeded"-style text that `is_context_window_exceeded_message` (further
|
||
// down) would otherwise re-demote into the suppressed
|
||
// `ContextWindowExceeded` bucket (CodeRabbit). Gated on the managed envelope
|
||
// so a BYO provider's own context-overflow — genuine user-state, not our
|
||
// guard — still flows to that matcher and stays demoted.
|
||
if crate::openhuman::inference::provider::is_managed_backend_envelope(message)
|
||
&& crate::openhuman::inference::provider::is_backend_client_guard_leak(message)
|
||
{
|
||
return None;
|
||
}
|
||
// Check the Codex-CLI import envelope first: it is highly specific
|
||
// (literal `codex cli auth` / `.codex/auth.json`) and carries no overlap
|
||
// with the generic matchers below, so ordering is for clarity, not
|
||
// precedence. See `ExpectedErrorKind::CodexCliAuthUnavailable`. The
|
||
// keyring/persist failure path (`upsert_profile`) stringifies to generic
|
||
// keychain / "auth profile" / SQLite text that contains neither anchor,
|
||
// so a real defect in the import still falls through to capture.
|
||
if lower.contains("codex cli auth") || lower.contains(".codex/auth.json") {
|
||
return Some(ExpectedErrorKind::CodexCliAuthUnavailable);
|
||
}
|
||
// TAURI-RUST-5EH — `approval_decide` resolved a request_id whose row was
|
||
// already decided / lazily expired / superseded (benign race per the
|
||
// inline-approvals design spec). `rpc::approval_decide` emits this exact
|
||
// `"no pending approval found"` wording ONLY after `store::get_decision`
|
||
// confirms a persisted decision exists; the genuine never-registered case
|
||
// raises `"no pending approval ever registered"` (matched by neither this
|
||
// arm nor any below), so a real lost-registration defect still reaches
|
||
// Sentry. See `ExpectedErrorKind::ApprovalNoPendingRace`.
|
||
if lower.contains("no pending approval found") {
|
||
return Some(ExpectedErrorKind::ApprovalNoPendingRace);
|
||
}
|
||
// TAURI-RUST-CGP — a remote MCP server answered the connect handshake with
|
||
// HTTP 401 (`McpUnauthorizedError`). `connections::connect` already stores a
|
||
// `needs_auth` flag so the UI prompts for OAuth sign-in (#3733 / #3719), but
|
||
// the `mcp_clients_connect` RPC still re-raises the stringified error here.
|
||
// It is preventable user-state (the server needs sign-in) with no
|
||
// Sentry-actionable signal — demote it. Highly specific anchor; no overlap
|
||
// with the generic matchers below. See `is_mcp_server_needs_auth_message`.
|
||
if is_mcp_server_needs_auth_message(&lower) {
|
||
return Some(ExpectedErrorKind::McpServerNeedsAuth);
|
||
}
|
||
if lower.contains("local ai is disabled") {
|
||
return Some(ExpectedErrorKind::LocalAiDisabled);
|
||
}
|
||
// `"no api key is configured"` covers the composio direct-mode factory
|
||
// bail (`client.rs`: "composio direct mode selected but no api key is
|
||
// configured"). `composio_list_connections` now short-circuits that
|
||
// state to an empty list, so the dominant 5 s-poll leak (TAURI-RUST-R4)
|
||
// no longer reaches here — but other direct-mode ops the user invokes
|
||
// explicitly (execute / authorize) can still surface it, and it is the
|
||
// same user-config state with no Sentry-actionable signal.
|
||
//
|
||
// `"no api key supplied"` is Cohere's exact 401 body wording when the
|
||
// hosted `/v2/embed` endpoint is called with an empty bearer (the BYO-key
|
||
// embedding path with no key configured). The `CohereEmbedding::embed`
|
||
// guard now fast-fails before issuing that request, but this arm demotes
|
||
// any residual 401 of that shape — older clients, or a present-but-rejected
|
||
// key — so the flood (TAURI-RUST-52S) stays out of Sentry either way.
|
||
if is_api_key_unset_message(&lower) {
|
||
return Some(ExpectedErrorKind::ApiKeyMissing);
|
||
}
|
||
// Check `ChannelSupervisorRestart` BEFORE `is_loopback_unavailable` and
|
||
// `is_network_unreachable_message`: the supervisor wrapper contains
|
||
// substrings (`error sending request for url`, OS-localized WSAETIMEDOUT
|
||
// bodies, occasionally `connection refused`) that would otherwise classify
|
||
// as `NetworkUnreachable` (which only demotes to `warn!` — still a Sentry
|
||
// event) or `LoopbackUnavailable`. The supervisor's own restart loop
|
||
// handles the condition; per-restart messages carry no actionable Sentry
|
||
// signal (TAURI-RUST-15 / -BB). Sustained outages still surface via
|
||
// `health.bus` / `FAIL_ESCALATE_THRESHOLD`, which is a separate path.
|
||
if is_channel_supervisor_restart_message(&lower) {
|
||
return Some(ExpectedErrorKind::ChannelSupervisorRestart);
|
||
}
|
||
// Check `is_loopback_unavailable` BEFORE `is_network_unreachable_message`:
|
||
// a loopback `Connection refused` body shape would otherwise demote to the
|
||
// broader `NetworkUnreachable` bucket and lose the boot-window vs.
|
||
// user-environment distinction. Mirrors the `ProviderUserState`-before-
|
||
// `BackendUserError` precedence pattern from #1795 (PR comment).
|
||
if is_loopback_unavailable(&lower) {
|
||
return Some(ExpectedErrorKind::LoopbackUnavailable);
|
||
}
|
||
// Check `is_ollama_user_config_rejection` BEFORE the generic network /
|
||
// backend-error matchers: the GX "daemon unreachable at localhost" shape
|
||
// contains a loopback host but no `Connection refused (os error …)`
|
||
// marker, and the XS / MA / KM 400/404 shapes are pure user-config —
|
||
// wrong model name, model not pulled, daemon opted-in but not running.
|
||
// Route them to the dedicated arm so they share the `ProviderUserState`
|
||
// bucket with the composio / OAuth user-state errors instead of falling
|
||
// through to capture. See `is_ollama_user_config_rejection`.
|
||
if is_ollama_user_config_rejection(&lower) {
|
||
return Some(ExpectedErrorKind::ProviderUserState);
|
||
}
|
||
if is_network_unreachable_message(&lower) {
|
||
return Some(ExpectedErrorKind::NetworkUnreachable);
|
||
}
|
||
if is_transient_upstream_http_message(&lower) {
|
||
return Some(ExpectedErrorKind::TransientUpstreamHttp);
|
||
}
|
||
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);
|
||
}
|
||
// TAURI-RUST-8FQ — the OpenAI ChatGPT/Codex OAuth access token expired with
|
||
// no usable refresh token. The provider HTTP layer
|
||
// (`provider::ops::api_error` / `chat_via_responses`) already demotes its
|
||
// own per-attempt event, but the same `anyhow::bail!` string re-raises here
|
||
// at the RPC boundary (`jsonrpc` → `report_error_or_expected`); route it to
|
||
// the shared `ProviderUserState` bucket so the re-report is demoted too
|
||
// instead of leaking the event the emit-site already suppressed. User-state
|
||
// — recovery is reconnecting OpenAI; Sentry has no remediation path. The
|
||
// markers are distinct from the backend "invalid token" session-expiry
|
||
// wording matched below, so this does not shadow that arm.
|
||
if crate::openhuman::inference::provider::is_openai_oauth_session_expired_message(message) {
|
||
return Some(ExpectedErrorKind::ProviderUserState);
|
||
}
|
||
// TAURI-RUST-5MV — ollama.com hosted-inference 500 (`Internal Server Error
|
||
// (ref: <uuid>)`) for `*:cloud` models. The provider HTTP layer
|
||
// (`native_chat` / `streaming_chat` / `api_error`) already demotes its own
|
||
// per-attempt event and re-raises the actionable
|
||
// "Ollama cloud is temporarily unavailable …" string; this catches the
|
||
// re-report at the agent / RPC boundary (`provider_chat` →
|
||
// `report_error_or_expected`, the `domain=agent` half of the flood). Routed
|
||
// to `TransientUpstreamHttp` — it is an external upstream 5xx the
|
||
// reliable-provider layer retries + falls back over, with no client lever.
|
||
// Delegates to the single-source provider matcher so the phrasing can't
|
||
// drift. Distinct anchor from the matchers above, so it shadows nothing.
|
||
if crate::openhuman::inference::provider::is_ollama_cloud_internal_500_message(message) {
|
||
return Some(ExpectedErrorKind::TransientUpstreamHttp);
|
||
}
|
||
if is_backend_user_error_message(&lower) {
|
||
return Some(ExpectedErrorKind::BackendUserError);
|
||
}
|
||
// Check `is_session_expired_message` BEFORE `is_embedding_backend_auth_failure`:
|
||
// the OpenHuman-backend embedding 401 "Invalid token" envelope
|
||
// (`Embedding API error (401 …): {"error":"Invalid token"}`) is a
|
||
// recoverable session expiry (TAURI-RUST-4K5, #2786), not a generic
|
||
// backend error. The broader `is_embedding_backend_auth_failure` matcher
|
||
// below would otherwise demote that exact wire shape to `BackendUserError`
|
||
// first and swallow the re-auth signal. `is_session_expired_message` is
|
||
// narrowly anchored (parenthesised `(401` + the `"error":"Invalid token"`
|
||
// envelope), so the bare-status `Embedding API error 401 …` shape and
|
||
// BYO-key 401s still fall through to the matchers below.
|
||
if is_session_expired_message(message) {
|
||
return Some(ExpectedErrorKind::SessionExpired);
|
||
}
|
||
if is_embedding_backend_auth_failure(&lower) {
|
||
return Some(ExpectedErrorKind::SessionExpired);
|
||
}
|
||
// TAURI-RUST-5JR — a custom embeddings endpoint with no embeddings route
|
||
// (the user pointed the Custom (OpenAI-compatible) provider at a chat-only
|
||
// base URL, e.g. DeepSeek, which 404s every `/embeddings` POST).
|
||
// Deterministic user-config state, re-emitted on every memory re-embed;
|
||
// the embeddings settings UI surfaces an actionable "pick an
|
||
// embeddings-capable provider" message. Demote to info. Scoped to 404/405
|
||
// only so a real 500 from a valid embeddings endpoint stays in Sentry.
|
||
if is_embedding_endpoint_absent(&lower) {
|
||
return Some(ExpectedErrorKind::ProviderConfigRejection);
|
||
}
|
||
// TAURI-RUST-9SK — the user entered a non-embedding (chat) model id as the
|
||
// embeddings model (e.g. an OpenRouter `…:free` chat model), so the
|
||
// embeddings endpoint 400s `Model <id> does not exist` on every memory
|
||
// re-embed (2205 events / 1 user). OpenRouter's bare `"does not exist"` +
|
||
// integer `"code":400` body matches none of the chat-side phrases in
|
||
// `is_provider_config_rejection_message` (which key on the OpenAI-native
|
||
// `"does not exist or you do not have access"` / `model_not_found`), so
|
||
// without this it reaches Sentry. Deterministic user-config state; the
|
||
// settings UI surfaces an actionable "pick an embeddings-capable model"
|
||
// remediation. Scoped to the 400 model-rejection body so a real 400
|
||
// (oversized input, server fault) stays visible — same polarity contract as
|
||
// `is_embedding_endpoint_absent`.
|
||
if is_embedding_model_rejected(&lower) {
|
||
return Some(ExpectedErrorKind::ProviderConfigRejection);
|
||
}
|
||
// Provider config-rejection (unknown model / abstract tier leaked to a
|
||
// custom provider / model-specific temperature). Body-shape based and
|
||
// intrinsically scoped to third-party providers — the OpenHuman
|
||
// backend never emits these phrases. See the predicate's polarity
|
||
// contract. Drops OPENHUMAN-TAURI-WJ / -QW / -HB / -NH re-reports
|
||
// (#2079 / #2076 / #2202).
|
||
if crate::openhuman::inference::provider::is_provider_config_rejection_message(message) {
|
||
return Some(ExpectedErrorKind::ProviderConfigRejection);
|
||
}
|
||
if is_local_ai_capability_unavailable_message(&lower) {
|
||
return Some(ExpectedErrorKind::LocalAiCapabilityUnavailable);
|
||
}
|
||
if crate::openhuman::inference::provider::is_budget_exhausted_message(message) {
|
||
return Some(ExpectedErrorKind::BudgetExhausted);
|
||
}
|
||
if is_prompt_injection_blocked_message(&lower) {
|
||
return Some(ExpectedErrorKind::PromptInjectionBlocked);
|
||
}
|
||
// Context-window-exceeded re-report from a higher layer (agent /
|
||
// web_channel). The provider api_error cascade suppresses its own
|
||
// emit; this catches the re-raise. Delegates to the single-source
|
||
// provider matcher so the phrasing can't drift. Runs last so a more
|
||
// specific matcher always wins.
|
||
if crate::openhuman::inference::provider::is_context_window_exceeded_message(message) {
|
||
return Some(ExpectedErrorKind::ContextWindowExceeded);
|
||
}
|
||
if is_memory_store_breaker_open(&lower) {
|
||
return Some(ExpectedErrorKind::MemoryStoreBreakerOpen);
|
||
}
|
||
if is_whatsapp_data_sqlite_busy_message(&lower) {
|
||
return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy);
|
||
}
|
||
if is_subconscious_schema_unavailable_message(&lower) {
|
||
return Some(ExpectedErrorKind::SubconsciousSchemaUnavailable);
|
||
}
|
||
if is_disk_full_message(&lower) {
|
||
return Some(ExpectedErrorKind::DiskFull);
|
||
}
|
||
if is_config_load_timed_out_message(&lower) {
|
||
return Some(ExpectedErrorKind::ConfigLoadTimedOut);
|
||
}
|
||
// OS-level config-read denial on a file that exists (user-environment, zero
|
||
// local lever). Keyed on the config-read anchor + an access-denied/locked io
|
||
// signal; NotFound / unseen kinds fall through and keep paging. Requires the
|
||
// loader to surface the full io chain (#3962).
|
||
if is_config_read_io_failure_message(&lower) {
|
||
return Some(ExpectedErrorKind::ConfigReadIoFailure);
|
||
}
|
||
// Empty-provider-response re-report from the web-channel layer. Runs
|
||
// last so an earlier, more specific matcher always wins. See the
|
||
// variant doc-comment and [`is_empty_provider_response_message`] for
|
||
// the two-emit-site rationale (agent layer is handled by the typed
|
||
// `AgentError::skips_sentry()` in PR #2790; this covers the
|
||
// web_channel re-report where the type was flattened to a String).
|
||
if is_empty_provider_response_message(&lower) {
|
||
return Some(ExpectedErrorKind::EmptyProviderResponse);
|
||
}
|
||
// RPC-level filesystem path validation — explicit wire-shape anchors
|
||
// (root_path / hosted path) prevent accidental demotion of unrelated
|
||
// errors. See the variant doc-comment and
|
||
// [`is_filesystem_user_path_invalid_message`] polarity contract.
|
||
if is_filesystem_user_path_invalid_message(&lower) {
|
||
return Some(ExpectedErrorKind::FilesystemUserPathInvalid);
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Detect filesystem-out-of-space errors that bubble up from any syscall
|
||
/// (`open`, `write`, `mkdir`, `rename`). Three platform-stable renderings:
|
||
///
|
||
/// - **POSIX `ENOSPC`** (Linux / macOS / BSD): `std::io::Error` renders as
|
||
/// `"No space left on device (os error 28)"`. The errno-name substring is
|
||
/// what we anchor on — case-folded to `"no space left on device"`.
|
||
/// - **Windows `ERROR_DISK_FULL` (112)**: `std::io::Error` renders as
|
||
/// `"There is not enough space on the disk. (os error 112)"`. Anchor on
|
||
/// `"not enough space on the disk"`.
|
||
/// - **Windows `ERROR_HANDLE_DISK_FULL` (39)**: same wire text but errno 39.
|
||
/// The text anchor already covers it.
|
||
///
|
||
/// A fourth shape comes from call sites that render the `io::ErrorKind` debug
|
||
/// name instead of the `io::Error` Display — notably the auth-profile
|
||
/// lock-create annotation, which emits
|
||
/// `"... (kind=Some(StorageFull), os_code=Some(28))"` (Sentry TAURI-RUST-4SZ).
|
||
/// That string carries no "no space left on device" text, so anchor
|
||
/// additionally on the cross-platform `StorageFull` ErrorKind token (std maps
|
||
/// ENOSPC / `ERROR_DISK_FULL` / `ERROR_HANDLE_DISK_FULL` all to
|
||
/// `ErrorKind::StorageFull`).
|
||
///
|
||
/// A fifth shape comes from SQLite itself. When the engine detects the
|
||
/// disk-full condition during its own page bookkeeping (journal/WAL extension)
|
||
/// before the next syscall surfaces an errno, rusqlite renders the `SQLITE_FULL`
|
||
/// result code as `"database or disk is full"` (Sentry TAURI-RUST-B6N, hit at
|
||
/// `memory_store::unified::documents::tx.commit()` during
|
||
/// `openhuman.memory_doc_ingest`). `SQLITE_FULL` has only two causes:
|
||
/// genuine ENOSPC/ERROR_DISK_FULL (always the case in practice — the same
|
||
/// burst always produces an os-error-28/112 sibling event) or a
|
||
/// `max_page_count` PRAGMA cap (we set none).
|
||
///
|
||
/// rusqlite renders `SQLITE_FULL` in one of two shapes. The **bare** shape is
|
||
/// the five words `"database or disk is full"` — Our local memory-store write
|
||
/// call-sites wrap it with `format!("<verb>: {e}")` (e.g. `"commit tx: ..."` /
|
||
/// `"clear_namespace commit tx: ..."` in `memory_store::unified::documents`),
|
||
/// so the phrase lands as the **suffix** of the local emit. The **extended**
|
||
/// shape carries the full error-code envelope, `"database or disk is full:
|
||
/// Error code 13: Insertion failed because database is full"` (Sentry
|
||
/// TAURI-RUST-4R8, `memory_queue::store::claim_next` on `mem_tree_jobs`); here
|
||
/// the canonical phrase sits mid-string, so the suffix anchor can't catch it.
|
||
/// We detect this shape by requiring **both** local fragments together — the
|
||
/// `"database or disk is full"` phrase AND the libsqlite3-sys `code_to_str`
|
||
/// token `"insertion failed because database is full"` — which only rusqlite's
|
||
/// own `SQLITE_FULL` Display emits as a pair. Requiring both (rather than the
|
||
/// `code_to_str` token alone) keeps the silencer from matching a remote
|
||
/// provider body that merely quotes the token half — e.g. an OpenAI-compatible
|
||
/// `OpenAiEmbedding::embed` failure framed as `"Embedding API error (… Error
|
||
/// code 13: Insertion failed because database is full)"` whose *server-side*
|
||
/// SQLite is full is operator-actionable and must still surface to Sentry
|
||
/// (codex CR on #3911). Both arms anchor on local-emit fragments rather than a
|
||
/// bare `contains("database or disk is full")` so the silencer does not match a
|
||
/// non-2xx backend response body whose payload happens to mention the phrase
|
||
/// (e.g. an `api.tinyhumans.ai` 5xx whose server-side SQLite is full). Non-2xx
|
||
/// backend bodies are framed by
|
||
/// `integrations::client::post` / `composio::client` as `"Backend returned
|
||
/// <status> <reason> for <METHOD> <url>: <detail>"` — an operator-actionable
|
||
/// server/storage failure that must still surface to Sentry. As
|
||
/// defense-in-depth for the edge case where the backend body itself ends with
|
||
/// the phrase, reject any message that also carries the `"backend returned "`
|
||
/// envelope prefix (codex CR on #3672, mirrors the precedent set by
|
||
/// [`is_backend_user_error_message`]).
|
||
///
|
||
/// This is defense-in-depth for the genuinely
|
||
/// unpreventable **write** paths (a write can't succeed on a full disk); the
|
||
/// read path no longer emits this error at all (it degrades to a lock-free
|
||
/// read — see `AuthProfilesStore::load`).
|
||
fn is_disk_full_message(lower: &str) -> bool {
|
||
if lower.contains("no space left on device")
|
||
|| lower.contains("not enough space on the disk")
|
||
|| lower.contains("storagefull")
|
||
{
|
||
return true;
|
||
}
|
||
// Two SQLITE_FULL renderings — see the fifth-shape section above. The
|
||
// **bare** shape lands the phrase as a suffix (after trimming trailing
|
||
// whitespace / punctuation that closures + JSON wrappers append). The
|
||
// **extended** shape (TAURI-RUST-4R8) puts the phrase mid-string followed
|
||
// by `: Error code 13: Insertion failed because database is full`; require
|
||
// BOTH local fragments so we match only rusqlite's own `SQLITE_FULL` Display
|
||
// and never a remote provider body that merely quotes the `code_to_str`
|
||
// half (codex CR on #3911). The negative `"backend returned "` guard
|
||
// rejects the remote 5xx envelope as a further line of defense.
|
||
let trimmed = lower.trim_end_matches(|c: char| {
|
||
c.is_ascii_whitespace() || matches!(c, '.' | ',' | ';' | ':' | '"' | '\'')
|
||
});
|
||
let bare_suffix = trimmed.ends_with("database or disk is full");
|
||
let extended_local = lower.contains("database or disk is full")
|
||
&& lower.contains("insertion failed because database is full");
|
||
(bare_suffix || extended_local) && !lower.contains("backend returned ")
|
||
}
|
||
|
||
/// Detect the literal `"Config loading timed out"` string produced by
|
||
/// [`crate::openhuman::config::ops::load_config_with_timeout`] /
|
||
/// [`crate::openhuman::config::ops::reload_config_snapshot_with_timeout`]
|
||
/// when `tokio::time::timeout` elapses around `Config::load_or_init` /
|
||
/// `Config::load_from_config_path`.
|
||
fn is_config_load_timed_out_message(lower: &str) -> bool {
|
||
lower.contains("config loading timed out")
|
||
}
|
||
|
||
/// Detect a config-file READ that failed because the operating system refused
|
||
/// access to a file that **exists** — i.e. an unpreventable user-environment
|
||
/// condition with zero local lever, not an OpenHuman defect.
|
||
///
|
||
/// `Config::load_or_init` (`impl_load.rs`) takes its read branch only after
|
||
/// `config_path.exists()` returns true, then `read_to_string` is retried 5× and
|
||
/// still fails. On a healthy install that never happens; in the wild a user's
|
||
/// `config.toml` can be ACL-denied, held open by another process (antivirus /
|
||
/// backup agent), or a OneDrive "files on demand" placeholder that won't
|
||
/// hydrate. We cannot unlock or re-ACL a foreign-held file, so the per-poll
|
||
/// re-report (TAURI-RUST-DME: `inference_downloads_progress` re-loads config on
|
||
/// every poll → 36k events / 1 user) carries no Sentry-actionable signal.
|
||
///
|
||
/// Polarity contract — demote **only** when BOTH hold:
|
||
/// 1. an OpenHuman config-read context anchor is present — either
|
||
/// `"failed to read config file"` (`load_or_init` retry path,
|
||
/// `impl_load.rs`, the DME surface) or `"reading config.toml from"`
|
||
/// (`load_from_config_path` snapshot-reload path) — AND
|
||
/// 2. an OS-level *access-denied / locked* signal is present.
|
||
///
|
||
/// `NotFound` (`os error 2` / "cannot find the file"), "is a directory", and any
|
||
/// io kind not enumerated here are deliberately EXCLUDED: a file that vanished
|
||
/// after the `exists()` check is a TOCTOU race / app defect and MUST keep
|
||
/// paging. This matcher is only meaningful once the loader surfaces the full
|
||
/// chain (`{:#}`, #3962) — the io fragment lives in the source, not the top
|
||
/// `with_context` line.
|
||
fn is_config_read_io_failure_message(lower: &str) -> bool {
|
||
let has_config_read_anchor =
|
||
lower.contains("failed to read config file") || lower.contains("reading config.toml from");
|
||
if !has_config_read_anchor {
|
||
return false;
|
||
}
|
||
// A directory (or otherwise non-regular file) at the config path is a
|
||
// bad-install / corruption signal that MUST keep paging. On Windows reading
|
||
// a directory surfaces the same `Access is denied. (os error 5)` shape as a
|
||
// genuine ACL denial, so the io-signal check below cannot tell them apart;
|
||
// the read site (`impl_load.rs`) now fails a directory fast with this
|
||
// distinct wording, and we belt-and-braces exclude it here too. (Codex P2.)
|
||
if lower.contains("is a directory") || lower.contains("not a file") {
|
||
return false;
|
||
}
|
||
lower.contains("access is denied")
|
||
|| lower.contains("permission denied")
|
||
|| lower.contains("being used by another process")
|
||
|| lower.contains("cannot access the file")
|
||
|| lower.contains("(os error 5)")
|
||
|| lower.contains("(os error 32)")
|
||
}
|
||
|
||
/// Match whatsapp structured-ingest failures caused by transient SQLite lock
|
||
/// contention. Keep this matcher scoped to the whatsapp ingest envelope so we
|
||
/// don't demote unrelated database failures in other domains.
|
||
fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool {
|
||
if !lower.contains("[whatsapp_data] ingest failed:") {
|
||
return false;
|
||
}
|
||
if !lower.contains("upsert wa_message") {
|
||
return false;
|
||
}
|
||
lower.contains("database is locked")
|
||
|| lower.contains("database table is locked")
|
||
|| lower.contains("database file is locked")
|
||
|| lower.contains("error code 5")
|
||
}
|
||
|
||
/// Match subconscious-engine SQLite schema-init failures caused by the host
|
||
/// filesystem being unable to open the DB file (`SQLITE_CANTOPEN` /
|
||
/// `SQLITE_IOERR_SHMMAP`). Anchored to the subconscious open/DDL envelope so it
|
||
/// can't demote unrelated DB failures, and deliberately scoped to cant-open /
|
||
/// shared-memory IO text — *not* `database is locked`, which the store retries
|
||
/// and which (if persistent) is a real contention signal worth surfacing.
|
||
///
|
||
/// See [`ExpectedErrorKind::SubconsciousSchemaUnavailable`].
|
||
fn is_subconscious_schema_unavailable_message(lower: &str) -> bool {
|
||
let in_subconscious_envelope = lower.contains("subconscious schema ddl")
|
||
|| lower.contains("failed to open subconscious db");
|
||
if !in_subconscious_envelope {
|
||
return false;
|
||
}
|
||
lower.contains("unable to open the database file")
|
||
|| lower.contains("xshmmap")
|
||
|| lower.contains("error code 14")
|
||
|| lower.contains("error code 4618")
|
||
}
|
||
|
||
fn is_embedding_backend_auth_failure(lower: &str) -> bool {
|
||
lower.contains("embedding api error")
|
||
&& lower.contains("401")
|
||
&& lower.contains("invalid token")
|
||
}
|
||
|
||
/// Detect a custom embeddings endpoint that exposes **no embeddings API** —
|
||
/// the `OpenAiEmbedding` client POSTed `/embeddings` and the host answered
|
||
/// `404 Not Found` (route absent) or `405 Method Not Allowed`. Canonical wire
|
||
/// shape from `src/openhuman/embeddings/openai.rs`:
|
||
///
|
||
/// ```text
|
||
/// Embedding API error (404 Not Found): <body>
|
||
/// Embedding API error (405 Method Not Allowed): <body>
|
||
/// ```
|
||
///
|
||
/// Deterministic user-config state: the user pointed the Custom
|
||
/// (OpenAI-compatible) embeddings provider at a base URL whose host has no
|
||
/// embeddings endpoint (e.g. a chat-only provider like DeepSeek). Every memory
|
||
/// re-embed re-emits it (TAURI-RUST-5JR, ~2685 events / 9 users) and the
|
||
/// settings UI surfaces an actionable remediation — Sentry has no fix to make.
|
||
///
|
||
/// Polarity (important): scoped to **404/405 only**. A `500` from a valid
|
||
/// embeddings endpoint is a real server fault and must keep reaching Sentry; a
|
||
/// `400` (e.g. oversized input) is prevented at source by the chunk cap
|
||
/// (#3598) and likewise stays visible. Reused by
|
||
/// `embeddings::rpc::update_settings` as the save-time hard-block signal so the
|
||
/// two never drift.
|
||
pub(crate) fn is_embedding_endpoint_absent(lower: &str) -> bool {
|
||
lower.contains("embedding api error") && (lower.contains("(404") || lower.contains("(405"))
|
||
}
|
||
|
||
/// Detect a custom/cloud embeddings endpoint that IS an embeddings API but
|
||
/// **rejected the configured model id** — the user pasted a non-embedding
|
||
/// (chat/reasoning) model into the embeddings model field. Canonical wire shape
|
||
/// from `src/openhuman/embeddings/openai.rs` (TAURI-RUST-9SK, ~2205 events):
|
||
///
|
||
/// ```text
|
||
/// Embedding API error (400 Bad Request): {"error":{"message":"Model nvidia/nemotron-3-super-120b-a12b does not exist","code":400}}
|
||
/// ```
|
||
///
|
||
/// Deterministic user-config state, re-emitted on every memory re-embed; the
|
||
/// embeddings settings UI surfaces an actionable "pick an embeddings-capable
|
||
/// model" remediation (appended to the message at the emit site). The
|
||
/// OpenRouter body — bare `"does not exist"` with an integer `"code":400` —
|
||
/// matches none of the chat-side phrases in
|
||
/// `inference::provider::is_provider_config_rejection_message` (those key on the
|
||
/// OpenAI-native `"does not exist or you do not have access"` /
|
||
/// `model_not_found`), so this dedicated matcher is what demotes it.
|
||
///
|
||
/// Polarity (important): scoped to **400** + a model-rejection body. A bare
|
||
/// 400 (oversized input — prevented at source by the chunk cap #3598) or a 500
|
||
/// from a valid embeddings endpoint is a real fault and must keep reaching
|
||
/// Sentry, so this never fires on them.
|
||
fn is_embedding_model_rejected(lower: &str) -> bool {
|
||
lower.contains("embedding api error")
|
||
&& lower.contains("(400")
|
||
&& (lower.contains("does not exist")
|
||
|| lower.contains("does not support embeddings")
|
||
// Gemini's OpenAI-compat shim (generativelanguage.googleapis.com)
|
||
// maps `/v1/embeddings` → `BatchEmbedContents` and rejects a bare
|
||
// model id with `BatchEmbedContentsRequest.model: unexpected model
|
||
// name format` / `INVALID_ARGUMENT` on every re-embed (TAURI-RUST-4SA,
|
||
// 4,494 events / 1 user). The Custom-endpoint path now normalizes the
|
||
// id to `models/<name>` at the source; this demotes any that slip
|
||
// through (already-stored bad state, older releases, other compat
|
||
// hosts) so the per-embed flood stays out of Sentry. Distinct cause
|
||
// from the #4070/9SK `"does not exist"` family.
|
||
|| lower.contains("unexpected model name format")
|
||
|| (lower.contains("invalid_argument")
|
||
&& lower.contains("batchembedcontentsrequest.model")))
|
||
}
|
||
|
||
/// Detect the memory-store chunk DB's circuit-breaker-open message that
|
||
/// `memory_store::chunks::store::get_or_init_connection` emits via
|
||
/// `anyhow::bail!` when the per-path breaker rejects new init attempts.
|
||
///
|
||
/// Canonical wire shape (after the `chunk aggregates: …` context wrap added by
|
||
/// `memory_tree::tree::rpc::pipeline_status_rpc`):
|
||
///
|
||
/// ```text
|
||
/// chunk aggregates: [memory_tree] circuit breaker open for <path>: too many consecutive init failures
|
||
/// ```
|
||
///
|
||
/// The `[memory_tree]` tag is the anchor — it's specific to the chunk-store
|
||
/// emit site and won't collide with unrelated "circuit breaker" mentions in
|
||
/// other domains (provider reliability layer logs, doc strings, …). The
|
||
/// `circuit breaker open` substring is required so a log line that merely
|
||
/// mentions the `[memory_tree]` prefix doesn't get swallowed.
|
||
fn is_memory_store_breaker_open(lower: &str) -> bool {
|
||
lower.contains("[memory_tree]") && lower.contains("circuit breaker open")
|
||
}
|
||
|
||
/// Detect **app-session-expired** boundary errors that bubble up from any
|
||
/// backend-touching call site (agent, web channel, cron, integrations).
|
||
///
|
||
/// This is also the JSON-RPC dispatch-site classifier. Keep it stricter than
|
||
/// a bare "401 + unauthorized" pair: OpenAI / Anthropic BYO-key failures,
|
||
/// Composio scope failures, and channel-provider 401s are actionable scoped
|
||
/// errors, not proof that the user's OpenHuman app session expired.
|
||
///
|
||
/// The canonical OpenHuman session-expired wire shapes:
|
||
///
|
||
/// - `"OpenHuman API error (401 Unauthorized): {…\"Session expired. Please
|
||
/// log in again.\"…}"` — emitted by `providers::ops::api_error` from the
|
||
/// OpenHuman backend and re-raised through `agent::run_single` /
|
||
/// `channels::providers::web::run_chat_task` (OPENHUMAN-TAURI-26). The
|
||
/// `"session expired"` substring anchors the match to the OpenHuman
|
||
/// backend's session-renewal body, not the bare numeric status.
|
||
/// - `"OpenHuman API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
|
||
/// — same emit site, same wire shape as the `Session expired` body, but the
|
||
/// OpenHuman backend swaps in `"Invalid token"` for the JWT-validity
|
||
/// rejection branch (vs. the explicit session-renewal branch).
|
||
/// OPENHUMAN-TAURI-4P0. The conjunctive anchor — `"OpenHuman API error
|
||
/// (401"` **and** the envelope-shaped `"\"error\":\"Invalid token\""` —
|
||
/// keeps the #2286 contract intact: bare `"Invalid token"`, OpenAI /
|
||
/// Anthropic BYO-key 401s, Discord upstream-bot-token rejections, and
|
||
/// provider scope errors still route to Sentry as actionable.
|
||
/// - `"Embedding API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
|
||
/// — TAURI-RUST-4K5 (~118 events, escalating on 0.56.0). Same OpenHuman
|
||
/// backend session-expired envelope as 4P0, but the embedding client at
|
||
/// `src/openhuman/embeddings/openai.rs:139` wraps it with the
|
||
/// `"Embedding API error"` prefix instead of `"OpenHuman API error"`.
|
||
/// Uses the same conjunctive-anchor pattern so BYO-key embedding 401s
|
||
/// from third-party providers (OpenAI / Voyage / Cohere) still escalate
|
||
/// — guarded by `does_not_classify_embedding_byo_key_401_as_session_expired`.
|
||
/// - `"OpenHuman streaming API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
|
||
/// — TAURI-RUST-1EE (~110 events, ongoing on 0.56.0). Same envelope as
|
||
/// 4P0, wrapped by the streaming-chat path at
|
||
/// `inference/provider/compatible.rs:949` with the
|
||
/// `"OpenHuman streaming API error"` prefix. The `streaming` token means
|
||
/// the 4P0 anchor doesn't match, so it needs its own prefix arm; BYO-key
|
||
/// streaming 401s still escalate — guarded by
|
||
/// `does_not_classify_streaming_byo_key_401_as_session_expired`.
|
||
/// - `"SESSION_EXPIRED: backend session not active — sign in to resume LLM work"`
|
||
/// — the `scheduler_gate::is_signed_out` sentinel from
|
||
/// `providers::openhuman_backend::resolve_bearer`.
|
||
/// - `"no backend session token; run auth_store_session first"` and
|
||
/// `"session JWT required"` — local pre-flight guards that fire when the
|
||
/// stored profile is empty (`#1465`-ish onboarding spam) or has been
|
||
/// cleared by a previous 401 cycle. Both shapes are OpenHuman-specific.
|
||
///
|
||
/// At the JSON-RPC dispatch boundary the same strict match controls
|
||
/// `DomainEvent::SessionExpired` publication, so downstream/provider 401s stay
|
||
/// recoverable and do not clear the stored app session.
|
||
pub fn is_session_expired_message(msg: &str) -> bool {
|
||
let lower = msg.to_ascii_lowercase();
|
||
lower.contains("session expired")
|
||
|| lower.contains("no backend session token")
|
||
|| lower.contains("session jwt required")
|
||
|| msg.contains("SESSION_EXPIRED")
|
||
|| (msg.contains("OpenHuman API error (401") && msg.contains("\"error\":\"Invalid token\""))
|
||
|| (msg.contains("Embedding API error (401") && msg.contains("\"error\":\"Invalid token\""))
|
||
// OPENHUMAN-TAURI-4P0 — OpenHuman backend's "Invalid token" 401
|
||
// envelope. Both anchors must be present: the OpenHuman-scoped
|
||
// `"OpenHuman API error (401"` prefix (so a third-party provider's
|
||
// `"OpenAI API error (401 Unauthorized): invalid_api_key"` cannot
|
||
// match), AND the envelope-shaped `"\"error\":\"Invalid token\""`
|
||
// (so bare prose mentions of "invalid token" — Discord OAuth
|
||
// failures, generic upstream errors covered by #2286 — stay
|
||
// actionable in Sentry).
|
||
|| (msg.contains("OpenHuman API error (401")
|
||
&& msg.contains("\"error\":\"Invalid token\""))
|
||
// TAURI-RUST-4K5 — same OpenHuman backend "Invalid token" envelope
|
||
// wrapped by `src/openhuman/embeddings/openai.rs:139` with the
|
||
// `"Embedding API error"` prefix instead of `"OpenHuman API error"`.
|
||
// Same conjunctive-anchor pattern as 4P0: the embedding-scoped
|
||
// prefix gates the match so a third-party BYO-key embedding 401
|
||
// (e.g. OpenAI/Voyage/Cohere rejecting the user's own API key)
|
||
// stays actionable — guarded by
|
||
// `does_not_classify_embedding_byo_key_401_as_session_expired`.
|
||
|| (msg.contains("Embedding API error (401")
|
||
&& msg.contains("\"error\":\"Invalid token\""))
|
||
// TAURI-RUST-1EE — same OpenHuman backend "Invalid token" envelope
|
||
// wrapped by the streaming-chat path at
|
||
// `inference/provider/compatible.rs:949` with the
|
||
// `"OpenHuman streaming API error"` prefix. The `streaming` token
|
||
// between `OpenHuman` and `API error` means the 4P0 anchor
|
||
// (`"OpenHuman API error (401"`) does not match it, so the
|
||
// streaming path needs its own prefix arm. Same conjunctive-anchor
|
||
// pattern keeps third-party BYO-key streaming 401s
|
||
// (`"OpenAI streaming API error (401): invalid_api_key"`)
|
||
// escalating — guarded by
|
||
// `does_not_classify_streaming_byo_key_401_as_session_expired`.
|
||
|| (msg.contains("OpenHuman streaming API error (401")
|
||
&& msg.contains("\"error\":\"Invalid token\""))
|
||
}
|
||
|
||
/// Detect a remote MCP server's connect-time 401 — the user must sign in to
|
||
/// that server (OAuth), not a code defect. Anchored on the canonical
|
||
/// [`crate::openhuman::mcp_client::McpUnauthorizedError`] `Display`
|
||
/// body, which renders as `"MCP unauthorized for \`<endpoint>\` (HTTP 401…)"`.
|
||
///
|
||
/// Conjunctive match — both anchors must hit (input already lower-cased):
|
||
///
|
||
/// 1. `"mcp unauthorized for "` — the typed-error prefix. Scopes the match to
|
||
/// the MCP transport's own 401 so an unrelated "unauthorized" / "401" from
|
||
/// another domain cannot borrow this demotion.
|
||
/// 2. `"(http 401"` — the parenthesised status the `Display` impl always emits
|
||
/// (with or without the trailing `resource metadata:` discovery hint).
|
||
///
|
||
/// `connections::connect` already classifies this and stores a `needs_auth`
|
||
/// flag so the UI prompts for sign-in; this predicate keeps the parallel
|
||
/// `mcp_clients_connect` RPC re-report out of Sentry. See
|
||
/// [`ExpectedErrorKind::McpServerNeedsAuth`].
|
||
fn is_mcp_server_needs_auth_message(lower: &str) -> bool {
|
||
lower.contains("mcp unauthorized for ") && lower.contains("(http 401")
|
||
}
|
||
|
||
/// Detect the "a configured provider has no API key" user-config state.
|
||
///
|
||
/// Single source of truth for the `ApiKeyMissing` wording so the
|
||
/// `expected_error_kind` demotion arm and the cron scheduler's halt-on-first
|
||
/// classifier (`is_api_key_unset_failure`, TAURI-RUST-HCK) can never drift
|
||
/// apart. The phrasing is emitted deterministically, before any HTTP, by the
|
||
/// credential guards:
|
||
/// - `inference/provider/compatible_request.rs::credential_for_request`
|
||
/// ("<provider> API key not set. Configure via the web UI …")
|
||
/// - the embeddings credential guards (cohere/openai) + the composio
|
||
/// direct-mode factory bail ("no api key is configured" / "no api key
|
||
/// supplied").
|
||
///
|
||
/// Distinct from a *rejected* key (a provider 401 "Invalid API key"): that is a
|
||
/// present-but-wrong key and stays actionable in Sentry — this matcher is for
|
||
/// the **absent** key only, which has no Sentry remediation path.
|
||
pub fn is_api_key_unset_message(text: &str) -> bool {
|
||
let lower = text.to_ascii_lowercase();
|
||
lower.contains("api key not set")
|
||
|| lower.contains("missing api key")
|
||
|| lower.contains("no api key is configured")
|
||
|| lower.contains("no api key supplied")
|
||
}
|
||
|
||
/// Detect the in-process-core boot-window shape: a sibling component
|
||
/// (frontend RPC relay, agent-integrations / composio HTTP clients) tried to
|
||
/// reach the embedded core's `127.0.0.1:<port>` listener before it finished
|
||
/// binding, so the kernel returned `Connection refused`. The condition
|
||
/// self-resolves once startup completes — Sentry has no remediation path.
|
||
///
|
||
/// Conjunctive match — both anchors must hit:
|
||
///
|
||
/// 1. **Loopback host with port**: substring `127.0.0.1:` or `localhost:` so
|
||
/// a doc URL or unrelated mention without a port (`localhost`,
|
||
/// `127.0.0.1\n`) does not match. Pinned to the colon+port pattern
|
||
/// because every observed shape from reqwest / hyper / our own
|
||
/// `IntegrationClient` wraps the host as `<host>:<port>` in the URL the
|
||
/// error chain renders.
|
||
/// 2. **Connection refused with platform errno**: `connection refused (os
|
||
/// error 61)` (macOS / BSD), `connection refused (os error 111)`
|
||
/// (Linux), or `connection refused (os error 10061)` (Windows
|
||
/// `WSAECONNREFUSED`). Pinning to `(os error N)` keeps the matcher from
|
||
/// swallowing higher-level wrappers that merely mention "connection
|
||
/// refused" in prose.
|
||
///
|
||
/// Drops OPENHUMAN-TAURI-R5 (~2.5k events, `integrations.get` emit site)
|
||
/// and OPENHUMAN-TAURI-R6 (~2.5k events, the `rpc.invoke_method` re-wrap of
|
||
/// the same trace). Both share `trace_id=6ebf5b62748d5144e541e2cddeabbbd0`
|
||
/// and the canonical body shape:
|
||
///
|
||
/// ```text
|
||
/// error sending request for url (http://127.0.0.1:18474/agent-integrations/composio/connections)
|
||
/// → client error (Connect) → tcp connect error → Connection refused (os error 61)
|
||
/// ```
|
||
///
|
||
/// Without this matcher the body falls through to
|
||
/// [`is_network_unreachable_message`] and demotes as `NetworkUnreachable`,
|
||
/// which conflates an internal lifecycle race with user-environment problems
|
||
/// (VPN drop, captive portal, ISP block) and makes the "what's spiking?"
|
||
/// question un-answerable. See [`ExpectedErrorKind::LoopbackUnavailable`].
|
||
fn is_loopback_unavailable(lower: &str) -> bool {
|
||
let has_loopback_host = lower.contains("127.0.0.1:") || lower.contains("localhost:");
|
||
if !has_loopback_host {
|
||
return false;
|
||
}
|
||
lower.contains("connection refused (os error 61)")
|
||
|| lower.contains("connection refused (os error 111)")
|
||
|| lower.contains("connection refused (os error 10061)")
|
||
}
|
||
|
||
/// Detect Ollama embed call sites that surface a user-config rejection from
|
||
/// the local Ollama daemon — pure user-state errors the UI already surfaces
|
||
/// (toast / settings page warning) where Sentry has no remediation path.
|
||
///
|
||
/// Several canonical wire shapes are covered, all emitted by
|
||
/// `openhuman::embeddings::ollama::OllamaEmbedding::embed` and the embed
|
||
/// service fallback path:
|
||
///
|
||
/// - **TAURI-RUST-XS** (~376 events on self-hosted Sentry): user pointed the
|
||
/// embedder at a chat / vision model id with a temperature suffix (e.g.
|
||
/// `qwen3-vl:4b@0.7`) which Ollama parses as malformed. Wire shape:
|
||
/// `ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}`.
|
||
/// - **OPENHUMAN-TAURI-MA / -KM** (deferred follow-up from PR #2216), and
|
||
/// **TAURI-RUST-K** (~1990 events) / **TAURI-RUST-8K** (~411 events) on
|
||
/// self-hosted Sentry: user configured a model id that the local Ollama
|
||
/// daemon hasn't pulled yet. Wire shape:
|
||
/// `ollama embed failed with status 404 Not Found: {"error":"model \"<id>\" not found, try pulling it first"}`.
|
||
/// (Self-hosted Sentry events still flow from older client releases that
|
||
/// predate this matcher; they drop off naturally as users upgrade.)
|
||
/// - **TAURI-RUST-3X / -8WA** (~982 events on 0.57.52): 501 "embeddings not
|
||
/// supported". Two bodies — the model is chat/vision-only
|
||
/// (`{"error":"this model does not support embeddings"}`) or the Ollama
|
||
/// daemon was started without embed support
|
||
/// (`{"error":"This server does not support embeddings. Start it with `--embeddings`"}`).
|
||
/// - **TAURI-RUST-3E** (~249 events): 401 auth-required Ollama endpoint with
|
||
/// no credentials configured. Wire shape:
|
||
/// `ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}`.
|
||
/// - **OPENHUMAN-TAURI-GX**: user opted into Ollama embeddings but the
|
||
/// daemon isn't running on `localhost:11434`, so the embed service falls
|
||
/// back to cloud embeddings for the session. Wire shape:
|
||
/// `ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session`.
|
||
///
|
||
/// All are user-config: the user picked the wrong model id, forgot to pull
|
||
/// it, ran a daemon without embed support, omitted credentials, or forgot to
|
||
/// start the daemon. The remediation is "fix the model id in Settings" /
|
||
/// "run `ollama pull <id>`" / "start ollama with `--embeddings`" / "add a
|
||
/// key" / "start ollama" — none of which Sentry can do for them.
|
||
///
|
||
/// Each arm is anchored on the `"ollama embed"` prefix
|
||
/// (`"ollama embed failed"` for the failed-request shapes,
|
||
/// `"ollama embeddings opted-in"` for the daemon-unreachable fallback) so
|
||
/// unrelated errors elsewhere in the codebase that happen to contain
|
||
/// `"invalid model name"`, `"not found"`, or `"does not support embeddings"`
|
||
/// substrings are not silenced.
|
||
///
|
||
/// Routes to [`ExpectedErrorKind::ProviderUserState`] — the same bucket that
|
||
/// holds the composio / gmail / OAuth user-state errors. We deliberately do
|
||
/// **not** introduce a dedicated Ollama enum variant: the demotion semantics
|
||
/// (drop to `info` log, skip Sentry capture) are identical and adding a new
|
||
/// variant for every provider would balloon the enum without changing
|
||
/// behavior.
|
||
fn is_ollama_user_config_rejection(lower: &str) -> bool {
|
||
// XS — 400-status user-config (invalid model name, including the
|
||
// temperature-suffix shape `qwen3-vl:4b@0.7` Ollama parses as malformed).
|
||
if lower.contains("ollama embed failed") && lower.contains("invalid model name") {
|
||
return true;
|
||
}
|
||
|
||
// MA / KM — 404-status pull-required. The wire shape is JSON-escaped
|
||
// (`\"<model-id>\" not found`); after lower-casing we still see the
|
||
// backslash-quoted form. Anchor on `model \"` + `\" not found` so an
|
||
// unrelated 404 that merely contains `"model"` and `"not found"` is not
|
||
// swallowed. The `\\"` byte pair in Rust source matches the literal
|
||
// `\"` sequence in the wire shape.
|
||
if lower.contains("ollama embed failed")
|
||
&& lower.contains("model \\\"")
|
||
&& lower.contains("\\\" not found")
|
||
{
|
||
return true;
|
||
}
|
||
|
||
// 3X / 8WA — 501-status "embeddings not supported". Ollama emits two
|
||
// bodies for this: the model is chat/vision-only
|
||
// (`{"error":"this model does not support embeddings"}`) or the daemon
|
||
// itself was started without embedding support
|
||
// (`{"error":"This server does not support embeddings. Start it with `--embeddings`"}`,
|
||
// TAURI-RUST-8WA, ~982 events on 0.57.52). Both are user-side Ollama
|
||
// config the app can't fix — it can neither swap the user's model nor
|
||
// restart their daemon with `--embeddings`. Anchor on the shared
|
||
// `does not support embeddings` phrase (still gated by the
|
||
// `ollama embed failed` prefix) so a future qualifier wording still demotes.
|
||
if lower.contains("ollama embed failed") && lower.contains("does not support embeddings") {
|
||
return true;
|
||
}
|
||
|
||
// TAURI-RUST-3E (~249 events) — 401-status auth failure from Ollama
|
||
// (user pointed the embedder at an authenticated Ollama endpoint
|
||
// without configuring credentials, e.g. self-hosted Ollama behind an
|
||
// auth proxy or Ollama Cloud without API key). Body shape:
|
||
// `{"error": "unauthorized"}`. Anchor on `ollama embed failed`
|
||
// + `status 401` so unrelated 401s from other call sites (provider
|
||
// chat, backend API) aren't silenced.
|
||
if lower.contains("ollama embed failed") && lower.contains("status 401") {
|
||
return true;
|
||
}
|
||
|
||
// GX — daemon-unreachable opt-in state. The wire shape is emitted by
|
||
// the embed service when the user has opted into Ollama in settings
|
||
// but the daemon isn't responding, so the service falls back to cloud
|
||
// embeddings for the session. Anchor on the full prefix to keep the
|
||
// matcher from colliding with unrelated `"daemon unreachable"`
|
||
// messages from other domains (e.g. backend connection-health logs).
|
||
if lower.contains("ollama embeddings opted-in but daemon unreachable at") {
|
||
return true;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Detect transport-level connection failures that fire before any HTTP status
|
||
/// is observed — DNS resolution failures, TCP connect refused/reset, TLS
|
||
/// handshake failures, or ISP/firewall blocks. The canonical shape is
|
||
/// reqwest's `"error sending request for url (…)"`, which surfaces from any
|
||
/// HTTP call site (provider chat, embeddings, backend RPC) when the request
|
||
/// can't reach the server at all.
|
||
///
|
||
/// These are user-environment problems — VPN drop, captive portal, ISP-level
|
||
/// block (OPENHUMAN-TAURI-32: user in RU couldn't reach `api.tinyhumans.ai`),
|
||
/// firewall — that no amount of retry / fallback on our side can resolve.
|
||
/// Sentry has no signal to act on (no status, no trace, no payload), so each
|
||
/// occurrence is pure noise. Classify them as expected so the report site
|
||
/// logs a breadcrumb rather than spawning an error event.
|
||
///
|
||
/// Loopback `127.0.0.1:<port>` `Connection refused` shapes are routed
|
||
/// through [`is_loopback_unavailable`] *before* this matcher so the
|
||
/// boot-window race against the embedded core keeps its own bucket — see
|
||
/// the precedence comment in [`expected_error_kind`].
|
||
///
|
||
/// Three additional substrings cover wire-shape variants observed in
|
||
/// Wave 4 that the original `"dns error"` / status-code matchers miss:
|
||
///
|
||
/// - `"failed to lookup address"` / `"nodename nor servname"` —
|
||
/// `getaddrinfo()` failure renderings on macOS / BSD libc and POSIX
|
||
/// resolvers (`OPENHUMAN-TAURI-44` ~50 events,
|
||
/// `[socket] Connection failed: WebSocket connect: IO error: failed to
|
||
/// lookup address information: nodename nor servname provided, or not
|
||
/// known`).
|
||
/// - `"http error: 200 ok"` — tungstenite's `WsError::Http(200)` render
|
||
/// when a corporate proxy / captive portal intercepts the WebSocket
|
||
/// handshake and returns a plain HTML 200 page (`OPENHUMAN-TAURI-4P`
|
||
/// ~66 events). Tungstenite-only — reqwest renders HTTP 200 as
|
||
/// `"HTTP status server error (200)"`, so this can't collide with the
|
||
/// regular HTTP call path.
|
||
/// - `"unexpected eof during handshake"` — `native-tls`'s render when the
|
||
/// peer (or an intercepting firewall / antivirus / corporate TLS proxy)
|
||
/// closes the TCP connection mid-TLS-handshake, surfacing as
|
||
/// `"TLS error: native-tls error: unexpected EOF during handshake"`
|
||
/// wrapped by `socket::ws_loop::run_connection` into
|
||
/// `"WebSocket connect: …"` (`TAURI-RUST-4ZD`, first seen on
|
||
/// `openhuman@0.56.0`, Windows). The existing `"tls handshake"` anchor
|
||
/// misses it because the words aren't contiguous (`"tls error"` …
|
||
/// `"during handshake"`). Same user-environment shape as the other
|
||
/// handshake-stage entries — the socket supervisor already retries with
|
||
/// exponential backoff and Sentry has no actionable signal.
|
||
/// - `"http version must be 1.1 or higher"` — tungstenite's
|
||
/// `ProtocolError::WrongHttpVersion` render. Fires when a server (or
|
||
/// intermediary proxy / HTTP/2-only edge) responds to the WebSocket
|
||
/// upgrade with HTTP/2+, which the WS spec forbids — the handshake
|
||
/// requires HTTP/1.1 (`CORE-RUST-DP`, ~2 events / 24h, first seen on
|
||
/// `openhuman@0.56.0`). Same shape as the existing handshake-stage
|
||
/// entries: a user-environment / infra misconfiguration that the
|
||
/// client cannot fix; Sentry has no actionable signal beyond what the
|
||
/// socket supervisor's exponential backoff already provides.
|
||
fn is_network_unreachable_message(lower: &str) -> bool {
|
||
lower.contains("error sending request for url")
|
||
|| lower.contains("dns error")
|
||
|| lower.contains("failed to lookup address")
|
||
|| lower.contains("nodename nor servname")
|
||
|| lower.contains("connection refused")
|
||
|| lower.contains("connection reset")
|
||
// OPENHUMAN-TAURI-EM (128 events): the channel supervisor wraps
|
||
// `discord_listen()`'s anyhow chain as `format!("Channel {} error:
|
||
// {e:#}; restarting", ...)`, which lands as
|
||
// `"Channel discord error: IO error: Operation timed out (os error
|
||
// 60); restarting"`. The discord gateway TCP/WebSocket connection
|
||
// timing out is transient network state, not a code bug — the
|
||
// supervisor already retries with exponential backoff. Same shape
|
||
// surfaces on every channel (slack/telegram/...) once the
|
||
// underlying socket hits ETIMEDOUT, so we match on the platform-
|
||
// agnostic phrase, symmetric with `"connection reset"` /
|
||
// `"connection refused"` above. Errno renderings are not pinned
|
||
// because `(os error 60)` (BSD/macOS), `(os error 110)` (Linux),
|
||
// `(os error 10060)` (Windows `WSAETIMEDOUT`), and bare prose
|
||
// `"operation timed out"` (hyper / tungstenite / std::io) all
|
||
// share the same lowercase substring.
|
||
|| lower.contains("operation timed out")
|
||
|| lower.contains("network is unreachable")
|
||
|| lower.contains("no route to host")
|
||
|| lower.contains("tls handshake")
|
||
|| lower.contains("unexpected eof during handshake")
|
||
|| lower.contains("certificate verify failed")
|
||
|| lower.contains("http error: 200 ok")
|
||
|| lower.contains("http version must be 1.1 or higher")
|
||
}
|
||
|
||
/// Detect the canonical supervisor-wrap shape emitted by
|
||
/// `channels::runtime::supervision::spawn_supervised_listener` —
|
||
/// `"Channel <name> error: <inner>; restarting"`. Language-agnostic
|
||
/// (anchored on the Rust wrapper, not the inner error wording) so it
|
||
/// covers OS-localized variants (TAURI-RUST-BB Chinese-Windows
|
||
/// WSAETIMEDOUT body) that escape the English-only network anchors in
|
||
/// [`is_network_unreachable_message`].
|
||
///
|
||
/// The supervisor restarts the listener with its own exponential backoff;
|
||
/// sustained outages surface via separate `health.bus` events /
|
||
/// `FAIL_ESCALATE_THRESHOLD`. Per-restart messages carry no actionable
|
||
/// Sentry signal — Sentry has no remediation path beyond what the
|
||
/// supervisor already does (TAURI-RUST-15 ~11.4 k events / -BB ~815
|
||
/// events on self-hosted `tauri-rust`).
|
||
///
|
||
/// Anchors on three substrings together to avoid false positives:
|
||
/// - leading `"channel "` (with trailing space disambiguates from
|
||
/// unrelated mentions like `"channels"` or `"channel-runtime"`)
|
||
/// - `" error:"` (the wrapper's literal separator)
|
||
/// - `"; restarting"` (the wrapper's literal trailer)
|
||
///
|
||
/// A bare `"…; restarting"` log line without the `"Channel <name> error:"`
|
||
/// preamble must NOT classify — that's a generic restart note from some
|
||
/// other subsystem and Sentry signal there may still be actionable.
|
||
fn is_channel_supervisor_restart_message(lower: &str) -> bool {
|
||
lower.starts_with("channel ") && lower.contains(" error:") && lower.contains("; restarting")
|
||
}
|
||
|
||
/// Detect transient upstream HTTP failures that have bubbled up out of the
|
||
/// provider layer and into higher-level domains (`agent`, `web_channel`, …).
|
||
///
|
||
/// The reliable-provider stack already retries / falls back on
|
||
/// [`TRANSIENT_PROVIDER_HTTP_STATUSES`] (408/429/502/503/504), and the
|
||
/// `before_send` filter drops the per-attempt provider events that carry
|
||
/// `domain=llm_provider`. But the same error is *also* returned via
|
||
/// `Result::Err` and re-reported by callers that wrap the provider — e.g.
|
||
/// `agent.run_single` (OPENHUMAN-TAURI-5Z), `web_channel.run_chat_task`,
|
||
/// scheduler tick handlers — under a different `domain` tag, escaping the
|
||
/// provider-scoped filter and producing one Sentry event per failed turn.
|
||
///
|
||
/// The canonical wire format from `providers::ops::api_error` is:
|
||
/// `"<provider> API error (<status>): <sanitized>"` — e.g.
|
||
/// `"OpenHuman API error (504 Gateway Timeout): error code: 504"`. Pin the
|
||
/// match to that exact `"api error (<status>"` prefix so an unrelated message
|
||
/// that merely mentions "504" (a log line, a doc URL) is not silenced.
|
||
///
|
||
/// Also matches the second canonical wire shape: tungstenite's
|
||
/// `WsError::Http(response)` Display, which renders as `"HTTP error: <status>"`
|
||
/// (and which `socket::ws_loop::run_connection` wraps as
|
||
/// `"WebSocket connect: HTTP error: 502 Bad Gateway"`). Per
|
||
/// OPENHUMAN-TAURI-5P (~110 events) and -EZ (~51 events), backend
|
||
/// staging/production load balancers emit HTTP 502/504 during the WebSocket
|
||
/// upgrade handshake; tungstenite surfaces those as `WsError::Http` and the
|
||
/// socket reconnect loop already handles them via exponential backoff. Each
|
||
/// `FAIL_ESCALATE_THRESHOLD` escalation fires `report_error_or_expected` with
|
||
/// the formatted reason, which would land in Sentry as `domain=socket`
|
||
/// noise without this matcher (the existing `domain=integrations`
|
||
/// before_send filter scopes too narrowly).
|
||
///
|
||
/// Three separator variants cover every observed shape: trailing space
|
||
/// (`"HTTP error: 502 Bad Gateway"`), trailing newline (`"HTTP error: 502\n…"`
|
||
/// from chained errors), and trailing colon (`"HTTP error: 502: …"`). Bare
|
||
/// `"HTTP error: 502"` at end-of-string is not matched on purpose — the
|
||
/// status integer alone could collide with unrelated log lines containing
|
||
/// `"HTTP error: 5023"` (port number, runbook ID).
|
||
fn is_transient_upstream_http_message(lower: &str) -> bool {
|
||
TRANSIENT_PROVIDER_HTTP_STATUSES.iter().any(|code| {
|
||
lower.contains(&format!("api error ({code}"))
|
||
|| lower.contains(&format!("api error {code} "))
|
||
|| lower.contains(&format!("http error: {code} "))
|
||
|| lower.contains(&format!("http error: {code}\n"))
|
||
|| lower.contains(&format!("http error: {code}:"))
|
||
})
|
||
}
|
||
|
||
/// Detect non-2xx HTTP failures returned from the backend integrations / composio
|
||
/// clients that are by definition user-input or user-auth-state problems — not
|
||
/// bugs Sentry can act on.
|
||
///
|
||
/// The canonical wire format from
|
||
/// [`crate::openhuman::integrations::client::IntegrationClient::post`] / `get`
|
||
/// and [`crate::openhuman::composio::client::ComposioClient`] is:
|
||
/// `"Backend returned <status> <reason> for <METHOD> <url>: <detail>"` — e.g.
|
||
/// `"Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: Composio authorization failed: 400 …"`
|
||
/// (OPENHUMAN-TAURI-BC: user submitted SharePoint authorize without filling in
|
||
/// the required Tenant Name field). The backend correctly returned a 4xx; the
|
||
/// UI already surfaces the structured error to the user via toast — Sentry has
|
||
/// no remediation path because the request was malformed *by the user's
|
||
/// input*, not by our code.
|
||
///
|
||
/// We pin the match to the `"backend returned "` prefix so an unrelated
|
||
/// message merely mentioning "400" (a log line, doc URL) is not silenced.
|
||
///
|
||
/// We classify only 4xx codes, with **two exclusions**:
|
||
/// - `408 Request Timeout` and `429 Too Many Requests` are *transient* — they
|
||
/// are surfaced via [`is_transient_upstream_http_message`] for the provider
|
||
/// path and stay actionable for the backend path so a sustained 429 (rate
|
||
/// limit cliff) still pages.
|
||
///
|
||
/// 5xx is intentionally **not** classified here — server-side failures from
|
||
/// our backend are real bugs that should reach Sentry. The transient
|
||
/// 502/503/504 deduplication is handled by the threshold logic in callers
|
||
/// (see e.g. `openhuman::socket::ws_loop::FAIL_ESCALATE_THRESHOLD`).
|
||
fn is_backend_user_error_message(lower: &str) -> bool {
|
||
let Some(rest) = lower.split_once("backend returned ").map(|(_, r)| r) else {
|
||
return false;
|
||
};
|
||
let status_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||
let Ok(status) = status_digits.parse::<u16>() else {
|
||
return false;
|
||
};
|
||
// 4xx (except transient 408 / 429 which are handled separately).
|
||
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-XX: custom_openai upstream rejected the request with
|
||
// its own 400. Wire shape produced by
|
||
// `inference/provider/compatible.rs::is_custom_openai_upstream_bad_request_http_400`:
|
||
//
|
||
// custom_openai API error (400 Bad Request): {"error":{
|
||
// "message":"Bad request to upstream provider",
|
||
// "type":"upstream_error","status":400}}
|
||
//
|
||
// Anchored to the `custom_openai api error (400` prefix so this can't
|
||
// silence unrelated errors that happen to mention both
|
||
// "bad request to upstream provider" and "upstream_error" elsewhere
|
||
// (e.g. a future provider whose envelope reuses one of those strings).
|
||
if lower.contains("custom_openai api error (400")
|
||
&& lower.contains("bad request to upstream provider")
|
||
&& lower.contains("upstream_error")
|
||
{
|
||
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;
|
||
}
|
||
|
||
// OPENHUMAN-TAURI-S7: provider policy rejection on Kimi's coding
|
||
// endpoint when requests are not sent from an approved coding-agent
|
||
// client. Canonical body contains `access_terminated_error` and:
|
||
// "currently only available for Coding Agents ...".
|
||
if lower.contains("access_terminated_error")
|
||
|| lower.contains("currently only available for coding agents")
|
||
{
|
||
return true;
|
||
}
|
||
|
||
// TAURI-RUST-X9 (#1166): direct-mode composio call against the user's
|
||
// personal Composio v3 tenant rejected with a 401 because the stored
|
||
// API key is invalid / revoked / has the wrong prefix. The canonical
|
||
// wire shape rendered by
|
||
// `src/openhuman/composio/tools/impl/network/composio.rs::response_error`
|
||
// and the various direct-mode op wrappers is:
|
||
//
|
||
// `[composio-direct] list_connections failed: Composio v3
|
||
// connected_accounts failed: HTTP 401: Invalid API key: ak_…`
|
||
//
|
||
// The "Invalid API key" body is rendered for every direct-mode
|
||
// endpoint (list_connections / list_tools / authorize / etc.), so we
|
||
// gate on the **`[composio-direct]` prefix** + either of the two
|
||
// anchors that prove the failure came from the v3 auth wall:
|
||
// - `HTTP 401` (the status the v3 wall returns)
|
||
// - `Invalid API key` (the body Composio puts in the JSON)
|
||
//
|
||
// Requiring the `[composio-direct]` prefix keeps this from
|
||
// accidentally swallowing unrelated bugs — backend-mode 401s from
|
||
// `integrations/composio/*` still carry the `Backend returned 401`
|
||
// shape (handled by the failure-tag flow with `status="401"`),
|
||
// not the `HTTP 401: Invalid API key` shape.
|
||
//
|
||
// Remediation is purely user-state: the user must rotate / re-enter
|
||
// their Composio key via Settings → Composio → Direct mode. Sentry
|
||
// has no actionable signal — the UI surfaces the "Invalid API key"
|
||
// toast and the polling layer already retries every 5 s.
|
||
//
|
||
// Drops Sentry TAURI-RUST-X9 (~15.7 k events / ~22 h, single user,
|
||
// release openhuman@0.54.0+c25fc8e5fd3e).
|
||
if lower.contains("[composio-direct]")
|
||
&& (lower.contains("http 401") || lower.contains("invalid api key"))
|
||
{
|
||
return true;
|
||
}
|
||
|
||
// TAURI-RUST-34H — composio backend endpoint (e.g.
|
||
// `/agent-integrations/composio/connections`) wraps an upstream
|
||
// Cloudflare anti-bot challenge as `Backend returned 500 Internal
|
||
// Server Error … 403 <!DOCTYPE html>…<title>Just a moment...</title>…`.
|
||
// The CF interstitial is keyed by the user's network reputation /
|
||
// geo / cookie state — there is nothing in `openhuman_core` that
|
||
// can act on it. Backend ops or the user's network is the
|
||
// remediation path; Sentry has no signal.
|
||
//
|
||
// Double-anchor on the Cloudflare challenge title + the literal
|
||
// "cloudflare" token to avoid colliding with unrelated bodies that
|
||
// merely mention "Just a moment" in a different context.
|
||
//
|
||
// Drops ~8.9 k events / 14d (TAURI-RUST-34H, sibling -32G / -34J /
|
||
// -323 share the same cascade).
|
||
if lower.contains("just a moment...") && lower.contains("cloudflare") {
|
||
return true;
|
||
}
|
||
|
||
// OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed a
|
||
// user-configured custom-provider's `/models` endpoint and the upstream
|
||
// server returned 404. Wire shape emitted at `ops.rs:118-122`:
|
||
//
|
||
// "provider returned 404: {\"error\":\"path \\\"/api/v1/models\\\" not found\"}"
|
||
//
|
||
// (the trailing body is whatever the upstream server wrote — `{"error":...}`,
|
||
// `{"detail":...}`, bare HTML, etc.; we only anchor on the `provider returned
|
||
// 404` prefix). The semantic is unambiguous: the user pointed a custom
|
||
// OpenAI-compatible provider at a base URL that does not host a `/models`
|
||
// listing endpoint (wrong base, model-only proxy, typo'd path). The model
|
||
// dropdown already surfaces the failure inline — Sentry has no remediation.
|
||
//
|
||
// **404 only**. Other 4xx from the same emit site stay actionable:
|
||
// - 401 / 403: BYO-key auth wall — actionable misconfiguration; the
|
||
// `does_not_classify_byo_key_provider_401_as_session_expired` contract
|
||
// (#2286) intentionally keeps these in Sentry.
|
||
// - 400: typically request-shape bugs in OUR client; must escalate.
|
||
// - 429 / 5xx: transient — handled by other matchers / retry policy.
|
||
//
|
||
// No `inference/provider/ops.rs::list_models` other than this site emits
|
||
// the `provider returned NNN` prefix (verified via grep), so the prefix
|
||
// alone is a sufficient anchor.
|
||
//
|
||
// TAURI-RUST-8X3: anchor to the position where `provider returned 404` is
|
||
// the formatted *error prefix* — never to any occurrence in the response
|
||
// body. The primary fix classifies the *raw* error at the source
|
||
// (`inference/ops.rs::inference_list_models`) before any log prefix is
|
||
// applied, so the raw shape always starts with `provider returned 404:`.
|
||
// The one historically-observed prefixed re-report path is the
|
||
// `inference/ops.rs` `error!("[inference::ops] list_models:error: {err}")`
|
||
// log line — handled below as an explicit prefixed shape.
|
||
//
|
||
// A bare `contains` would mis-fire: a genuine 400/500 list-models failure
|
||
// formats as `provider returned 500: <body>`, and if `<body>` merely
|
||
// relays an upstream phrase like `upstream provider returned 404 ...`, the
|
||
// loose substring would demote that real 4xx/5xx defect out of Sentry —
|
||
// exactly the failures the discrimination guard
|
||
// (`does_not_classify_non_404_list_models_failures_as_user_state`) says
|
||
// must still escalate. So we require the anchor to be the prefix, not buried
|
||
// text. (Mirrors the parenthesised `(401` anchoring in
|
||
// `is_session_expired_message`.)
|
||
if lower.starts_with("provider returned 404")
|
||
|| lower.contains("list_models:error: provider returned 404")
|
||
{
|
||
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
|
||
/// on a 0–4 GB tier). These are pure user-state conditions — the local-AI
|
||
/// service surfaces them so the UI can prompt the user to switch tiers —
|
||
/// and carry no remediable signal for Sentry.
|
||
///
|
||
/// The two canonical wire shapes today both contain `"for this ram tier"`:
|
||
///
|
||
/// - `"Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or
|
||
/// above to enable it."` — from `inference/local/service/assets.rs::ensure_capability_ready`
|
||
/// - `"vision summaries are unavailable for this RAM tier. Use OCR-only
|
||
/// summarization or switch to a higher local AI tier."` —
|
||
/// from `inference/local/service/vision_embed.rs::summarize`
|
||
///
|
||
/// Anchor the classifier to that exact substring so an unrelated message
|
||
/// that merely mentions "RAM tier" out of context is not silenced.
|
||
fn is_local_ai_capability_unavailable_message(lower: &str) -> bool {
|
||
lower.contains("for this ram tier")
|
||
}
|
||
|
||
/// Detect prompts rejected by the in-process prompt-injection guard.
|
||
///
|
||
/// Both enforcement actions that produce a user-visible error — `Blocked`
|
||
/// (score ≥ 0.70) and `ReviewBlocked` (score ≥ 0.55) — share a unique
|
||
/// prefix that cannot appear in any other error path. Anchored to the exact
|
||
/// strings emitted by `prompt_guard_user_message` in
|
||
/// `src/openhuman/inference/local/ops.rs`.
|
||
fn is_prompt_injection_blocked_message(lower: &str) -> bool {
|
||
lower.contains("prompt flagged for security review")
|
||
|| lower.contains("prompt blocked by security policy")
|
||
}
|
||
|
||
/// Detect an RPC-level filesystem path validation failure from user input.
|
||
///
|
||
/// Anchored on the two known wire shapes — both emitted at the RPC entry
|
||
/// boundary when a user typed/picked a path that doesn't resolve to an
|
||
/// existing directory:
|
||
///
|
||
/// - `"root_path is not a directory: <path>"` — historically emitted by the
|
||
/// now-removed knowledge-vault `vault_create` path when the chosen folder
|
||
/// didn't exist or pointed at a file (Sentry TAURI-RUST-4QH). Kept as a
|
||
/// classifier fixture since the wire shape may recur from other callers.
|
||
/// - `"hosted path is not a directory: <path>"` —
|
||
/// [`crate::openhuman::http_host::path_utils`] when an HTTP host config
|
||
/// references a missing directory. Not yet observed in Sentry but
|
||
/// shares the same user-input failure mode; preempts a future ID.
|
||
///
|
||
/// Both are deterministic Err returns at the validation gate of an RPC
|
||
/// handler, BEFORE any side-effect happens. The UI already surfaces the
|
||
/// typed error and Sentry has no remediation path.
|
||
///
|
||
/// **Polarity contract** — explicit wire-shape anchors prevent accidental
|
||
/// demotion of future errors whose bodies happen to contain "path is not
|
||
/// a directory:" in a different context:
|
||
///
|
||
/// - `skills::ops_install` emits `"{path} is not a directory — refusing
|
||
/// to remove"` (em-dash separator, no "root_path" or "hosted path"
|
||
/// prefix). That is an `rm -rf` safety guard catching an UNEXPECTED
|
||
/// state, not user input — it must STAY actionable.
|
||
/// - A generic `"input config path is not a directory: /etc/foo"` from a
|
||
/// future provider/wallet/storage error would NOT match (no known
|
||
/// prefix) and would reach Sentry as intended.
|
||
///
|
||
/// All matches are substring-based against the lower-cased message so
|
||
/// the classifier survives caller wrapping (`rpc.invoke_method`,
|
||
/// anyhow context chains, …).
|
||
fn is_filesystem_user_path_invalid_message(lower: &str) -> bool {
|
||
lower.contains("root_path is not a directory:")
|
||
|| lower.contains("hosted path is not a directory:")
|
||
}
|
||
|
||
/// Detect the agent harness's empty-provider-response bail.
|
||
///
|
||
/// Anchored on the literal user-facing string emitted at
|
||
/// `agent::harness::session::turn` —
|
||
/// `"The model returned an empty response. Please try again."` — which is
|
||
/// preserved verbatim as the provider/model returns a body with
|
||
/// `text_chars=0 thinking_chars=0 tool_calls=0`.
|
||
///
|
||
/// This catches the **web-channel re-report** (Sentry TAURI-RUST-4Z1):
|
||
/// `channels::providers::web::run_chat_task` wraps the failure as
|
||
/// `"run_chat_task failed client_id=… error=The model returned an empty
|
||
/// response. Please try again."` and routes it through
|
||
/// `report_error_or_expected` after the typed
|
||
/// `AgentError::EmptyProviderResponse` was flattened to a `String` at the
|
||
/// native-bus boundary (so the agent-layer `skips_sentry()` suppression
|
||
/// from PR #2790 can't reach it).
|
||
///
|
||
/// Anchored on `"model returned an empty response"` (not the looser
|
||
/// `"empty response"`) so the sibling phrases stay actionable:
|
||
/// `"summarizer returned empty response, falling through"`
|
||
/// (`payload_summarizer`) and `"provider returned an empty response;
|
||
/// returning empty extraction"` (`subagent_runner::extract_tool`) are
|
||
/// internal fall-through paths with different wording and are NOT
|
||
/// silenced.
|
||
fn is_empty_provider_response_message(lower: &str) -> bool {
|
||
lower.contains("model returned an empty response")
|
||
}
|
||
|
||
/// Capture an error to Sentry with structured tags.
|
||
///
|
||
/// `domain` and `operation` are required and become tags `domain:<…>` and
|
||
/// `operation:<…>`. `extra` is an optional list of extra tag pairs. The error
|
||
/// itself is rendered via `Display` and emitted as a `tracing::error!` event,
|
||
/// which the Sentry tracing layer turns into a Sentry event under the active
|
||
/// scope.
|
||
///
|
||
/// Use stable, low-cardinality values for tag keys/values so Sentry can group
|
||
/// and aggregate. High-cardinality data (full IDs, payloads) belongs in the
|
||
/// error message body, not in tags.
|
||
pub fn report_error<E: Display + ?Sized>(
|
||
err: &E,
|
||
domain: &str,
|
||
operation: &str,
|
||
extra: &[Tag<'_>],
|
||
) {
|
||
// Use the alternate format specifier so `anyhow::Error` renders its full
|
||
// context chain (outer context + every wrapped cause, joined by ": ").
|
||
// Plain `Display` impls fall back to the standard representation. Without
|
||
// this, anyhow's default `to_string()` only emits the outermost context
|
||
// and the underlying cause (e.g. a `toml::de::Error` with line/column) is
|
||
// dropped — making the captured Sentry event undiagnosable. See
|
||
// OPENHUMAN-TAURI-B2 for an instance where this masked the real failure.
|
||
let message = format!("{err:#}");
|
||
report_error_message(&message, domain, operation, extra);
|
||
}
|
||
|
||
/// Report an error unless it is an expected user-state/config condition.
|
||
///
|
||
/// Expected conditions are logged at `info` or `warn` so the Sentry tracing
|
||
/// layer records at most a breadcrumb, not an error event.
|
||
pub fn report_error_or_expected<E: Display + ?Sized>(
|
||
err: &E,
|
||
domain: &str,
|
||
operation: &str,
|
||
extra: &[Tag<'_>],
|
||
) {
|
||
let message = format!("{err:#}");
|
||
if let Some(kind) = expected_error_kind(&message) {
|
||
report_expected_message(kind, &message, domain, operation);
|
||
return;
|
||
}
|
||
report_error_message(&message, domain, operation, extra);
|
||
}
|
||
|
||
fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, operation: &str) {
|
||
match kind {
|
||
ExpectedErrorKind::LocalAiDisabled => {
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected local-ai disabled error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ApiKeyMissing => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected API-key configuration error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::NetworkUnreachable => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected network-unreachable error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::TransientUpstreamHttp => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped transient upstream HTTP error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::LocalAiBinaryMissing => {
|
||
// User-state condition: piper / whisper.cpp / Ollama binary
|
||
// isn't installed on this host. The error message itself is
|
||
// the user-facing instruction ("Set PIPER_BIN or install
|
||
// piper.") — Sentry has nothing to act on, since we can't
|
||
// install the binary for them. OPENHUMAN-TAURI-9N is the
|
||
// canonical instance: `local_ai_tts` fails immediately
|
||
// (elapsed_ms=1) on a Windows host without piper installed.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected local-ai binary-missing error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::BackendUserError => {
|
||
// 4xx from the integrations / composio backend client —
|
||
// user-input or auth-state failure that the backend already
|
||
// surfaced to the user via the structured error toast.
|
||
// OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the
|
||
// user didn't fill in the required Tenant Name field.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[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::McpServerNeedsAuth => {
|
||
// A remote MCP server rejected the connect handshake with HTTP 401:
|
||
// it needs OAuth sign-in. `mcp_registry::connections::connect`
|
||
// already stores a `needs_auth` flag and the UI prompts the user to
|
||
// authenticate (#3733 / #3719) — but `mcp_clients_connect` re-raises
|
||
// the stringified error to the RPC dispatcher, where it was being
|
||
// captured as a full Sentry error (TAURI-RUST-CGP: ~1.2k events / 79
|
||
// users). Preventable user-state with no Sentry-actionable signal;
|
||
// demote to info so the breadcrumb survives but no error event fires.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "mcp_server_needs_auth",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected MCP needs-auth (401) error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ProviderConfigRejection => {
|
||
// User-config state: a custom cloud provider rejected the
|
||
// request because of the user's model / parameter setup — an
|
||
// OpenHuman abstract tier alias leaked to a provider that only
|
||
// speaks its native ids (#2079), an unknown / stale model pin
|
||
// (#2202), or a model-specific temperature constraint (#2076,
|
||
// Moonshot Kimi K2). The provider HTTP layer already demoted
|
||
// its own per-attempt event; this is the re-report raised
|
||
// again by agent.run_single / web_channel.run_chat_task. The
|
||
// UI surfaces an actionable "fix your model/provider settings"
|
||
// error — Sentry has no remediation path
|
||
// (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH).
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "provider_config_rejection",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected provider config-rejection error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::LocalAiCapabilityUnavailable => {
|
||
// User-state condition: the local-AI service refused a
|
||
// capability (vision summarization, vision asset download)
|
||
// because the user's RAM tier doesn't support it. The
|
||
// error message itself is the user-facing remediation
|
||
// ("Switch to the 4-8 GB tier or above to enable it.") —
|
||
// Sentry has nothing to act on. OPENHUMAN-TAURI-3B: 28
|
||
// hits in 4 days from `local_ai_download_asset` on a
|
||
// 0–4 GB tier requesting vision.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected local-ai capability-unavailable error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::BudgetExhausted => {
|
||
// User-state condition: the backend reports the user is out of
|
||
// budget / credits / balance (HTTP 400 from the OpenHuman backend,
|
||
// surfaced by `providers::is_budget_exhausted_message`). The UI
|
||
// already surfaces this as an actionable toast — Sentry would
|
||
// turn each affected turn into noise (OPENHUMAN-TAURI-3M / -12 /
|
||
// -13). Demote to info so it still appears in breadcrumbs but
|
||
// never spawns a Sentry error event.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "budget",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected budget-exhausted error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::SessionExpired => {
|
||
// Auth-boundary condition: the user's JWT expired (or was never
|
||
// present). The JSON-RPC dispatch layer already handles the
|
||
// teardown — `Err` propagation publishes `DomainEvent::SessionExpired`
|
||
// which clears the stored token and flips the scheduler-gate
|
||
// signed-out override so background workers stand down — and the
|
||
// UI re-auths the user. The per-attempt error event from the
|
||
// upstream call site (agent.run_single, web_channel.run_chat_task)
|
||
// adds noise without signal: every mid-conversation 401 would
|
||
// emit one event before the cascade dampener kicks in
|
||
// (OPENHUMAN-TAURI-26, and the same upstream gap that
|
||
// OPENHUMAN-TAURI-1T's #1516 cascade fix dampened but did not
|
||
// close). Demote to info so the breadcrumb survives for trace
|
||
// correlation but Sentry sees no error event.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected session-expired error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::LoopbackUnavailable => {
|
||
// In-process-core boot-window condition: a sibling component
|
||
// tried to reach `127.0.0.1:<port>` before the embedded core's
|
||
// HTTP listener finished binding (OPENHUMAN-TAURI-R5 / -R6).
|
||
// Self-resolves once startup completes. Demote at `debug!` —
|
||
// lower than the `warn!` we use for NetworkUnreachable because
|
||
// this isn't a user-environment problem; it's an internal
|
||
// lifecycle race that always recovers. We deliberately drop the
|
||
// raw `message` from the structured fields and format string and
|
||
// log only `domain` / `operation` / `kind` — the body adds no
|
||
// remediation signal (the URL is always loopback, the error is
|
||
// always "Connection refused") and keeping the breadcrumb sparse
|
||
// mirrors the per-#1719 review feedback (metadata over raw text
|
||
// for noise demotions).
|
||
tracing::debug!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "loopback_unavailable",
|
||
"[observability] {domain}.{operation} skipped expected loopback-unavailable error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::PromptInjectionBlocked => {
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "prompt_injection_blocked",
|
||
"[observability] {domain}.{operation} skipped expected prompt-injection-blocked error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ContextWindowExceeded => {
|
||
// Request too long for the model's context window. The provider
|
||
// api_error cascade already demotes its own emit; this is the
|
||
// higher-layer re-report. Deterministic user-state — the UI
|
||
// shows the retry message and the user trims / starts a new
|
||
// chat. Demote to `warn!` (breadcrumb only) — same tier as the
|
||
// other usage-state conditions.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "context_window_exceeded",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected context-window-exceeded error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::DiskFull => {
|
||
// Host filesystem out of space. The user must free space on
|
||
// their machine — Sentry can't help. Demote at `warn!` so a
|
||
// sustained spike still shows up in operator dashboards
|
||
// without turning every affected user-session into a Sentry
|
||
// error event. Drops TAURI-RUST-H4.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "disk_full",
|
||
"[observability] {domain}.{operation} skipped expected disk-full error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::MemoryStoreBreakerOpen => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "memory_store_breaker_open",
|
||
"[observability] {domain}.{operation} skipped expected memory-store circuit-breaker-open error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::WhatsAppDataSqliteBusy => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "whatsapp_data_sqlite_busy",
|
||
"[observability] {domain}.{operation} skipped expected whatsapp_data sqlite busy/locked error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::FilesystemUserPathInvalid => {
|
||
// User-input validation failure surfaced at the RPC
|
||
// boundary — e.g. `openhuman.vault_create` called with a
|
||
// `root_path` that doesn't exist. The typed error is
|
||
// already shown to the user; Sentry has no remediation
|
||
// path. Demote to `info!` — same tier as
|
||
// `PromptInjectionBlocked`, which is the closest severity
|
||
// class ("user input we already surfaced a typed error for";
|
||
// not operator-actionable like `DiskFull` / `NetworkUnreachable`).
|
||
//
|
||
// **Do not include the raw `message` here.** The message
|
||
// body embeds the user's local filesystem layout (username,
|
||
// project name, document directory, …) and
|
||
// `sentry_tracing_layer` in `core::logging` maps
|
||
// `Level::INFO` to `EventFilter::Breadcrumb` — so any
|
||
// formatted body would be attached as a breadcrumb to
|
||
// every subsequent Sentry event from this hub, leaking
|
||
// user paths into unrelated reports. Log only `domain` /
|
||
// `operation` / `kind` (no PII), matching the
|
||
// `LoopbackUnavailable` arm above ("metadata over raw text
|
||
// for noise demotions", per the #1719 review feedback).
|
||
// Full-path diagnostics for local debugging stay available
|
||
// via `RUST_LOG=…=debug` since `Level::DEBUG` / `TRACE`
|
||
// are mapped to `EventFilter::Ignore`.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "filesystem_user_path_invalid",
|
||
"[observability] {domain}.{operation} skipped expected filesystem path validation error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::EmptyProviderResponse => {
|
||
// Model/user-config condition — the provider returned a
|
||
// completely empty body and the agent harness bailed with the
|
||
// user-facing retry message. The agent layer already suppresses
|
||
// this via the typed `AgentError::skips_sentry()` (PR #2790);
|
||
// this arm covers the `web_channel.run_chat_task` re-report
|
||
// where the type was flattened to a String. Demote to `warn!`
|
||
// (breadcrumb only) — same tier as `MaxIterationsExceeded`,
|
||
// the other deterministic agent-state outcome surfaced to the
|
||
// user via the `chat_error` event.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "empty_provider_response",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected empty-provider-response error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ChannelSupervisorRestart => {
|
||
// Channel supervisor caught a transient error from a channel
|
||
// listener (`spawn_supervised_listener`) and restarted it. The
|
||
// wrapper is language-agnostic — anchored on the Rust supervisor
|
||
// shape, not the inner error wording — so this catches both the
|
||
// English Discord-gateway body (TAURI-RUST-15 ~11.4 k events) and
|
||
// OS-localized variants (TAURI-RUST-BB Chinese WSAETIMEDOUT,
|
||
// ~815 events) that the English-only `NetworkUnreachable`
|
||
// matchers miss. Self-resolving via the supervisor's exponential
|
||
// backoff — Sentry has no remediation path. Sustained outages
|
||
// still surface through `health.bus` / `FAIL_ESCALATE_THRESHOLD`
|
||
// (separate code path, not affected by this demotion). Demote to
|
||
// `info!` so the breadcrumb survives for trace correlation but
|
||
// Sentry sees no error or warn event.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "channel_supervisor_restart",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected channel-supervisor restart: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ConfigLoadTimedOut => {
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "config_load_timed_out",
|
||
error = %message,
|
||
"[observability] {domain}.{operation} skipped expected config-load timeout: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ConfigReadIoFailure => {
|
||
// OS refused to read an existing config.toml (ACL-denied, locked by
|
||
// another process, OneDrive placeholder). User-environment state —
|
||
// we cannot make the file readable, and the same poll re-reports it
|
||
// every cycle (TAURI-RUST-DME). Demote at `warn!` so it stays in the
|
||
// local log for support without paging on every poll.
|
||
// Metadata-only: the raw message embeds the absolute config path
|
||
// (username / home dir). Keep this arm PII-free like the other
|
||
// path-sensitive demotions — domain/operation/kind are enough to
|
||
// see the condition without leaking the path into local logs.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "config_read_io_failure",
|
||
"[observability] {domain}.{operation} skipped expected config-read io failure (OS access denied/locked)"
|
||
);
|
||
}
|
||
ExpectedErrorKind::SubconsciousSchemaUnavailable => {
|
||
// Host-filesystem condition: SQLite couldn't open the subconscious
|
||
// DB file (CANTOPEN / xShmMap). The WAL-fallback in
|
||
// `subconscious::store` already prevents the shared-memory variant;
|
||
// what reaches here is a genuine local open failure the user must
|
||
// fix on their machine (permissions, remount, free the volume) —
|
||
// Sentry has no remediation path. Demote at `warn!` so a sustained
|
||
// spike still shows in operator dashboards without turning every
|
||
// affected session into a Sentry error event. Drops TAURI-RUST-8WM.
|
||
// Do not include the raw `message`: it can embed the absolute
|
||
// subconscious DB path (home dir / username). Mirror the
|
||
// metadata-only demotions (`DiskFull`, `FilesystemUserPathInvalid`)
|
||
// and log only domain/operation/kind — no PII in the breadcrumb.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "subconscious_schema_unavailable",
|
||
"[observability] {domain}.{operation} skipped expected subconscious schema DB-unavailable error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::CodexCliAuthUnavailable => {
|
||
// User-state condition: the Codex CLI login at `~/.codex/auth.json`
|
||
// is missing / unparseable / has no tokens. The import RPC already
|
||
// returned the actionable "Run `codex login` first" string and the
|
||
// UI surfaces it inline — Sentry has nothing to act on. Demote at
|
||
// `info!` (mirrors `LocalAiBinaryMissing`). Do NOT include the raw
|
||
// `message`: it embeds the absolute `~/.codex/auth.json` path
|
||
// (home dir / username). Log only domain/operation/kind — no PII.
|
||
tracing::info!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "codex_cli_auth_unavailable",
|
||
"[observability] {domain}.{operation} skipped expected codex-cli auth-unavailable error"
|
||
);
|
||
}
|
||
ExpectedErrorKind::BackendErrorCodeOwned => {
|
||
// Managed-backend `errorCode` (#870) re-report — the backend owns
|
||
// this error (already paged its own 5xx, or expected user-state).
|
||
// The FE surfaces actionable copy via `classify_inference_error`;
|
||
// Sentry must not double-report (F2/F4). Demote at `warn!` so the
|
||
// breadcrumb retains the code for triage without spawning an event.
|
||
let code =
|
||
crate::openhuman::inference::provider::extract_backend_error_code_token(message)
|
||
.unwrap_or_default();
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "backend_error_code",
|
||
error_code = %code,
|
||
"[observability] {domain}.{operation} skipped backend-owned errorCode={code} error: {message}"
|
||
);
|
||
}
|
||
ExpectedErrorKind::ApprovalNoPendingRace => {
|
||
// Benign approval race (TAURI-RUST-5EH): the request_id was already
|
||
// decided / lazily expired / superseded — `store::get_decision`
|
||
// confirmed a persisted decision exists, so this is a double-tap,
|
||
// two-operator, or expiry-while-live race classified benign by the
|
||
// inline-approvals design spec, not a lost registration. Demote at
|
||
// `warn!` so a sustained spike still shows in operator dashboards
|
||
// without paging. The genuine never-registered case takes the
|
||
// capture path (distinct wording) and stays a Sentry signal.
|
||
tracing::warn!(
|
||
domain = domain,
|
||
operation = operation,
|
||
kind = "approval_no_pending_race",
|
||
"[observability] {domain}.{operation} skipped benign already-decided/expired approval race: {message}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Distinct `tracing::Metadata::target()` we set on the diagnostic
|
||
/// `tracing::error!` emitted from [`report_error_message`].
|
||
///
|
||
/// Sentry capture for this helper happens via an explicit
|
||
/// `sentry::capture_message` call below — not via the `sentry-tracing`
|
||
/// layer scooping up the `tracing::error!` event. The production
|
||
/// `sentry_tracing_layer()` in `core::logging` filters events with this
|
||
/// target to `EventFilter::Ignore` so we never double-report (one direct
|
||
/// `capture_message`, one tracing-bridge capture of the same condition).
|
||
///
|
||
/// Why direct capture instead of relying on the bridge: the bridge worked
|
||
/// in steady-state but flaked under parallel test scheduling
|
||
/// (`thread_not_found_rpc_error_does_not_report_to_sentry` repeatedly hit
|
||
/// `events.len() == 0` in CI even with a thread-default subscriber wired
|
||
/// up — likely a Linux-only thread-local ordering quirk in
|
||
/// `sentry-tracing`'s `Hub::current()` lookup at event-emit time). Direct
|
||
/// `sentry::capture_message` synchronously routes through the active hub
|
||
/// and is deterministic, which keeps both production reporting and tests
|
||
/// honest.
|
||
pub const REPORT_ERROR_TRACING_TARGET: &str = "openhuman::observability::report_error";
|
||
|
||
pub(crate) fn report_error_message(
|
||
message: &str,
|
||
domain: &str,
|
||
operation: &str,
|
||
extra: &[Tag<'_>],
|
||
) {
|
||
sentry::with_scope(
|
||
|scope| {
|
||
scope.set_tag("domain", domain);
|
||
scope.set_tag("operation", operation);
|
||
for (k, v) in extra {
|
||
scope.set_tag(k, v);
|
||
}
|
||
},
|
||
|| {
|
||
// Direct, synchronous Sentry capture — see
|
||
// `REPORT_ERROR_TRACING_TARGET` for why we don't rely on the
|
||
// `sentry-tracing` layer for this call site.
|
||
sentry::capture_message(message, sentry::Level::Error);
|
||
// Diagnostic log line for stderr / file appenders. Tagged with
|
||
// the marker target so the production sentry-tracing layer
|
||
// skips it (no double Sentry event).
|
||
tracing::error!(
|
||
target: REPORT_ERROR_TRACING_TARGET,
|
||
domain = domain,
|
||
operation = operation,
|
||
error = %message,
|
||
"[observability] {domain}.{operation} failed: {message}"
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Capture a message to Sentry at **warning** severity with structured tags.
|
||
///
|
||
/// Mirror of [`report_error_message`] but at `sentry::Level::Warning`: the
|
||
/// event is still recorded in Sentry (so it stays available for triage and
|
||
/// dashboards) while warning-severity events do not trip the error-rate
|
||
/// alert/paging rules that `Level::Error` events do (see the
|
||
/// `sentry_tracing_layer` mapping in `core::logging`, where `ERROR` becomes a
|
||
/// captured `Event` and `WARN`/`INFO` only a `Breadcrumb`). Use this for
|
||
/// transport-boundary conditions worth seeing in aggregate that are never an
|
||
/// actionable core defect — e.g. unrecognised RPC method names (#3567).
|
||
///
|
||
/// Like [`report_error_message`], capture is an explicit, synchronous
|
||
/// `sentry::capture_message` rather than the `sentry-tracing` bridge; the
|
||
/// accompanying diagnostic line is tagged with [`REPORT_ERROR_TRACING_TARGET`]
|
||
/// so the production layer ignores it and we never double-report.
|
||
pub(crate) fn report_warning_message(
|
||
message: &str,
|
||
domain: &str,
|
||
operation: &str,
|
||
extra: &[Tag<'_>],
|
||
) {
|
||
sentry::with_scope(
|
||
|scope| {
|
||
scope.set_tag("domain", domain);
|
||
scope.set_tag("operation", operation);
|
||
for (k, v) in extra {
|
||
scope.set_tag(k, v);
|
||
}
|
||
},
|
||
|| {
|
||
sentry::capture_message(message, sentry::Level::Warning);
|
||
tracing::warn!(
|
||
target: REPORT_ERROR_TRACING_TARGET,
|
||
domain = domain,
|
||
operation = operation,
|
||
message = %message,
|
||
"[observability] {domain}.{operation} warning: {message}"
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Returns true when a Sentry event is a per-attempt provider HTTP failure
|
||
/// that the reliable-provider layer already handles via retry + fallback.
|
||
///
|
||
/// The primary suppression lives at the call site
|
||
/// (`openhuman::inference::provider::ops::should_report_provider_http_failure`),
|
||
/// which short-circuits transient codes before `report_error` ever fires.
|
||
/// This helper is intended for use inside the `sentry::ClientOptions`
|
||
/// `before_send` hook as defense-in-depth — it catches any future call
|
||
/// site that emits a `tracing::error!` with the same shape but bypasses
|
||
/// the classifier.
|
||
///
|
||
/// Match criteria (all required):
|
||
/// - tag `domain == "llm_provider"` — pins the filter to provider-originated
|
||
/// events so an unrelated subsystem emitting `failure=non_2xx`/`status=503`
|
||
/// for its own reasons doesn't get silently dropped
|
||
/// - tag `failure == "non_2xx"` (the marker set by `ops::api_error`)
|
||
/// - tag `status` parses to one of [`TRANSIENT_PROVIDER_HTTP_STATUSES`]
|
||
pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("llm_provider") {
|
||
return false;
|
||
}
|
||
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
|
||
return false;
|
||
}
|
||
let Some(status_u16) = tags.get("status").and_then(|s| s.parse::<u16>().ok()) else {
|
||
return false;
|
||
};
|
||
TRANSIENT_PROVIDER_HTTP_STATUSES.contains(&status_u16)
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for managed-backend `errorCode`
|
||
/// events (#870): drops any Sentry event whose message/exception text carries a
|
||
/// backend `errorCode` that the backend owns (F2/F4) — *except* a backend-flagged
|
||
/// malformed `BAD_REQUEST`, which the client caused and so still pages (F8).
|
||
///
|
||
/// Primary suppression lives at the emit sites (`api_error` /
|
||
/// `compatible_*` streaming gates) and at the higher-layer re-report
|
||
/// classifier (`expected_error_kind` → [`ExpectedErrorKind::BackendErrorCodeOwned`]).
|
||
/// This catches any future call site that re-emits the same flattened error
|
||
/// without routing through those funnels. Delegates the decision to the
|
||
/// single-source [`crate::openhuman::inference::provider::managed_error_skips_sentry`]
|
||
/// (managed-envelope gated, so a BYO payload carrying an `errorCode`-shaped
|
||
/// field is not wrongly dropped) so the layers can't drift.
|
||
pub fn is_backend_error_code_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let direct = event.message.as_deref();
|
||
let from_logentry = event.logentry.as_ref().map(|log| log.message.as_str());
|
||
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
|
||
[direct, from_logentry, from_exception]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(crate::openhuman::inference::provider::managed_error_skips_sentry)
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for transient streaming **transport**
|
||
/// failures (F7): drops `domain=llm_provider, failure=transport` events whose
|
||
/// body is a transient transport phrase (timeout / reset / TLS-handshake EOF /
|
||
/// "error sending request"). Flaky-network blips on the streaming send are
|
||
/// recovered by retry/fallback and carry no actionable Sentry signal; a
|
||
/// non-transient transport failure (DNS misconfig, unexpected protocol error)
|
||
/// is not matched and still pages.
|
||
///
|
||
/// Primary gate lives at the two streaming emit sites in
|
||
/// `compatible_provider_impl.rs` (`stream_chat` / `stream_chat_history`); this
|
||
/// catches any future site that reports the same shape without gating.
|
||
///
|
||
/// Scoped to the **streaming** operations on purpose (CodeRabbit): only the
|
||
/// `stream_chat` / `stream_chat_history` transport emits are meant to be
|
||
/// suppressed. A non-streaming `domain=llm_provider, failure=transport` event
|
||
/// carries a different `operation` tag and must keep paging, so the
|
||
/// observability blind spot stays as narrow as F7 intends.
|
||
pub fn is_transient_provider_transport_failure(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("llm_provider") {
|
||
return false;
|
||
}
|
||
if tags.get("failure").map(String::as_str) != Some("transport") {
|
||
return false;
|
||
}
|
||
if !matches!(
|
||
tags.get("operation").map(String::as_str),
|
||
Some("stream_chat") | Some("stream_chat_history")
|
||
) {
|
||
return false;
|
||
}
|
||
event_has_transient_transport_phrase(event)
|
||
}
|
||
|
||
/// Defense-in-depth filter for aggregate provider exhaustion events where the
|
||
/// aggregate only restates transient attempt failures.
|
||
///
|
||
/// Keep ordinary `failure=all_exhausted` events: they are the useful "every
|
||
/// fallback failed" signal. Drop only the narrow shape observed in #3542,
|
||
/// where the aggregate body starts with the reliable-provider exhaustion
|
||
/// prefix and contains transient HTTP/transport wording already classified by
|
||
/// [`is_transient_message_failure`].
|
||
pub fn is_all_transient_provider_exhaustion_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("llm_provider") {
|
||
return false;
|
||
}
|
||
if tags.get("failure").map(String::as_str) != Some("all_exhausted") {
|
||
return false;
|
||
}
|
||
|
||
let direct = event.message.as_deref();
|
||
let from_logentry = event.logentry.as_ref().map(|log| log.message.as_str());
|
||
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
|
||
[direct, from_logentry, from_exception]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(all_provider_attempts_are_transient)
|
||
}
|
||
|
||
fn all_provider_attempts_are_transient(message: &str) -> bool {
|
||
let Some(attempts) = message.strip_prefix("All providers/models failed. Attempts:") else {
|
||
return false;
|
||
};
|
||
let mut saw_attempt = false;
|
||
for attempt in attempts.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||
saw_attempt = true;
|
||
if !is_transient_message_failure(attempt) {
|
||
return false;
|
||
}
|
||
}
|
||
saw_attempt
|
||
}
|
||
|
||
/// Returns true when a Sentry event's message/exception text contains the
|
||
/// canonical max-tool-iterations cap phrase (see
|
||
/// `openhuman::agent::error::MAX_ITERATIONS_ERROR_PREFIX`).
|
||
///
|
||
/// Defense-in-depth filter for the Sentry `before_send` hook: the primary
|
||
/// suppression lives at the call sites in `agent::harness::session::
|
||
/// runtime::run_single`, `channels::runtime::dispatch`, and
|
||
/// `channels::providers::web::run_chat_task`, all of which now skip
|
||
/// `report_error` when this variant is detected. This filter catches any
|
||
/// future call site that re-emits the message without going through those
|
||
/// funnels — e.g. a new wrapper that calls `tracing::error!` directly with
|
||
/// the typed error rendering — and keeps OPENHUMAN-TAURI-99 / -98
|
||
/// permanently off Sentry without requiring touch-ups at each new site.
|
||
///
|
||
/// Match strategy: scans `event.message` first (the path used by
|
||
/// `report_error_message` → `sentry::capture_message`) and falls back to
|
||
/// the last exception's `value` (the shape `sentry-tracing` produces when
|
||
/// stacktraces are attached). Both fields are checked for the canonical
|
||
/// prefix so the filter stays robust to future Sentry plumbing changes.
|
||
pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let direct = event.message.as_deref();
|
||
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
|
||
[direct, from_exception]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(crate::openhuman::agent::error::is_max_iterations_error)
|
||
}
|
||
|
||
/// Tag + body classifier for the `before_send` chain — drops Sentry events
|
||
/// emitted at the OpenHuman backend / rpc layers for "401 Session
|
||
/// expired" or the pre-flight "no session token stored" guards.
|
||
///
|
||
/// Pairs with [`is_session_expired_message`] (which classifies the
|
||
/// message body at the emit site via `report_error_or_expected`). This
|
||
/// fn runs in `before_send` so it catches any future call site that
|
||
/// re-emits the same shape without routing through the classifier —
|
||
/// keeps OPENHUMAN-TAURI-25 / -1Q / -27 / -1G permanently off Sentry
|
||
/// (~185 events/day combined).
|
||
///
|
||
/// Scope: only the three domains that surface session-expired today
|
||
/// (`llm_provider`, `backend_api`, `rpc`). Composio's OAuth-state 401
|
||
/// is excluded — that's actionable and must reach Sentry.
|
||
pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
let Some(domain) = tags.get("domain").map(String::as_str) else {
|
||
return false;
|
||
};
|
||
if !matches!(domain, "llm_provider" | "backend_api" | "rpc") {
|
||
return false;
|
||
}
|
||
|
||
let status_is_401 = tags
|
||
.get("status")
|
||
.and_then(|s| s.parse::<u16>().ok())
|
||
.is_some_and(|code| code == 401);
|
||
|
||
let direct = event.message.as_deref();
|
||
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
|
||
let body_matches = [direct, from_exception]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(is_session_expired_message);
|
||
|
||
if status_is_401 && body_matches {
|
||
return true;
|
||
}
|
||
|
||
// Pre-flight rpc guard has no status tag — accept on body alone,
|
||
// scoped to the rpc dispatcher (other domains don't emit the
|
||
// "no session token stored" sentinel).
|
||
if domain == "rpc" && body_matches {
|
||
return true;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for opaque `openhuman.auth_get_me`
|
||
/// RPC failures whose message body has been collapsed to just the bare
|
||
/// HTTP method + path (`"GET /auth/me"`) with no underlying transport error.
|
||
///
|
||
/// Pairs with the primary fix at `openhuman::credentials::ops::auth_get_me`,
|
||
/// which replaced `e.to_string()` with `format!("{e:#}")` so the full
|
||
/// `anyhow` context chain reaches the rpc dispatcher. Before that
|
||
/// fix, every transient network failure under this RPC — reqwest timeout,
|
||
/// connection reset, TLS handshake EOF, DNS hiccup — fingerprinted to one
|
||
/// opaque "GET /auth/me" Sentry group (TAURI-RUST-10, ~409 events / 17
|
||
/// users) because `is_transient_message_failure` could not see the
|
||
/// stripped transport phrases.
|
||
///
|
||
/// This filter is the catch-all if anyone re-introduces the same anyhow
|
||
/// `.to_string()` collapse at another call site that eventually reaches
|
||
/// `report_error_or_expected` with the same shape, OR if the existing fix
|
||
/// regresses. Genuine `auth_get_me` errors that carry the underlying
|
||
/// context chain (`"GET /auth/me: error sending request for url (...): …"`)
|
||
/// still page — only the bare path-only body is dropped.
|
||
///
|
||
/// Match criteria (all required):
|
||
/// - tag `domain == "rpc"`
|
||
/// - tag `operation == "invoke_method"`
|
||
/// - tag `method == "openhuman.auth_get_me"`
|
||
/// - `event.message` (or last exception `value`) trims to **exactly**
|
||
/// `"GET /auth/me"` — strict equality, not `contains`, so a body with
|
||
/// the chain appended still surfaces.
|
||
pub fn is_auth_get_me_opaque_transport_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("rpc") {
|
||
return false;
|
||
}
|
||
if tags.get("operation").map(String::as_str) != Some("invoke_method") {
|
||
return false;
|
||
}
|
||
if tags.get("method").map(String::as_str) != Some("openhuman.auth_get_me") {
|
||
return false;
|
||
}
|
||
|
||
const OPAQUE_BODY: &str = "GET /auth/me";
|
||
let direct = event.message.as_deref();
|
||
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
|
||
[direct, from_exception]
|
||
.into_iter()
|
||
.flatten()
|
||
.any(|body| body.trim() == OPAQUE_BODY)
|
||
}
|
||
|
||
pub fn is_transient_http_status(status: &str) -> bool {
|
||
TRANSIENT_HTTP_STATUSES.contains(&status)
|
||
}
|
||
|
||
pub fn is_transient_http_status_code(status: u16) -> bool {
|
||
let status = status.to_string();
|
||
is_transient_http_status(status.as_str())
|
||
}
|
||
|
||
pub fn contains_transient_transport_phrase(message: &str) -> bool {
|
||
let lower = message.to_ascii_lowercase();
|
||
TRANSIENT_TRANSPORT_PHRASES
|
||
.iter()
|
||
.any(|phrase| lower.contains(phrase))
|
||
}
|
||
|
||
pub fn is_updater_transient_http_status(status: u16) -> bool {
|
||
UPDATER_TRANSIENT_HTTP_STATUSES.contains(&status)
|
||
}
|
||
|
||
pub fn is_updater_transient_message(message: &str) -> bool {
|
||
let lower = message.to_ascii_lowercase();
|
||
UPDATER_TRANSIENT_MESSAGE_PHRASES
|
||
.iter()
|
||
.any(|phrase| lower.contains(phrase))
|
||
}
|
||
|
||
fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> bool {
|
||
event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(contains_transient_transport_phrase)
|
||
|| event
|
||
.logentry
|
||
.as_ref()
|
||
.is_some_and(|log| contains_transient_transport_phrase(&log.message))
|
||
|| event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(contains_transient_transport_phrase)
|
||
})
|
||
}
|
||
|
||
fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> bool {
|
||
event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(is_updater_transient_message)
|
||
|| event
|
||
.logentry
|
||
.as_ref()
|
||
.is_some_and(|log| is_updater_transient_message(&log.message))
|
||
|| event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(is_updater_transient_message)
|
||
})
|
||
}
|
||
|
||
fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool {
|
||
matches!(
|
||
event.tags.get("domain").map(String::as_str),
|
||
Some("update") | Some("update.check_releases") | Some("updater")
|
||
)
|
||
}
|
||
|
||
fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some(domain) {
|
||
return false;
|
||
}
|
||
|
||
match tags.get("failure").map(String::as_str) {
|
||
Some("non_2xx") => tags
|
||
.get("status")
|
||
.is_some_and(|status| is_transient_http_status(status)),
|
||
Some("transport") => event_has_transient_transport_phrase(event),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Transient backend API failures (gateway hiccups, scheduled downtime).
|
||
/// Match by event tags written by report_error at the authed_json call site.
|
||
pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> bool {
|
||
is_transient_domain_failure(event, "backend_api")
|
||
}
|
||
|
||
/// Transient integrations / Composio failures (timeout, connection reset,
|
||
/// gateway hiccups).
|
||
///
|
||
/// Accepts both `domain="integrations"` (the shared
|
||
/// [`crate::openhuman::integrations::IntegrationClient`] HTTP wrapper that
|
||
/// fronts every backend-proxied integration) and `domain="composio"` (errors
|
||
/// reported from the Composio op layer in
|
||
/// [`crate::openhuman::composio::ops`]). Composio routes through the same
|
||
/// `IntegrationClient`, so the failure shape is identical — but op-level
|
||
/// reporters that wrap and re-emit those errors with their own domain tag
|
||
/// would otherwise escape the integrations-scoped filter (OPENHUMAN-TAURI-35
|
||
/// ~139ev, -2H ~26ev: `[composio] list_connections failed: Backend returned
|
||
/// 502 …` events that landed in Sentry under `domain=composio`).
|
||
pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> bool {
|
||
is_transient_domain_failure(event, "integrations")
|
||
|| is_transient_domain_failure(event, "composio")
|
||
}
|
||
|
||
/// Skill-install fetch **client errors** (4xx, esp. 404/410).
|
||
///
|
||
/// `install_workflow_from_url_with_home` fetches a user/catalog-supplied
|
||
/// `SKILL.md`; a 4xx means the requested URL is gone or wrong — expected
|
||
/// user-input state surfaced to the UI as "skill not found", not a
|
||
/// Sentry-actionable defect. The primary suppression lives at that emit site
|
||
/// (it no longer calls `report_error` for 4xx); this is the defense-in-depth
|
||
/// net mirroring the `is_transient_*` filters, catching any future skills call
|
||
/// site that reports a 4xx. Matched by the tags `report_error` writes:
|
||
/// `domain=skills`, `failure=non_2xx`, and a 4xx `status`. A 5xx is a genuine
|
||
/// remote failure and stays reportable. Drops TAURI-RUST-CGE (~1,446 events /
|
||
/// 72 users on `openhuman@0.57.53`).
|
||
pub fn is_skills_install_client_error_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("skills") {
|
||
return false;
|
||
}
|
||
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
|
||
return false;
|
||
}
|
||
tags.get("status")
|
||
.and_then(|status| status.parse::<u16>().ok())
|
||
.is_some_and(|code| (400..500).contains(&code))
|
||
}
|
||
|
||
/// Transient updater failures from GitHub release probes/downloads.
|
||
///
|
||
/// Core-side reports carry structured tags (`domain=update`, often
|
||
/// `operation=check_releases`, plus `failure/status`). Tauri's updater plugin
|
||
/// can also emit message-only events such as
|
||
/// `"failed to check for updates: error sending request for url (...latest.json)"`.
|
||
/// Match both shapes, but never drop an arbitrary update-domain event unless
|
||
/// it also has a transient status/transport marker.
|
||
pub fn is_updater_transient_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
if event_has_updater_transient_message(event) {
|
||
return true;
|
||
}
|
||
|
||
if !event_has_updater_domain(event) {
|
||
return false;
|
||
}
|
||
|
||
match event.tags.get("failure").map(String::as_str) {
|
||
Some("non_2xx") => event
|
||
.tags
|
||
.get("status")
|
||
.and_then(|status| status.parse::<u16>().ok())
|
||
.is_some_and(is_updater_transient_http_status),
|
||
Some("transport") => event_has_transient_transport_phrase(event),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// String tokens that mark a formatted error message as a transient HTTP
|
||
/// failure. Used at upstream emit sites (`rpc.invoke_method`,
|
||
/// `web_channel.run_chat_task`) where the error has already been stringified
|
||
/// and the original `status` / `failure` tag context is gone.
|
||
///
|
||
/// Each token combines a status code with a non-numeric anchor (parenthesis
|
||
/// or canonical reason phrase) so bare numeric coincidences ("process 502
|
||
/// exited") do not match.
|
||
const TRANSIENT_STATUS_MESSAGE_TOKENS: &[&str] = &[
|
||
"(408 ",
|
||
"(429 ",
|
||
"(502 ",
|
||
"(503 ",
|
||
"(504 ",
|
||
"(520 ",
|
||
"408 request timeout",
|
||
"429 too many requests",
|
||
"502 bad gateway",
|
||
"503 service unavailable",
|
||
"504 gateway timeout",
|
||
"520 <unknown status code>",
|
||
];
|
||
|
||
/// Returns true when a formatted error message describes a transient HTTP
|
||
/// or transport-layer failure that has already been demoted further down the
|
||
/// stack. Use at upstream re-emit sites (`rpc.invoke_method`,
|
||
/// `web_channel.run_chat_task`) where `report_error` is called with the
|
||
/// stringified downstream error and no `failure` / `status` tag context.
|
||
pub fn is_transient_message_failure(msg: &str) -> bool {
|
||
let lower = msg.to_ascii_lowercase();
|
||
TRANSIENT_STATUS_MESSAGE_TOKENS
|
||
.iter()
|
||
.any(|token| lower.contains(token))
|
||
|| contains_transient_transport_phrase(&lower)
|
||
}
|
||
|
||
/// Returns true when a Sentry event is a budget-exhausted 400 that should be
|
||
/// dropped from `before_send`.
|
||
///
|
||
/// Match criteria (all required):
|
||
/// - tag `failure == "non_2xx"`
|
||
/// - tag `status == "400"`
|
||
/// - the event message or any exception value contains one of the tight
|
||
/// budget-exhaustion phrases
|
||
///
|
||
/// Note: `domain` is intentionally not gated here as defense-in-depth over
|
||
/// the emit-site classifier — any non_2xx/400 event that carries the
|
||
/// budget-exhausted phrasing is dropped regardless of which domain produced
|
||
/// it, so a future re-emitter under a different tag still gets filtered.
|
||
pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
|
||
return false;
|
||
}
|
||
if tags.get("status").map(String::as_str) != Some("400") {
|
||
return false;
|
||
}
|
||
event_contains_budget_exhausted_message(event)
|
||
}
|
||
|
||
/// Whether a raw error / message string is a provider **insufficient-credits
|
||
/// 402** — the BYO account (e.g. OpenRouter) genuinely lacks the balance to
|
||
/// satisfy the request. Anchored on BOTH a 402-status shape AND a credit
|
||
/// phrase, so a bare `402` (or a non-402 error whose body merely contains the
|
||
/// digits `402`) is not swallowed and keeps reaching Sentry.
|
||
///
|
||
/// Single source of truth shared by the message-level cron halt
|
||
/// (`openhuman::cron::scheduler`'s `is_insufficient_credits_failure`, which
|
||
/// stops retrying a permanent 402 and skips its `report_error`) and the
|
||
/// event-level `before_send` filter [`is_insufficient_credits_event`] — the
|
||
/// same split as [`is_session_expired_message`] ↔ [`is_session_expired_event`].
|
||
/// TAURI-RUST-514 / -C62.
|
||
pub fn is_insufficient_credits_message(text: &str) -> bool {
|
||
let lower = text.to_ascii_lowercase();
|
||
// Anchor the 402 to a status shape — the emit sites format the message
|
||
// as "<provider> API error (402 Payment Required): <body>". Matching a
|
||
// bare "402" would false-positive on body digits (e.g. a 400 error
|
||
// whose body says "can only afford 402 tokens"), which is NOT this
|
||
// user-state and must keep reaching Sentry.
|
||
let is_402_status = lower.contains("(402") || lower.contains("402 payment required");
|
||
if !is_402_status {
|
||
return false;
|
||
}
|
||
// Check the credit/balance signal against the BODY only. The status prefix
|
||
// "(402 Payment Required)" itself contains the phrase "payment required",
|
||
// which `body_indicates_insufficient_credits` matches — so feeding it the
|
||
// whole string would classify ANY 402 (even one whose body is an unrelated
|
||
// condition) as insufficient-credits and suppress it (codex P2 on #3913).
|
||
// Slice off everything up to and including the formatted "): " status
|
||
// separator first; fall back to the whole text when the separator is
|
||
// absent (non-standard shape) so a credit phrase there still matches.
|
||
let body = lower
|
||
.split_once("): ")
|
||
.map_or(lower.as_str(), |(_, body)| body);
|
||
crate::openhuman::inference::provider::body_indicates_insufficient_credits(body)
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for **insufficient-credits 402**
|
||
/// provider events (TAURI-RUST-C62): the user's own BYO provider account
|
||
/// (e.g. OpenRouter) is out of balance — a billing state OpenHuman has no
|
||
/// lever over once the request already caps `max_tokens`.
|
||
///
|
||
/// The primary emit-site demotion lives in the `Provider::chat()` native_chat
|
||
/// cascade (`is_provider_insufficient_credits_402`), but the compatible
|
||
/// provider reports the same failure from several other paths
|
||
/// (`chat_with_system`, `chat_with_history`, the streaming gates, and the
|
||
/// shared `api_error` helper) that don't run that cascade. This filter is the
|
||
/// single outermost net that catches all of them, keyed on the formatted
|
||
/// message rather than tags so it matches regardless of which path emitted it.
|
||
///
|
||
/// Match criteria (all required):
|
||
/// - the event message or any exception value names a 402 / payment-required
|
||
/// failure (`"402"` or `"payment required"`), AND
|
||
/// - that same text carries an insufficient-credits phrase
|
||
/// (`provider::body_indicates_insufficient_credits`).
|
||
pub fn is_insufficient_credits_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
if event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(is_insufficient_credits_message)
|
||
{
|
||
return true;
|
||
}
|
||
event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(is_insufficient_credits_message)
|
||
})
|
||
}
|
||
|
||
/// Message-level matcher for a provider **monthly-quota / usage-limit
|
||
/// exhausted** failure. Status-agnostic by design — unlike
|
||
/// [`is_insufficient_credits_message`] it does NOT anchor on a 402 status,
|
||
/// because the Kiro IDE proxy wraps its 402 inside a 500 envelope
|
||
/// (TAURI-RUST-C9A). Delegates to the single-source quota-phrase set in
|
||
/// [`crate::openhuman::inference::provider::body_indicates_quota_exhausted`], so
|
||
/// the emit-site guard and this `before_send` net can't drift. Shared with the
|
||
/// event-level filter [`is_quota_exhausted_event`].
|
||
pub fn is_quota_exhausted_message(text: &str) -> bool {
|
||
crate::openhuman::inference::provider::body_indicates_quota_exhausted(text)
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for provider **monthly-quota
|
||
/// exhausted** events (TAURI-RUST-C9A): the user's third-party plan has spent
|
||
/// its allotment for the period — a billing/plan state OpenHuman has no lever
|
||
/// over.
|
||
///
|
||
/// The primary emit-site demotion lives in the `Provider::chat()` native_chat
|
||
/// cascade and the shared `api_error` helper (`is_provider_quota_exhausted`),
|
||
/// but the compatible provider reports the same failure from several other
|
||
/// paths that don't run those guards. This filter is the single outermost net
|
||
/// that catches all of them, keyed on the formatted message rather than tags so
|
||
/// it matches regardless of which path emitted it (and regardless of whether
|
||
/// the upstream wrapped the 402 in a 500 envelope).
|
||
pub fn is_quota_exhausted_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
if event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(is_quota_exhausted_message)
|
||
{
|
||
return true;
|
||
}
|
||
event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(is_quota_exhausted_message)
|
||
})
|
||
}
|
||
|
||
/// Whether a raw error / message string is an Ollama **Cloud** hosted-inference
|
||
/// `500` (`Internal Server Error (ref: <uuid>)`). Matches either the raw emit
|
||
/// shape (`ollama API error (500 …): {"error":"Internal Server Error (ref: …)"}`)
|
||
/// or the actionable re-raise the emit sites swap in
|
||
/// (`is_ollama_cloud_internal_500_message`). The raw arm requires BOTH the
|
||
/// `ollama` provider name and the `internal server error (ref:` envelope, so a
|
||
/// generic 500 from another provider, or a local Ollama daemon crash (which
|
||
/// carries no `ref:` UUID), still reaches Sentry.
|
||
pub fn is_ollama_cloud_internal_500_message_any(text: &str) -> bool {
|
||
if crate::openhuman::inference::provider::is_ollama_cloud_internal_500_message(text) {
|
||
return true;
|
||
}
|
||
let lower = text.to_ascii_lowercase();
|
||
lower.contains("ollama") && lower.contains("internal server error (ref:")
|
||
}
|
||
|
||
/// Defense-in-depth `before_send` filter for **Ollama Cloud hosted-inference
|
||
/// 500s** (TAURI-RUST-5MV): ollama.com's `*:cloud` models intermittently
|
||
/// return an opaque `Internal Server Error (ref: <uuid>)` with no client lever
|
||
/// (non-deterministic, byte-identical request succeeds when healthy), retried +
|
||
/// fallen-back by the reliable-provider layer.
|
||
///
|
||
/// The primary demotion lives at the `native_chat` / `streaming_chat` /
|
||
/// `api_error` emit sites, and the agent re-report is demoted via
|
||
/// `expected_error_kind` → `TransientUpstreamHttp`. This is the single outermost
|
||
/// net for any other compatible-provider path (`chat_with_system`,
|
||
/// `chat_with_history`, the non-native cascades) that reports the same body,
|
||
/// keyed on the message rather than tags so it matches regardless of emitter.
|
||
pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
if event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(is_ollama_cloud_internal_500_message_any)
|
||
{
|
||
return true;
|
||
}
|
||
event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(is_ollama_cloud_internal_500_message_any)
|
||
})
|
||
}
|
||
|
||
/// 404 on PATCH/DELETE to a channel-message path is an expected backend state
|
||
/// (user deleted the message provider-side, backend GC'd the relay row). The
|
||
/// primary suppression lives in `authed_json` via `parse_message_path` +
|
||
/// defense-in-depth inline check. This filter is the outermost safety net for
|
||
/// any future call site that bypasses both. Targets OPENHUMAN-TAURI-R7.
|
||
///
|
||
/// Match criteria (all required):
|
||
/// - tag `domain == "backend_api"`
|
||
/// - tag `failure == "non_2xx"`
|
||
/// - tag `status == "404"`
|
||
/// - tag `method == "PATCH"` or `"DELETE"`
|
||
/// - event message or exception value contains both `"/channels/"` and `"/messages/"`
|
||
pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let tags = &event.tags;
|
||
if tags.get("domain").map(String::as_str) != Some("backend_api") {
|
||
return false;
|
||
}
|
||
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
|
||
return false;
|
||
}
|
||
if tags.get("status").map(String::as_str) != Some("404") {
|
||
return false;
|
||
}
|
||
let method = tags.get("method").map(String::as_str).unwrap_or("");
|
||
if method != "PATCH" && method != "DELETE" {
|
||
return false;
|
||
}
|
||
event_contains_channel_message_path(event)
|
||
}
|
||
|
||
fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> bool {
|
||
let has_pattern = |s: &str| s.contains("/channels/") && s.contains("/messages/");
|
||
if event.message.as_deref().is_some_and(has_pattern) {
|
||
return true;
|
||
}
|
||
event
|
||
.exception
|
||
.values
|
||
.iter()
|
||
.any(|exc| exc.value.as_deref().is_some_and(has_pattern))
|
||
}
|
||
|
||
fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool {
|
||
if event
|
||
.message
|
||
.as_deref()
|
||
.is_some_and(crate::openhuman::inference::provider::is_budget_exhausted_message)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
event.exception.values.iter().any(|exception| {
|
||
exception
|
||
.value
|
||
.as_deref()
|
||
.is_some_and(crate::openhuman::inference::provider::is_budget_exhausted_message)
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and
|
||
/// plain `&str` — the three shapes that show up at error sites today.
|
||
#[test]
|
||
fn report_error_accepts_common_error_shapes() {
|
||
let anyhow_err = anyhow::anyhow!("boom");
|
||
report_error(&anyhow_err, "test", "anyhow_shape", &[]);
|
||
|
||
let io_err = std::io::Error::other("io failed");
|
||
report_error(&io_err, "test", "io_shape", &[("kind", "io")]);
|
||
|
||
report_error("plain message", "test", "str_shape", &[]);
|
||
}
|
||
|
||
#[test]
|
||
fn anyhow_chain_is_rendered_in_full() {
|
||
// Regression guard: `err.to_string()` on an anyhow chain only emits
|
||
// the outermost context. Using `{:#}` joins every cause, which is
|
||
// what Sentry needs to actually diagnose wrapped failures.
|
||
let inner = std::io::Error::other("inner cause");
|
||
let wrapped = anyhow::Error::from(inner).context("outer ctx");
|
||
assert_eq!(format!("{wrapped:#}"), "outer ctx: inner cause");
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-5EH: the benign already-decided/expired approval race
|
||
/// (`rpc::approval_decide` after `get_decision` confirms a persisted
|
||
/// decision) must classify as `ApprovalNoPendingRace` so the ~1,995-event
|
||
/// flood is demoted — while a genuine never-registered id (distinct
|
||
/// "ever registered" wording) must stay reportable (`None`).
|
||
#[test]
|
||
fn classifies_benign_approval_race_but_keeps_genuine_loss() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: no pending approval found for request_id \
|
||
'4f1c…' (already decided or expired)"
|
||
),
|
||
Some(ExpectedErrorKind::ApprovalNoPendingRace),
|
||
"benign already-decided/expired race must be demoted"
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: no pending approval ever registered \
|
||
for request_id '4f1c…'"
|
||
),
|
||
None,
|
||
"a genuine lost registration must remain a Sentry signal"
|
||
);
|
||
}
|
||
|
||
/// Exercise the full demotion path (classifier -> report arm) for a benign
|
||
/// approval race: it must take the expected branch and not panic.
|
||
#[test]
|
||
fn report_error_or_expected_demotes_benign_approval_race() {
|
||
report_error_or_expected(
|
||
"no pending approval found for request_id 'abc' (already decided or expired)",
|
||
"rpc",
|
||
"invoke_method",
|
||
&[],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_expected_config_errors() {
|
||
assert_eq!(
|
||
expected_error_kind("rpc.invoke_method failed: local ai is disabled"),
|
||
Some(ExpectedErrorKind::LocalAiDisabled)
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent.provider_chat failed: ollama API key not set. Configure via the web UI"
|
||
),
|
||
Some(ExpectedErrorKind::ApiKeyMissing)
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("ollama embed failed with status 500"),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-83A: the Codex-CLI import user-state errors must
|
||
/// classify as `CodexCliAuthUnavailable` so the ~430-event flood stays out
|
||
/// of Sentry. Verbatim envelope shapes from
|
||
/// `openai_oauth::store::import_codex_cli_auth_from_path` (with a fake path
|
||
/// in place of the real `~/.codex/auth.json`).
|
||
#[test]
|
||
fn classifies_codex_cli_import_user_state_as_expected() {
|
||
for msg in [
|
||
"Could not read Codex CLI auth at /home/u/.codex/auth.json: No such file \
|
||
or directory. Run `codex login` first, then try Codex auth again.",
|
||
"Could not parse Codex CLI auth at /home/u/.codex/auth.json: expected value. \
|
||
Run `codex login` again, then try Codex auth again.",
|
||
"Codex CLI auth at /home/u/.codex/auth.json has no tokens. Run `codex login` first.",
|
||
"Codex CLI auth at /home/u/.codex/auth.json has no access token. Run `codex login` first.",
|
||
"home directory is not set; cannot find ~/.codex/auth.json",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::CodexCliAuthUnavailable),
|
||
"must classify as CodexCliAuthUnavailable: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Exercise the full demotion path (classifier -> report arm) for a codex
|
||
/// auth-unavailable message: it must take the expected branch and not panic.
|
||
#[test]
|
||
fn report_error_or_expected_demotes_codex_auth_unavailable() {
|
||
report_error_or_expected(
|
||
"Could not read Codex CLI auth at /home/u/.codex/auth.json: No such file \
|
||
or directory. Run `codex login` first, then try Codex auth again.",
|
||
"inference",
|
||
"openai_oauth_import_codex_cli",
|
||
&[],
|
||
);
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-CGP: a remote MCP server's connect-time 401 is
|
||
/// preventable user-state (the server needs OAuth sign-in), already handled
|
||
/// by the `needs_auth` UX (#3733 / #3719), so it must classify as
|
||
/// `McpServerNeedsAuth` and stay out of Sentry. The canonical body comes
|
||
/// straight from the typed `McpUnauthorizedError` `Display` impl (both the
|
||
/// bare and `resource metadata:` variants), plus the
|
||
/// `mcp_clients_connect`-prefixed RPC re-report shape that actually reaches
|
||
/// the dispatcher.
|
||
#[test]
|
||
fn classifies_mcp_connect_401_as_needs_auth() {
|
||
use crate::openhuman::mcp_client::McpUnauthorizedError;
|
||
|
||
let bare = McpUnauthorizedError {
|
||
endpoint: "https://youtube.run.tools".to_string(),
|
||
resource_metadata: None,
|
||
};
|
||
let with_meta = McpUnauthorizedError {
|
||
endpoint: "https://youtube.run.tools".to_string(),
|
||
resource_metadata: Some(
|
||
"https://youtube.run.tools/.well-known/oauth-protected-resource".to_string(),
|
||
),
|
||
};
|
||
for msg in [
|
||
bare.to_string(),
|
||
with_meta.to_string(),
|
||
// The stringified RPC re-report shape that propagates from
|
||
// `mcp_clients_connect` to `report_error_or_expected`.
|
||
format!("openhuman.mcp_clients_connect failed: {with_meta}"),
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(&msg),
|
||
Some(ExpectedErrorKind::McpServerNeedsAuth),
|
||
"must classify MCP connect 401 as McpServerNeedsAuth: {msg}"
|
||
);
|
||
}
|
||
// Full demotion path (classifier -> report arm) must not panic.
|
||
report_error_or_expected(
|
||
&bare.to_string(),
|
||
"rpc",
|
||
"openhuman.mcp_clients_connect",
|
||
&[],
|
||
);
|
||
}
|
||
|
||
/// Guard against over-suppression: an MCP transport failure that is NOT the
|
||
/// typed 401 (a 500, or a generic "unauthorized" with no MCP anchor) MUST
|
||
/// still reach Sentry (stay `None`) so a real defect isn't blinded.
|
||
#[test]
|
||
fn does_not_classify_other_mcp_or_401_errors_as_needs_auth() {
|
||
for msg in [
|
||
"MCP server `https://youtube.run.tools` returned HTTP 500: internal error",
|
||
"openhuman.mcp_clients_connect failed: connection refused",
|
||
"Unauthorized (HTTP 401) from some unrelated provider",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::McpServerNeedsAuth),
|
||
"must NOT classify as McpServerNeedsAuth: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Guard against over-suppression on the import path: a genuine
|
||
/// keyring/persist failure (`upsert_profile`) or an unrelated error carries
|
||
/// neither the `codex cli auth` nor the `.codex/auth.json` anchor and MUST
|
||
/// still reach Sentry (stay `None`) so a real defect isn't blinded.
|
||
#[test]
|
||
fn does_not_classify_codex_persist_failure_as_codex_auth_unavailable() {
|
||
for msg in [
|
||
"failed to write auth profile store: keyring error: access denied",
|
||
"Auth profile not found: provider:openai/oauth",
|
||
"unable to open database file",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::CodexCliAuthUnavailable),
|
||
"real-defect/unrelated error must NOT be demoted as codex auth-unavailable: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-8FQ: the OpenAI ChatGPT/Codex OAuth `token_expired`
|
||
/// 401 re-raised at the RPC boundary (`{provider} Responses API error: …`)
|
||
/// must classify as `ProviderUserState` so the re-report is demoted, not
|
||
/// just the emit-site event. A genuine bad-key 401 must stay reportable.
|
||
#[test]
|
||
fn classifies_openai_oauth_token_expired_as_provider_user_state() {
|
||
let bail = "openai Responses API error: {\"error\":{\"message\":\"Provided \
|
||
authentication token is expired. Please try signing in again.\",\
|
||
\"type\":null,\"code\":\"token_expired\"}}";
|
||
assert_eq!(
|
||
expected_error_kind(bail),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"OAuth token_expired re-report must be demoted at the RPC boundary"
|
||
);
|
||
// A real misconfigured key must NOT be swallowed by this arm.
|
||
let bad_key = "openai Responses API error: {\"error\":{\"code\":\"invalid_api_key\",\
|
||
\"message\":\"Incorrect API key provided.\"}}";
|
||
assert_ne!(
|
||
expected_error_kind(bad_key),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"a genuine bad-key 401 must remain reportable"
|
||
);
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-R4: the composio direct-mode factory bail must
|
||
/// classify as `ApiKeyMissing` so any residual emit (explicit
|
||
/// execute/authorize call with no key) stays out of Sentry. Uses the
|
||
/// verbatim wire shape from `client.rs`.
|
||
#[test]
|
||
fn classifies_composio_direct_no_api_key_as_api_key_missing() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[composio] list_connections: composio direct mode selected but no api key \
|
||
is configured (set via composio.set_api_key RPC or config.composio.api_key)"
|
||
),
|
||
Some(ExpectedErrorKind::ApiKeyMissing)
|
||
);
|
||
}
|
||
|
||
/// Sentry TAURI-RUST-52S: Cohere's hosted `/v2/embed` returns a 401 with
|
||
/// body `"no api key supplied"` when called with an empty bearer (BYO-key
|
||
/// embedding path, no key configured). Verbatim wire shape from the embed
|
||
/// client must classify as `ApiKeyMissing` so the 8.7k-event flood stays
|
||
/// out of Sentry even from clients that predate the call-site guard.
|
||
#[test]
|
||
fn classifies_cohere_missing_api_key_401_as_api_key_missing() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Cohere embed API error (401 Unauthorized): \
|
||
{\"id\":\"3176e92f-d18e-4019-bd43-314821504a61\",\
|
||
\"message\":\"no api key supplied\"}"
|
||
),
|
||
Some(ExpectedErrorKind::ApiKeyMissing)
|
||
);
|
||
}
|
||
|
||
/// TAURI-RUST-HCK — the single-source `is_api_key_unset_message` matcher
|
||
/// (consumed by the cron scheduler's halt-on-first classifier) must match
|
||
/// the VERBATIM wording emitted by the inference credential guard
|
||
/// (`credential_for_request`), so a wording drift fails CI instead of
|
||
/// silently re-opening the cron flood. It must NOT match a present-but-
|
||
/// rejected key (401 "Invalid API key") nor an ordinary provider error.
|
||
#[test]
|
||
fn is_api_key_unset_message_matches_verbatim_credential_guard_wording() {
|
||
assert!(is_api_key_unset_message(
|
||
"openrouter API key not set. Configure via the web UI or set the appropriate env var."
|
||
));
|
||
assert!(is_api_key_unset_message(
|
||
"Cohere embed API error (401 Unauthorized): {\"message\":\"no api key supplied\"}"
|
||
));
|
||
assert!(!is_api_key_unset_message(
|
||
"OpenAI API error (401 Unauthorized): invalid_api_key"
|
||
));
|
||
assert!(!is_api_key_unset_message(
|
||
"OpenHuman API error (500 Internal Server Error): {\"error\":\"Internal server error\"}"
|
||
));
|
||
}
|
||
|
||
/// Guard against over-suppression: a genuine BYO-key auth failure
|
||
/// (wrong/expired key the upstream rejected) is an actionable bug
|
||
/// shape and MUST still reach Sentry (stay `None`), not get demoted by
|
||
/// the new "no api key is configured" substring.
|
||
#[test]
|
||
fn does_not_classify_invalid_api_key_401_as_missing() {
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (401 Unauthorized): invalid_api_key"),
|
||
None
|
||
);
|
||
}
|
||
|
||
/// Task B (issue #2898): prove the canonical 429 error message produced by
|
||
/// the embedding clients is already classified as `TransientUpstreamHttp`
|
||
/// so Sentry events are suppressed even without backoff.
|
||
///
|
||
/// The `is_transient_upstream_http_message` matcher checks for
|
||
/// `"api error (429 "` (case-insensitive), which is present in both the
|
||
/// OpenAI and Cohere canonical error shapes.
|
||
#[test]
|
||
fn embedding_429_classifies_as_transient_upstream_http() {
|
||
// OpenAI/Voyage canonical shape (openai.rs emit site).
|
||
let msg = "Embedding API error (429 Too Many Requests): Rate limit exceeded.";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"OpenAI 429 must classify as TransientUpstreamHttp: {msg}"
|
||
);
|
||
|
||
// Cohere canonical shape (cohere.rs emit site).
|
||
let cohere_msg = "Cohere embed API error (429 Too Many Requests): rate limit exceeded.";
|
||
assert_eq!(
|
||
expected_error_kind(cohere_msg),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"Cohere 429 must classify as TransientUpstreamHttp: {cohere_msg}"
|
||
);
|
||
|
||
// After-cap bail shape from the retry loop (openai.rs).
|
||
let cap_msg =
|
||
"Embedding API error (429 Too Many Requests): rate limit exceeded after 3 retries";
|
||
assert_eq!(
|
||
expected_error_kind(cap_msg),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"retry-cap bail message must classify as TransientUpstreamHttp: {cap_msg}"
|
||
);
|
||
|
||
// After-cap bail shape from the retry loop (cohere.rs).
|
||
let cohere_cap_msg =
|
||
"Cohere embed API error (429 Too Many Requests): rate limit exceeded after 3 retries";
|
||
assert_eq!(
|
||
expected_error_kind(cohere_cap_msg),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"Cohere retry-cap bail message must classify as TransientUpstreamHttp: {cohere_cap_msg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_is_not_configured_messages() {
|
||
assert_eq!(
|
||
expected_error_kind("workspace path is not configured for this user"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("provider 'voyage' is not configured in settings"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_ollama_user_config_rejections() {
|
||
// TAURI-RUST-XS (~376 events): user pointed embedder at a chat /
|
||
// vision model id, sometimes with a temperature suffix like `@0.7`
|
||
// that Ollama parses as malformed.
|
||
for raw in [
|
||
// Canonical XS wire shape from
|
||
// `OllamaEmbedding::embed` non-2xx path on a 400 Bad Request.
|
||
r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}"#,
|
||
// Same shape with a temperature-suffix model id the user pasted
|
||
// into Settings → Embeddings → Ollama.
|
||
r#"ollama embed failed with status 400 Bad Request: {"error":"invalid model name: qwen3-vl:4b@0.7"}"#,
|
||
// OPENHUMAN-TAURI-MA — model not pulled (404 Not Found).
|
||
r#"ollama embed failed with status 404 Not Found: {"error":"model \"bge-m3\" not found, try pulling it first"}"#,
|
||
// OPENHUMAN-TAURI-KM — same shape, different model id + `:latest` tag.
|
||
r#"ollama embed failed with status 404 Not Found: {"error":"model \"nomic-embed-text:latest\" not found, try pulling it first"}"#,
|
||
// OPENHUMAN-TAURI-GX — daemon-unreachable opt-in state.
|
||
"ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session",
|
||
// TAURI-RUST-3X — 501-status model-does-not-support-embeddings.
|
||
r#"ollama embed failed with status 501 Not Implemented: {"error":"this model does not support embeddings"}"#,
|
||
// TAURI-RUST-8WA — 501-status daemon started without embed support.
|
||
// Exact wire body so a narrow-back to "this model …" fails CI.
|
||
r#"ollama embed failed with status 501 Not Implemented: {"error":"This server does not support embeddings. Start it with `--embeddings`"}"#,
|
||
// TAURI-RUST-3E — 401 unauthorized embed (auth required at ollama endpoint).
|
||
r#"ollama embed failed with status 401 Unauthorized: {"error": "unauthorized"}"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"should classify Ollama user-config rejection: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_embedding_backend_auth_failure() {
|
||
// TAURI-RUST-T (~4k events) — companion of TAURI-RUST-4K5: the
|
||
// OpenHuman backend rejected the embeddings worker's bearer
|
||
// token. Both the bare-status and parenthesised wire shapes
|
||
// must classify as SessionExpired so the FE re-login prompt
|
||
// fires (matches the contract introduced by #2786 and
|
||
// exercised by classifies_embedding_api_invalid_token_401_as_session_expired).
|
||
for raw in [
|
||
r#"Embedding API error 401 Unauthorized: {"success":false,"error":"Invalid token"}"#,
|
||
r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"should classify embedding backend auth failure as SessionExpired: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_embedding_model_does_not_exist_400_as_config_rejection() {
|
||
// TAURI-RUST-9SK (~2205 events / 1 user) — a chat model id pasted as the
|
||
// embeddings model. Verbatim OpenRouter wire body (bare "does not exist"
|
||
// + integer "code":400), plus the enriched form after the emit site
|
||
// appends the actionable remediation. Both must demote so the per-embed
|
||
// flood stays out of Sentry.
|
||
for raw in [
|
||
r#"Embedding API error (400 Bad Request): {"error":{"message":"Model nvidia/nemotron-3-super-120b-a12b does not exist","code":400}}"#,
|
||
"Embedding API error (400 Bad Request): {\"error\":{\"message\":\"Model nvidia/nemotron-3-super-120b-a12b does not exist\",\"code\":400}} — this model isn't an embeddings model; pick an embeddings-capable model in Settings → Memory",
|
||
r#"Embedding API error (400 Bad Request): {"error":{"message":"this model does not support embeddings"}}"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection),
|
||
"should classify embedding model-rejection 400 as config rejection: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_gemini_model_format_400_as_config_rejection() {
|
||
// TAURI-RUST-4SA (~4,494 events / 1 user) — a bare model id sent to
|
||
// Gemini's OpenAI-compat shim, which maps `/v1/embeddings` →
|
||
// `BatchEmbedContents` and demands `models/<name>`. Verbatim Gemini wire
|
||
// body plus the enriched form after the emit site appends the Gemini
|
||
// remediation, and the alternate `INVALID_ARGUMENT` + field-path shape.
|
||
// All must demote so the per-embed flood stays out of Sentry.
|
||
for raw in [
|
||
r#"Embedding API error (400 Bad Request): {"error":{"code":400,"message":"BatchEmbedContentsRequest.model: unexpected model name format","status":"INVALID_ARGUMENT"}}"#,
|
||
"Embedding API error (400 Bad Request): {\"error\":{\"code\":400,\"message\":\"BatchEmbedContentsRequest.model: unexpected model name format\",\"status\":\"INVALID_ARGUMENT\"}} — Gemini needs the embeddings model id in `models/<name>` form (e.g. `models/text-embedding-004`); fix it in Settings → Memory",
|
||
r#"Embedding API error (400 Bad Request): {"error":{"code":400,"message":"Invalid value at 'model'","status":"INVALID_ARGUMENT","details":[{"field":"BatchEmbedContentsRequest.model"}]}}"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection),
|
||
"should classify Gemini model-format 400 as config rejection: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_embedding_400s() {
|
||
// Polarity: a 400 that is NOT a model-rejection (e.g. oversized input)
|
||
// and any non-400 must keep reaching Sentry.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"Embedding API error (400 Bad Request): {"error":{"message":"input exceeds the maximum number of tokens"}}"#
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"Embedding API error (500 Internal Server Error): {"error":"model does not exist"}"#
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_invalid_token_messages() {
|
||
// Provider 401s with "invalid token" in the body but no
|
||
// `Embedding API error` prefix must keep reaching Sentry — they're
|
||
// not the same wire shape and may indicate real provider bugs.
|
||
assert_eq!(
|
||
expected_error_kind(r#"openai chat failed 401: {"error":"invalid token"}"#),
|
||
None
|
||
);
|
||
// Embedding error without 401 must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"Embedding API error 500 Internal Server Error: {"error":"invalid token signature service down"}"#
|
||
),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_ollama_errors_as_user_config() {
|
||
// Unrelated 500 — server-side ollama bug must still reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind("ollama embed failed with status 500"),
|
||
None
|
||
);
|
||
// Parse-failure on the response — real bug in either the server
|
||
// or our deserializer, must still reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"ollama embed response parse failed: invalid type: expected sequence"
|
||
),
|
||
None
|
||
);
|
||
// Dimension mismatch — real bug (model dims don't match what we
|
||
// recorded), must still reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"ollama embed dimension mismatch at index 0: expected 768, got 1024"
|
||
),
|
||
None
|
||
);
|
||
// Unrelated `invalid model name` outside Ollama embed call —
|
||
// anchor on the `ollama embed` prefix keeps this from being silenced.
|
||
assert_eq!(
|
||
expected_error_kind("provider config validation failed: invalid model name"),
|
||
None
|
||
);
|
||
// Unrelated `model "…" not found` text without the `ollama embed`
|
||
// prefix — anchor keeps this from being silenced even when the
|
||
// exact MA/KM wire-shape substring appears in another context.
|
||
assert_eq!(
|
||
expected_error_kind(r#"provider listing failed: model \"foo\" not found in registry"#),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_local_ai_capability_unavailable_errors() {
|
||
// OPENHUMAN-TAURI-3B: surfaced by `local_ai_download_asset` when a
|
||
// user on a 0–4 GB RAM tier requests a vision asset. Both canonical
|
||
// wire shapes — emitted from `assets.rs` and `vision_embed.rs` —
|
||
// must classify as expected so they stop reaching Sentry.
|
||
for raw in [
|
||
"Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it.",
|
||
"vision summaries are unavailable for this RAM tier. Use OCR-only summarization or switch to a higher local AI tier.",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::LocalAiCapabilityUnavailable),
|
||
"should classify as local-ai capability unavailable: {raw}"
|
||
);
|
||
}
|
||
|
||
// Wrapped by the RPC dispatch layer as it reaches `report_error_or_expected`
|
||
// — the classifier is substring-based, so caller context must not defeat it.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or above to enable it."
|
||
),
|
||
Some(ExpectedErrorKind::LocalAiCapabilityUnavailable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_prompt_injection_blocked_errors() {
|
||
// OPENHUMAN-TAURI-140: ~1 480 events from `openhuman.agent_chat` where
|
||
// users' messages scored ≥ 0.45 on the injection heuristic. Both
|
||
// enforcement wire shapes must be classified as expected so they stop
|
||
// reaching Sentry.
|
||
for raw in [
|
||
"Prompt flagged for security review and was not processed. Please rephrase clearly.",
|
||
"Prompt blocked by security policy. Please rephrase without instruction overrides or exfiltration requests.",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::PromptInjectionBlocked),
|
||
"should classify as prompt-injection blocked: {raw}"
|
||
);
|
||
}
|
||
|
||
// Wrapped by the RPC dispatch layer — substring match must survive the prefix.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: Prompt flagged for security review and was not processed. Please rephrase clearly."
|
||
),
|
||
Some(ExpectedErrorKind::PromptInjectionBlocked)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_prompt_injection_blocked() {
|
||
// Must not silently swallow real security errors or generic "prompt" mentions.
|
||
assert_eq!(
|
||
expected_error_kind("prompt injection detected in tool arguments"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("security review required for deploy"),
|
||
None
|
||
);
|
||
}
|
||
|
||
// ── ContextWindowExceeded (TAURI-RUST-501) ─────────────────────────────
|
||
|
||
#[test]
|
||
fn classifies_context_window_exceeded_rereport() {
|
||
// TAURI-RUST-501: the custom-provider 500 body that escapes the
|
||
// provider api_error cascade's own status-gated checks. When the
|
||
// error is re-raised by `agent.run_single` / `web_channel.
|
||
// run_chat_task`, `report_error_or_expected` runs the classifier on
|
||
// the full message — this arm must catch the new phrasing.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom API error (500 Internal Server Error): \
|
||
{\"error\":{\"code\":500,\"message\":\"Context size has been exceeded.\",\"type\":\"server_error\"}}"
|
||
),
|
||
Some(ExpectedErrorKind::ContextWindowExceeded)
|
||
);
|
||
|
||
// The established phrasings the provider/reliable layer already
|
||
// recognized must classify here too (single-source matcher).
|
||
for raw in [
|
||
"OpenAI API error (400): This model's maximum context length is 8192 tokens",
|
||
"request exceeds the context window of this model",
|
||
"context length exceeded",
|
||
"prompt is too long",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ContextWindowExceeded),
|
||
"should classify as context-window-exceeded: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_lmstudio_n_keep_exceeds_n_ctx_rereport() {
|
||
// TAURI-RUST-6V0: the verbatim LM Studio 400 body — the un-evictable
|
||
// prefix (`n_keep`) is larger than the model's loaded context
|
||
// (`n_ctx`). When this 400 slips past the pre-dispatch guard and is
|
||
// re-raised by the agent/web_channel, `report_error_or_expected` must
|
||
// classify it as expected user-state so it stays out of Sentry.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"lmstudio API error (400 Bad Request): {\"error\":\"The number of tokens to keep from the initial prompt is greater than the context length (n_keep: 10978 >= n_ctx: 8192). Try to load the model with a larger context length, or provide a shorter input.\"}"
|
||
),
|
||
Some(ExpectedErrorKind::ContextWindowExceeded)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn context_prefix_too_large_error_display_classifies_as_expected() {
|
||
// S3.5.d coupling test: the pre-dispatch actionable error's Display
|
||
// string MUST classify as the suppressed ContextWindowExceeded bucket,
|
||
// so a wording drift in the user-facing message (which is what gets
|
||
// re-raised and re-reported up the stack) fails CI instead of silently
|
||
// leaking the event to Sentry.
|
||
let err = crate::openhuman::agent::harness::token_budget::ContextPrefixTooLargeError {
|
||
prefix_tokens: 10_978,
|
||
context_window: 8_192,
|
||
max_input_tokens: 7_372,
|
||
};
|
||
assert_eq!(
|
||
expected_error_kind(&err.to_string()),
|
||
Some(ExpectedErrorKind::ContextWindowExceeded),
|
||
"ContextPrefixTooLargeError Display must stay coupled to the \
|
||
context-window-exceeded classifier (drift would leak Sentry events)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_context_window_exceeded() {
|
||
// Anchors are context-overflow specific. A generic "window" or
|
||
// "context" mention, or an unrelated rate-limit "exceeded", must
|
||
// not classify.
|
||
for raw in [
|
||
"rate limit exceeded, retry after 30s",
|
||
"failed to open context menu window",
|
||
"tool call exceeded the allowed budget",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT classify as context-window-exceeded: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── FilesystemUserPathInvalid (TAURI-RUST-4QH) ─────────────────────────
|
||
|
||
#[test]
|
||
fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() {
|
||
// TAURI-RUST-4QH: verbatim wire shape from
|
||
// `openhuman::vault::ops::vault_create` line 37 when the
|
||
// user-picked vault folder doesn't resolve to an existing
|
||
// directory. Bubbles up as the RPC dispatcher's
|
||
// `display_message` and reaches `report_error_or_expected` —
|
||
// must classify so no Sentry event fires.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"root_path is not a directory: /Users/zadam/Documents/SndBrainOpenHuman"
|
||
),
|
||
Some(ExpectedErrorKind::FilesystemUserPathInvalid)
|
||
);
|
||
|
||
// The same body wrapped by the JSON-RPC dispatcher's `display_message`
|
||
// prefix (`rpc.invoke_method` re-emit shape from `src/core/jsonrpc.rs`).
|
||
// Must still classify so the dispatch-site re-report doesn't escape
|
||
// the matcher even if a future caller layers more context.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: root_path is not a directory: /Users/alice/openhuman-data"
|
||
),
|
||
Some(ExpectedErrorKind::FilesystemUserPathInvalid)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_http_host_hosted_path_not_a_directory_as_filesystem_user_path_invalid() {
|
||
// Preempt the symmetric shape from
|
||
// `openhuman::http_host::path_utils:23` —
|
||
// `"hosted path is not a directory: <path>"`. Not yet observed
|
||
// in Sentry but shares the same RPC validation polarity as
|
||
// vault_create's `root_path` check. Anchoring on
|
||
// `"path is not a directory:"` (with trailing colon) covers
|
||
// both without two separate matchers.
|
||
assert_eq!(
|
||
expected_error_kind("hosted path is not a directory: /var/www/static-site"),
|
||
Some(ExpectedErrorKind::FilesystemUserPathInvalid)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() {
|
||
// Polarity contract — the anchor requires a trailing colon
|
||
// after `"is not a directory"`, which discriminates user input
|
||
// (path follows the colon) from other shapes:
|
||
//
|
||
// 1. The `skills::ops_install:475` SAFETY GUARD —
|
||
// `"<path> is not a directory — refusing to remove"` — must
|
||
// stay actionable. It catches an `rm -rf` invariant violation
|
||
// (the target should have been a directory but wasn't),
|
||
// which is a code bug, not user input.
|
||
// 2. A narrative log line that happens to mention the phrase
|
||
// without the user-path colon suffix is not a validation
|
||
// failure and must not be silenced.
|
||
// 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR`
|
||
// renderings (`"Is a directory (os error 21)"`) is the
|
||
// inverse condition — different code path entirely.
|
||
for raw in [
|
||
// Safety guard — must NOT classify.
|
||
"/tmp/openhuman-cache is not a directory — refusing to remove",
|
||
// Narrative log line — must NOT classify.
|
||
"checked that path is not a directory before mkdir",
|
||
// Inverse condition (os error 21: EISDIR) — must NOT classify.
|
||
"open /etc/passwd failed: Is a directory (os error 21)",
|
||
// Bare path with no `directory` mention — must NOT classify.
|
||
"root_path must be absolute: ./relative/path",
|
||
// Generic body with the trailing colon but no known vault/http_host
|
||
// prefix — must NOT classify (future provider/storage errors that
|
||
// happen to embed "path is not a directory: ..." should reach Sentry).
|
||
"input config path is not a directory: /etc/foo",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── EmptyProviderResponse (TAURI-RUST-4Z1) ─────────────────────────────
|
||
|
||
#[test]
|
||
fn classifies_empty_provider_response_web_channel_rereport() {
|
||
// TAURI-RUST-4Z1: the web-channel re-report of the agent harness's
|
||
// empty-provider-response bail. `run_chat_task` wraps the flattened
|
||
// string and routes it through `report_error_or_expected` — the
|
||
// agent-layer typed suppression (PR #2790) can't reach it, so this
|
||
// string classifier must.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"run_chat_task failed client_id=l1uxaLd20_1mAdhp \
|
||
thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \
|
||
request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \
|
||
error=The model returned an empty response. Please try again."
|
||
),
|
||
Some(ExpectedErrorKind::EmptyProviderResponse)
|
||
);
|
||
|
||
// Bare user-facing string (the verbatim `turn.rs` emission), in case
|
||
// a different call site re-reports it without the run_chat_task wrap.
|
||
assert_eq!(
|
||
expected_error_kind("The model returned an empty response. Please try again."),
|
||
Some(ExpectedErrorKind::EmptyProviderResponse)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_empty_response_phrases() {
|
||
// Polarity contract: the anchor is `"model returned an empty
|
||
// response"`, NOT the looser `"empty response"`. The sibling paths
|
||
// below use different subjects or phrasings and are not user-facing
|
||
// failures — they must stay out of this bucket so a real regression
|
||
// in those paths still reaches Sentry.
|
||
for raw in [
|
||
// payload_summarizer.rs:261 — internal fall-through, not a failure.
|
||
"[payload_summarizer] summarizer returned empty response, falling through",
|
||
// subagent_runner/extract_tool.rs:379 — graceful empty extraction.
|
||
"[extract_from_result] provider returned an empty response; returning empty extraction",
|
||
// Generic mention without the model-subject anchor.
|
||
"warning: empty response body from health probe",
|
||
// channels/bus.rs:185 — channel-inbound graceful fallback (routes
|
||
// through report_error_or_expected; subject is "agent", not "model").
|
||
"[channel-inbound] agent returned empty response — finalizing draft with fallback",
|
||
// memory/query/walk.rs:292 — debug-level memory walk, not a failure.
|
||
"[memory_tree_walk] turn=3 LLM gave up (empty response)",
|
||
// learning/reflection.rs:576 — reflection skip, not a failure.
|
||
"[learning] reflection skipped (empty response — gate off or local AI unavailable)",
|
||
// agent/harness/session/turn.rs:811 — "provider returned an empty
|
||
// final response" uses subject "provider", not "model"; must not match.
|
||
"[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT classify as EmptyProviderResponse: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_memory_store_breaker_open() {
|
||
// TAURI-RUST-52X (~455 events on self-hosted Sentry): the chunk-store
|
||
// per-path circuit breaker tripped after consecutive SQLite init
|
||
// failures. The Windows wire shape is wrapped by
|
||
// `memory_tree::tree::rpc::pipeline_status_rpc`'s `chunk aggregates: …`
|
||
// context so the substring matcher must survive that prefix.
|
||
for raw in [
|
||
// Canonical wire shape from `get_or_init_connection`.
|
||
"[memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures",
|
||
// Canonical wire shape wrapped by the RPC handler's
|
||
// `format!("chunk aggregates: {e:#}")` context.
|
||
r"chunk aggregates: [memory_tree] circuit breaker open for C:\Users\u\.openhuman\users\6a09\workspace\memory_tree\chunks.db: too many consecutive init failures",
|
||
// Wrapped further by the JSON-RPC dispatch layer before reaching
|
||
// `report_error_or_expected`.
|
||
r"rpc.invoke_method failed: chunk aggregates: [memory_tree] circuit breaker open for /home/u/.openhuman/workspace/memory_tree/chunks.db: too many consecutive init failures",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::MemoryStoreBreakerOpen),
|
||
"should classify memory-store breaker-open: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_disk_full_errors() {
|
||
for raw in [
|
||
// Canonical POSIX errno 28 rendering from `std::io::Error`.
|
||
"Failed to create auth profile lock: open lock file: No space left on device (os error 28)",
|
||
// Same shape from a different call site — `tokio::fs::write`
|
||
// for a state snapshot.
|
||
"state snapshot write failed: No space left on device (os error 28)",
|
||
// Windows ERROR_DISK_FULL (112) rendering.
|
||
"log rotation failed: There is not enough space on the disk. (os error 112)",
|
||
// Outer-only `{}` wire shape that production actually emits for the
|
||
// auth-profile lock-create failure (Sentry TAURI-RUST-4SZ): the
|
||
// inner io::Error Display is flattened away at the RPC boundary, so
|
||
// only the `ErrorKind` debug + os_code survive — no "no space left
|
||
// on device" text. Must still classify via the StorageFull anchor.
|
||
"Failed to create auth profile lock (kind=Some(StorageFull), os_code=Some(28))",
|
||
// SQLITE_FULL rendering from rusqlite — engine-level disk-full
|
||
// detection during page-bookkeeping (journal/WAL extension) that
|
||
// beats the next syscall to the errno. Production hit at
|
||
// `memory_store::unified::documents::tx.commit()` during
|
||
// `openhuman.memory_doc_ingest`, in the same burst that emits
|
||
// os-error-112 siblings (Sentry TAURI-RUST-B6N).
|
||
"commit tx: database or disk is full",
|
||
// SQLITE_FULL **extended** rendering — the full error-code envelope
|
||
// where the canonical phrase is mid-string, not a suffix (Sentry
|
||
// TAURI-RUST-4R8, `memory_queue::store::claim_next` on
|
||
// `mem_tree_jobs`). Caught via the `code_to_str` token arm.
|
||
"Failed to claim next mem_tree_jobs row: database or disk is full: \
|
||
Error code 13: Insertion failed because database is full",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::DiskFull),
|
||
"should classify disk-full: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_space_messages() {
|
||
// Generic "space" prose without the errno-text anchor must not be
|
||
// silenced — the matcher pins to the platform-stable errno
|
||
// renderings only.
|
||
assert_eq!(
|
||
expected_error_kind("workspace path is invalid: contains a space character"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("not enough memory to allocate buffer"),
|
||
None
|
||
);
|
||
// The SQLite anchor pins to the exact `"database or disk is full"`
|
||
// phrase. Generic prose that mentions a full database for unrelated
|
||
// reasons (e.g. duplicate-row complaints, application-level capacity
|
||
// talk) must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind("upsert failed: database is full of duplicates"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("user quota: database is full for this tier"),
|
||
None
|
||
);
|
||
// A non-2xx backend body whose payload contains the SQLITE_FULL phrase
|
||
// (e.g. `api.tinyhumans.ai` server-side SQLite is full) is an
|
||
// operator-actionable storage failure, not the user's local disk —
|
||
// must still surface to Sentry. `integrations::client::post` frames
|
||
// these as `"Backend returned <status> <reason> for POST <url>:
|
||
// <detail>"` (codex CR on #3672). The suffix anchor excludes the
|
||
// embedded-in-JSON case; the negative `"backend returned "` guard
|
||
// covers the rare case where the body itself ends with the phrase.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/list: \
|
||
{\"error\":\"database or disk is full\"}"
|
||
),
|
||
None,
|
||
"remote-backend body must surface"
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/list: \
|
||
database or disk is full"
|
||
),
|
||
None,
|
||
"remote-backend body must surface even when the body itself ends with the phrase"
|
||
);
|
||
// Same guard for the extended-code token: a backend body that quotes
|
||
// the SQLITE_FULL `code_to_str` string is still operator-actionable
|
||
// and must surface (TAURI-RUST-4R8 token arm + `"backend returned "`
|
||
// exclusion).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 507 Insufficient Storage for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/list: \
|
||
Error code 13: Insertion failed because database is full"
|
||
),
|
||
None,
|
||
"remote-backend body carrying the extended SQLITE_FULL token must surface"
|
||
);
|
||
// A remote OpenAI-compatible embeddings 500 whose server-side SQLite is
|
||
// full is wrapped by `OpenAiEmbedding::embed` as `"Embedding API error
|
||
// (…)"` — no `"backend returned "` prefix and no local `"database or
|
||
// disk is full"` phrase, just the `code_to_str` half. Requiring BOTH
|
||
// local fragments for the extended shape keeps this operator-actionable
|
||
// server fault reportable (codex CR on #3911).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Embedding API error (status 500): Error code 13: \
|
||
Insertion failed because database is full"
|
||
),
|
||
None,
|
||
"remote embedding-API body quoting only the code_to_str token must surface"
|
||
);
|
||
// Non-suffix occurrences in other body framings (no `"Backend
|
||
// returned"` prefix) are also excluded by the suffix anchor — locks
|
||
// in the primary defense layer.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Embedding API error (500 Internal Server Error): \
|
||
{\"error\":\"database or disk is full\",\"retry\":true}"
|
||
),
|
||
None,
|
||
"embedded-in-JSON body must surface"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_config_load_timed_out() {
|
||
// Canonical wire string emitted by `load_config_with_timeout` and
|
||
// `reload_config_snapshot_with_timeout` in
|
||
// `src/openhuman/config/ops.rs`. Drops TAURI-RUST-5X.
|
||
assert_eq!(
|
||
expected_error_kind("Config loading timed out"),
|
||
Some(ExpectedErrorKind::ConfigLoadTimedOut),
|
||
);
|
||
// Same shape after the RPC dispatch wraps it for display — the
|
||
// matcher is substring-anchored, so a context prefix does not
|
||
// break it.
|
||
assert_eq!(
|
||
expected_error_kind("rpc.invoke_method failed: Config loading timed out"),
|
||
Some(ExpectedErrorKind::ConfigLoadTimedOut),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_timeouts_as_config_load_timed_out() {
|
||
// Network / HTTP timeouts go to `NetworkUnreachable` /
|
||
// `TransientUpstreamHttp`, not the config-load bucket. The
|
||
// anchor is the full literal phrase, so a bare "timed out" or
|
||
// "operation timed out" body cannot trip this matcher.
|
||
assert_ne!(
|
||
expected_error_kind(
|
||
"Channel discord error: IO error: Operation timed out (os error 60); restarting"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigLoadTimedOut),
|
||
);
|
||
assert_ne!(
|
||
expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"),
|
||
Some(ExpectedErrorKind::ConfigLoadTimedOut),
|
||
);
|
||
// Bare "timed out" without the config-load phrase must not match.
|
||
assert_eq!(expected_error_kind("cron job timed out after 30s"), None,);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_config_read_io_failure_for_os_denial_kinds() {
|
||
// TAURI-RUST-DME shape once the loader surfaces the full io chain
|
||
// (#3962): Windows access-denied on an existing config.toml.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Failed to read config file: C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml: Access is denied. (os error 5)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// Sharing-violation: file held open by another process (antivirus /
|
||
// backup agent).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Failed to read config file: C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml: The process cannot access the file because it is being used by another process. (os error 32)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// Unix permission-denied wording.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Failed to read config file: /home/u/.openhuman/users/local/config.toml: Permission denied (os error 13)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// Snapshot-reload context anchor (`load_from_config_path`) must demote
|
||
// the same OS-denial family so a long-lived reloader can't leak either.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"reading config.toml from C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml: Access is denied. (os error 5)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_demote_config_read_notfound_or_unkeyed_failures() {
|
||
// NotFound AFTER the `exists()` gate is a TOCTOU race / app defect —
|
||
// it MUST keep paging, never demote.
|
||
assert_ne!(
|
||
expected_error_kind(
|
||
"Failed to read config file: C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml: The system cannot find the file specified. (os error 2)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// Bare top-context line with no io signal (pre-#3962 shape, or an io
|
||
// kind we have not enumerated) must NOT demote — fail open to paging.
|
||
assert_ne!(
|
||
expected_error_kind(
|
||
"Failed to read config file: C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// The access-denied signal alone, without the config-read anchor, must
|
||
// not be hijacked into the config bucket.
|
||
assert_ne!(
|
||
expected_error_kind("opening keychain failed: Access is denied. (os error 5)"),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
// A directory at the config path is corruption — keep paging even though
|
||
// it carries an access-denied / os-error-5 shape (Codex P2). Both the
|
||
// unix wording and the Windows os-error-5 + read-site wording are
|
||
// excluded by the `is a directory` / `not a file` guard.
|
||
assert_ne!(
|
||
expected_error_kind(
|
||
"Failed to read config file: /home/u/.openhuman/users/local/config.toml: Is a directory (os error 21)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
assert_ne!(
|
||
expected_error_kind(
|
||
"Config path is a directory, not a file: C:\\Users\\u\\.openhuman\\users\\local-wb\\config.toml: Access is denied. (os error 5)"
|
||
),
|
||
Some(ExpectedErrorKind::ConfigReadIoFailure),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_whatsapp_data_sqlite_busy_errors() {
|
||
for raw in [
|
||
r#"[whatsapp_data] ingest failed: upsert wa_message chat=120363402402350155@g.us msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#,
|
||
r#"rpc.invoke_method failed: [whatsapp_data] ingest failed: upsert wa_message [email] msg=false_120363402402350155@g.us_3A357F28AE74548B1507_207897942335683@lid: database is locked: Error code 5: The database file is locked"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::WhatsAppDataSqliteBusy),
|
||
"should classify whatsapp_data sqlite busy/locked: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_subconscious_schema_unavailable_errors() {
|
||
for raw in [
|
||
// SQLITE_IOERR_SHMMAP (4618) — the original escalating issue (#3231).
|
||
"failed to run subconscious schema DDL: disk I/O error: Error code 4618: I/O error within the xShmMap method (trying to open a new shared-memory segment)",
|
||
// SQLITE_CANTOPEN (14) — sibling variant from the user report.
|
||
"failed to run subconscious schema DDL: unable to open database file: Error code 14: Unable to open the database file",
|
||
// Failure surfaced at the open step rather than the DDL step.
|
||
"failed to open subconscious DB: /home/u/.openhuman/subconscious/subconscious.db: unable to open the database file",
|
||
// Wrapped in outer RPC context — classifier runs on the full chain.
|
||
"rpc.invoke_method failed: failed to run subconscious schema DDL: disk I/O error: Error code 4618",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::SubconsciousSchemaUnavailable),
|
||
"should classify subconscious schema DB-unavailable: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_subconscious_lock_or_unrelated_open_failures() {
|
||
for raw in [
|
||
// Transient busy/locked is retried by the store and, if persistent,
|
||
// is a real contention signal — must NOT be demoted here.
|
||
"failed to run subconscious schema DDL: database is locked",
|
||
// Cant-open text without the subconscious envelope must not match.
|
||
"failed to open memory DB: unable to open the database file",
|
||
// Subconscious envelope but a non-FS error (e.g. malformed SQL) stays
|
||
// a real bug worth reporting.
|
||
"failed to run subconscious schema DDL: near \"CREAT\": syntax error",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::SubconsciousSchemaUnavailable),
|
||
"must not classify as subconscious schema unavailable: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_sqlite_lock_messages_as_whatsapp_busy() {
|
||
for raw in [
|
||
"failed to run subconscious schema DDL: database is locked",
|
||
"memory queue write failed: database table is locked",
|
||
"[whatsapp_data] list_messages failed: database is locked",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::WhatsAppDataSqliteBusy),
|
||
"must not classify as whatsapp_data sqlite busy: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_breaker_messages() {
|
||
// Generic "circuit breaker open" without the `[memory_tree]` anchor
|
||
// must not be silenced — other domains may use the same phrase for
|
||
// real bugs that need to reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind("provider reliability: circuit breaker open for openai"),
|
||
None
|
||
);
|
||
// The `[memory_tree]` tag alone is not enough — must co-occur with
|
||
// the `circuit breaker open` substring.
|
||
assert_eq!(
|
||
expected_error_kind("[memory_tree] failed to run schema DDL: disk full"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_capability_unavailable() {
|
||
// The classifier anchors on the exact "for this RAM tier" substring.
|
||
// Messages that talk about RAM in a different context (sizing the
|
||
// tier list, doc references) must not be silenced.
|
||
assert_eq!(expected_error_kind("ollama embed failed: out of RAM"), None);
|
||
assert_eq!(
|
||
expected_error_kind("local_ai_set_ram_tier failed: invalid tier value"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_network_unreachable_errors() {
|
||
// OPENHUMAN-TAURI-32: reqwest's transport-level error wrapped by the
|
||
// web_channel error site. The classifier must catch it even when
|
||
// embedded in caller context, since `report_error_or_expected` runs
|
||
// `expected_error_kind` on the full anyhow chain.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 \
|
||
error=error sending request for url (https://api.tinyhumans.ai/openai/v1/chat/completions)"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
for raw in [
|
||
"error sending request for url (https://api.example.com/x)",
|
||
"provider failed: dns error: failed to lookup address information",
|
||
"tcp connect: connection refused (os error 61)",
|
||
"stream closed: connection reset by peer",
|
||
"network is unreachable (os error 51)",
|
||
"no route to host",
|
||
"tls handshake eof",
|
||
"certificate verify failed: unable to get local issuer certificate",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::NetworkUnreachable),
|
||
"should classify as network-unreachable: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_openai_ollama_timeout_fallback_chain_is_network_unreachable() {
|
||
let chain =
|
||
"custom_openai chat completions transport error: error sending request for url \
|
||
(http://localhost:11434/v1/chat/completions): operation timed out \
|
||
(responses fallback failed: custom_openai API error (404 Not Found): \
|
||
{\"error\":\"not found\"})";
|
||
assert_eq!(
|
||
expected_error_kind(chain),
|
||
Some(ExpectedErrorKind::NetworkUnreachable),
|
||
"local Ollama timeout plus responses fallback failure must not page Sentry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_provider_errors_as_network() {
|
||
// Status-bearing provider failures (404, 500, …) are surfaced via
|
||
// their HTTP status path and must NOT be silenced by the
|
||
// network-unreachable classifier — the body text doesn't hit any of
|
||
// the transport-level markers.
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (404): model gpt-x not found"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (500): internal server error"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_wave4_socket_transport_wire_shapes() {
|
||
// OPENHUMAN-TAURI-44 (~50 events): libc `getaddrinfo()` rendering
|
||
// without the `dns error` token, wrapped by the socket emit site.
|
||
// The Wave 4 matcher arms catch the literal resolver phrases that
|
||
// the original `dns error` substring would miss when reqwest's
|
||
// wrapper isn't in the chain (e.g. tungstenite IO errors).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: IO error: failed to lookup address information: \
|
||
nodename nor servname provided, or not known"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
|
||
// OPENHUMAN-TAURI-4P (~66 events): tungstenite renders a captive
|
||
// portal / corporate proxy that intercepts the WS handshake as
|
||
// `WsError::Http(200)` → `"HTTP error: 200 OK"`. Classify as
|
||
// network-unreachable since no amount of app-side retry can pierce
|
||
// an intercepting proxy.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: HTTP error: 200 OK"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn http_200_classifier_does_not_silence_unrelated_log_lines() {
|
||
// The captive-portal arm anchors on `"http error: 200 ok"` (the
|
||
// exact tungstenite `WsError::Http(200)` Display rendering).
|
||
// Adjacent non-WebSocket log lines that mention `"HTTP/1.1 200 OK"`
|
||
// or `"status: 200 OK"` MUST NOT classify — those are normal-flow
|
||
// success traces, not failure events. Pin this precedence so a
|
||
// future refactor doesn't broaden the substring.
|
||
assert_eq!(expected_error_kind("HTTP/1.1 200 OK"), None);
|
||
assert_eq!(
|
||
expected_error_kind("upstream returned status: 200 OK after retry"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_tls_handshake_eof_as_network_unreachable() {
|
||
// TAURI-RUST-4ZD (first seen on `openhuman@0.56.0+e8968077aeb5`,
|
||
// Windows): `native-tls` renders a peer / firewall / antivirus /
|
||
// corporate-proxy TCP close mid-TLS-handshake as
|
||
// `"TLS error: native-tls error: unexpected EOF during handshake"`,
|
||
// which `socket::ws_loop::run_connection` wraps as
|
||
// `"WebSocket connect: <inner>"` and the supervisor's
|
||
// sustained-outage escalation wraps again. The existing
|
||
// `"tls handshake"` arm misses it because the words are not
|
||
// contiguous in this render (`"tls error"` … `"during handshake"`).
|
||
// Same user-environment shape as the other handshake-stage entries:
|
||
// the socket supervisor already retries with exponential backoff and
|
||
// Sentry has no actionable signal beyond that.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
|
||
// Bare native-tls render (no socket-supervisor wrap) — fires when the
|
||
// same handshake EOF escapes through a non-supervisor call site. The
|
||
// classifier runs on the full anyhow chain, so the shorter form must
|
||
// also match.
|
||
assert_eq!(
|
||
expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() {
|
||
// CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`,
|
||
// self-hosted `core-rust`): tungstenite renders
|
||
// `ProtocolError::WrongHttpVersion` as
|
||
// `"WebSocket protocol error: HTTP version must be 1.1 or higher"`,
|
||
// wrapped by `socket::ws_loop::run_connection` as
|
||
// `"WebSocket connect: <inner>"` and then by the supervisor's
|
||
// sustained-outage escalation as
|
||
// `"[socket] Connection failed (sustained outage after N attempts):
|
||
// WebSocket connect: WebSocket protocol error: HTTP version must be
|
||
// 1.1 or higher"`.
|
||
//
|
||
// The handshake requires HTTP/1.1; a server or intermediary proxy
|
||
// that responds with HTTP/2+ to the upgrade is misconfigured
|
||
// upstream — same shape as the existing `"tls handshake"` /
|
||
// `"certificate verify failed"` user-environment entries. The
|
||
// supervisor already retries with exponential backoff; Sentry has
|
||
// no actionable signal to add.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
|
||
// Bare tungstenite render (no socket-supervisor wrap) — fires when
|
||
// the same protocol error escapes through a non-supervisor call
|
||
// site. The classifier runs on the full anyhow chain, so the
|
||
// shorter form must also match.
|
||
assert_eq!(
|
||
expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() {
|
||
// The anchor is the literal `"unexpected eof during handshake"`
|
||
// phrase. A bare data-phase `"unexpected EOF"` (server closed
|
||
// mid-stream, parser truncation, …) MUST NOT classify — those are
|
||
// outside the handshake stage and may carry actionable signal. Pin
|
||
// the rejection contract so a future refactor doesn't loosen the
|
||
// substring into a generic `"unexpected eof"` matcher.
|
||
for raw in [
|
||
"stream closed: unexpected EOF",
|
||
"reqwest: unexpected EOF while reading body",
|
||
"json parser: unexpected EOF at byte 1024",
|
||
"decoder hit unexpected eof mid-frame",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"non-handshake unexpected-EOF log line must NOT classify: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() {
|
||
// The anchor is the literal tungstenite Display string. Adjacent
|
||
// log lines that mention HTTP version in any other context
|
||
// (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`,
|
||
// `"server requires HTTP version 2.0"`) MUST NOT classify — those
|
||
// are unrelated transport / negotiation traces and may carry
|
||
// actionable signal. Pin the rejection contract so a future
|
||
// refactor doesn't loosen the substring into a generic
|
||
// `"http version"` matcher.
|
||
for raw in [
|
||
"[transport] upgrading from HTTP/1.0 to HTTP/2",
|
||
"server advertises HTTP version 2.0 (h2 alpn)",
|
||
"client supports HTTP/1.1 only",
|
||
"version mismatch: requires HTTP/1.2 or higher",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"unrelated HTTP-version log line must NOT classify: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_transient_upstream_http_errors() {
|
||
// OPENHUMAN-TAURI-5Z: the canonical shape emitted by
|
||
// `providers::ops::api_error` and re-raised through `agent.run_single`.
|
||
assert_eq!(
|
||
expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// Every transient code must classify, whether the status renders as
|
||
// bare digits or "<digits> <reason>".
|
||
for raw in [
|
||
"OpenHuman API error (408): request timeout",
|
||
"OpenAI API error (429 Too Many Requests): rate limit",
|
||
"Anthropic API error (502 Bad Gateway): upstream unhealthy",
|
||
"custom_openai API error (502 Bad Gateway): upstream gateway blip",
|
||
"OpenHuman API error (503): service unavailable",
|
||
"Provider API error (504): upstream timed out",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify as transient upstream HTTP: {raw}"
|
||
);
|
||
}
|
||
|
||
// Wrapped in an anyhow chain (as it reaches the agent layer) must
|
||
// still classify — `expected_error_kind` is substring-based.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent turn failed: OpenHuman API error (504 Gateway Timeout): \
|
||
error code: 504"
|
||
),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502):
|
||
// legacy no-paren wire shape from older `embeddings::openai` /
|
||
// `embeddings::cohere` emit-site formats that predate the
|
||
// parenthesised `({status})` rendering. Anchored on the trailing
|
||
// space after the status code so unrelated digit runs don't match.
|
||
for raw in [
|
||
"Embedding API error 504 Gateway Timeout: error code: 504",
|
||
"Embedding API error 502 Bad Gateway: error code: 502",
|
||
"Cohere embed API error 503 Service Unavailable: error code: 503",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify legacy no-paren transient shape: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_digit_runs_as_transient() {
|
||
// The legacy no-paren matcher anchors `api error <code> ` with a
|
||
// trailing space so adjacent digit runs (`api error 5042…`) and
|
||
// non-transient codes (400/401/403/404) don't get silenced.
|
||
assert_eq!(
|
||
expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("provider returned api error 5042 (custom internal sentinel)"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn integrations_post_composio_timeout_dropped() {
|
||
// OPENHUMAN-TAURI-18 / -G regression guard. The integrations
|
||
// client at `crate::openhuman::integrations::client::IntegrationClient::post`
|
||
// builds the reqwest error chain and routes it through
|
||
// `report_error_or_expected(.., "integrations", "post", &[("failure",
|
||
// "transport")])`. The chain text contains the
|
||
// `"error sending request for url"` anchor so
|
||
// `is_network_unreachable_message` matches first and demotes to
|
||
// `NetworkUnreachable` (functionally equivalent to
|
||
// `TransientUpstreamHttp` for Sentry suppression — both routes
|
||
// skip the report path via `report_expected_message`).
|
||
//
|
||
// Pinning this exact wire shape catches a future refactor that
|
||
// drops the URL anchor (e.g. a chain-flatten helper that strips
|
||
// it for "PII safety"), which would silently re-open the leak.
|
||
let chain = "error sending request for url \
|
||
(https://api.tinyhumans.ai/agent-integrations/composio/execute) → \
|
||
client error (SendRequest) → connection error → \
|
||
Operation timed out (os error 60)";
|
||
assert_eq!(
|
||
expected_error_kind(chain),
|
||
Some(ExpectedErrorKind::NetworkUnreachable),
|
||
"TAURI-18 chain shape must classify as NetworkUnreachable"
|
||
);
|
||
|
||
// If the URL anchor is ever dropped, the transport-phrase
|
||
// fallback (`operation timed out` from
|
||
// `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message
|
||
// classifier helper used at upstream re-emit sites — confirm
|
||
// both paths so the regression surface is fully pinned.
|
||
assert!(
|
||
is_transient_message_failure(chain),
|
||
"TAURI-18 chain must also satisfy upstream message classifier \
|
||
(defense-in-depth for sites that lose the URL anchor)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_operation_timed_out_classifies_as_expected() {
|
||
// OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision`
|
||
// wraps a channel listener failure as
|
||
// `format!("Channel {} error: {e:#}; restarting", ch.name())` and
|
||
// routes the message through `report_error_or_expected`. The
|
||
// newer `ChannelSupervisorRestart` classifier (added for the
|
||
// broader 11.4k-event Sentry leak) anchors on the supervisor
|
||
// wrapper shape itself — `"Channel <name> error: …; restarting"`
|
||
// — and takes precedence over `NetworkUnreachable`. That single
|
||
// arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose
|
||
// shape the old narrower anchor pinned, plus OS-localized
|
||
// variants the English-only `NetworkUnreachable` would miss.
|
||
//
|
||
// Demotion tier difference: `ChannelSupervisorRestart` emits at
|
||
// `info!` (breadcrumb only, no Sentry event) where
|
||
// `NetworkUnreachable` emitted at `warn!` (still captured as a
|
||
// Sentry warn event). Sustained outages still page via
|
||
// `health.bus` / `FAIL_ESCALATE_THRESHOLD`.
|
||
for raw in [
|
||
// macOS (os error 60 = ETIMEDOUT on BSD)
|
||
"Channel discord error: IO error: Operation timed out (os error 60); restarting",
|
||
// Linux (os error 110 = ETIMEDOUT)
|
||
"Channel discord error: IO error: Operation timed out (os error 110); restarting",
|
||
// Windows (os error 10060 = WSAETIMEDOUT)
|
||
"Channel discord error: IO error: Operation timed out (os error 10060); restarting",
|
||
// Same shape on other channels — supervisor wrapper is provider-agnostic.
|
||
"Channel slack error: IO error: Operation timed out (os error 60); restarting",
|
||
"Channel telegram error: IO error: Operation timed out (os error 110); restarting",
|
||
// Bare prose form (no errno suffix) from hyper / tungstenite layers
|
||
// that render `std::io::Error` without `raw_os_error()`.
|
||
"Channel discord error: WebSocket connect: IO error: Operation timed out; restarting",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"channel supervisor timeout shape must classify as ChannelSupervisorRestart \
|
||
(precedence over NetworkUnreachable; got {:?} for {raw:?})",
|
||
expected_error_kind(raw)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn operation_timed_out_negative_cases_still_report() {
|
||
// Counter-case: a configuration/validation message that mentions
|
||
// "timeout" as a knob name (not transport state) and has no other
|
||
// classifier anchor must still reach Sentry. The substring chosen
|
||
// for the new matcher is `"operation timed out"`, not `"timeout"`,
|
||
// precisely so unrelated mentions of the word do not collide.
|
||
assert_eq!(
|
||
expected_error_kind("config rejected: timeout must be a positive integer"),
|
||
None,
|
||
"config validation noise (no 'operation timed out' anchor) must still reach Sentry"
|
||
);
|
||
// Bare empty string — no anchors at all.
|
||
assert_eq!(expected_error_kind(""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() {
|
||
// OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F
|
||
// (~39 events): the reliable provider layer retried 5xx, the
|
||
// agent re-raised the error, and `channels::runtime::dispatch`
|
||
// re-emitted it under `domain="channels", operation="dispatch_llm_error"`
|
||
// via raw `report_error` (which skips classification). Switching
|
||
// that site to `report_error_or_expected` routes the chain
|
||
// through this classifier — but only works if the canonical
|
||
// `"OpenHuman API error (NNN ...)"` substring still anchors the
|
||
// match through the channels-layer wrapping.
|
||
//
|
||
// The wrapping shape at the dispatch site is the agent error
|
||
// chain rendered via `format!("{e:#}")`. For a backend 502 from
|
||
// `providers::ops::api_error`, that resolves to:
|
||
// "OpenHuman API error (502 Bad Gateway): error code: 502"
|
||
// possibly prepended with a runner / iteration prefix. Both
|
||
// shapes must classify as transient so the dispatch re-emit
|
||
// gets demoted.
|
||
for raw in [
|
||
"OpenHuman API error (502 Bad Gateway): error code: 502",
|
||
"agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted",
|
||
"all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"channels.dispatch re-emit of {raw:?} must classify as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_socket_transient_http_errors() {
|
||
// OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)`
|
||
// surfaces during the WebSocket upgrade handshake when the backend
|
||
// load balancer returns 502 / 504. The socket reconnect loop wraps
|
||
// it as `format!("WebSocket connect: {e}")`, producing
|
||
// `"WebSocket connect: HTTP error: <status> <reason>"`. Each
|
||
// sustained-outage threshold escalation routes the formatted reason
|
||
// through `report_error_or_expected`, which must classify as
|
||
// transient so the per-client noise stops reaching Sentry.
|
||
for raw in [
|
||
"WebSocket connect: HTTP error: 502 Bad Gateway",
|
||
"WebSocket connect: HTTP error: 503 Service Unavailable",
|
||
"WebSocket connect: HTTP error: 504 Gateway Timeout",
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: HTTP error: 502 Bad Gateway",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify as transient upstream HTTP (socket shape): {raw}"
|
||
);
|
||
}
|
||
|
||
// Trailing-colon separator (chained error formatting).
|
||
// Note: avoid words like "connection refused" or "timeout" in the
|
||
// suffix — those would also match `is_network_unreachable_message` /
|
||
// `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind`
|
||
// would route through `NetworkUnreachable` first, defeating the
|
||
// assertion. Both classifications silence the event so production
|
||
// behavior is identical, but the test is anchored on the canonical
|
||
// socket shape so a future regression in `is_transient_upstream_http_message`
|
||
// surfaces here, not behind another classifier.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"WebSocket connect: HTTP error: 502: upstream returned bad gateway"
|
||
),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// Trailing-newline separator (multi-line error chain).
|
||
assert_eq!(
|
||
expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_http_error_text_as_transient_socket() {
|
||
// Bare numeric "HTTP error: 5023" (port number, runbook ID) without
|
||
// a separator must NOT silence — pin the matcher to space/newline/colon.
|
||
assert_eq!(expected_error_kind("HTTP error: 5023"), None);
|
||
// Non-transient HTTP statuses must not match — `WsError::Http` for
|
||
// a 401 / 403 / 404 is genuinely actionable (auth / routing bug).
|
||
for raw in [
|
||
"WebSocket connect: HTTP error: 401 Unauthorized",
|
||
"WebSocket connect: HTTP error: 403 Forbidden",
|
||
"WebSocket connect: HTTP error: 404 Not Found",
|
||
"WebSocket connect: HTTP error: 500 Internal Server Error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT silence actionable socket HTTP error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_actionable_provider_errors_as_transient_upstream() {
|
||
// 4xx (other than 408/429) and non-transient 5xx must continue to
|
||
// reach Sentry — those are real bugs (wrong model name, malformed
|
||
// request, internal exception) that need to be triaged.
|
||
for raw in [
|
||
"OpenAI API error (400): bad request",
|
||
"OpenAI API error (401): unauthorized",
|
||
"OpenAI API error (403): forbidden",
|
||
"OpenAI API error (404): model not found",
|
||
"OpenAI API error (500): internal server error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT silence actionable provider error: {raw}"
|
||
);
|
||
}
|
||
|
||
// A free-form message that merely mentions "504" without the
|
||
// `api error (` prefix must not be classified — pin the match to
|
||
// the canonical shape from `ops::api_error`.
|
||
assert_eq!(
|
||
expected_error_kind("see runbook for 504 handling at https://example.com/504"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[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. 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 \
|
||
{\"error\":{\"message\":\"Missing required fields: Tenant Name\",\
|
||
\"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}";
|
||
assert_eq!(
|
||
expected_error_kind(bc),
|
||
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 /
|
||
// composio clients — all user-input / auth-state failures that
|
||
// Sentry can't action.
|
||
for raw in [
|
||
"Backend returned 400 Bad Request for POST https://api.example.com/x: bad input",
|
||
"Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired",
|
||
"Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied",
|
||
"Backend returned 404 Not Found for GET https://api.example.com/x: missing",
|
||
"Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed",
|
||
"Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked",
|
||
// Lowercased context wrapping is irrelevant — substring match is case-insensitive.
|
||
"[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::BackendUserError),
|
||
"must classify as backend user-error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tool_execute_backend_401_invalid_token_does_not_hard_report() {
|
||
// TAURI-RUST-84E: the integrations client demotes its backend 401 via
|
||
// `report_error_or_expected`, but ALSO `anyhow::bail!`s it. The error
|
||
// bubbles to the agent tool-execute loop
|
||
// (`agent::harness::engine::tools::run_one_tool`'s `Ok(Err(e))` arm),
|
||
// which previously called the unconditional `report_error` — a second,
|
||
// hard Sentry event (domain=tool / operation=execute) for an
|
||
// already-classified user-end invalid-token condition. The arm now
|
||
// routes through `report_error_or_expected`; this test pins the exact
|
||
// wire shape so a classifier regression that lets the 401 escape (and
|
||
// resume double-reporting) fails CI.
|
||
//
|
||
// Mirror the tool-execute call path: the integrations client bails with
|
||
// `anyhow::anyhow!("Backend returned {status} for POST {url}: {detail}")`,
|
||
// the agent arm renders it with `{e:#}`, then `report_error_or_expected`
|
||
// consults `expected_error_kind`. Assert that classifier returns the
|
||
// expected user-state bucket (so the report is demoted, not hard).
|
||
let e = anyhow::anyhow!(
|
||
"Backend returned 401 Unauthorized for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/parallel/search: Invalid token"
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(&format!("{e:#}")),
|
||
Some(ExpectedErrorKind::BackendUserError),
|
||
"the 84E tool-execute backend-401 wire shape must classify as expected \
|
||
user-state so report_error_or_expected demotes it instead of firing a \
|
||
hard tool/execute Sentry event"
|
||
);
|
||
|
||
// A genuine tool failure (no classifier arm) must keep surfacing as a
|
||
// hard error — confirm routing ALL tool errors through
|
||
// `report_error_or_expected` does not silently swallow real failures.
|
||
assert_eq!(
|
||
expected_error_kind("tool 'web_search' panicked: index out of bounds"),
|
||
None,
|
||
"a genuine tool failure must NOT be classified as expected — it still \
|
||
reaches Sentry as a hard error"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_transient_or_server_backend_errors_as_user_error() {
|
||
// 408 / 429 are transient — they belong to the
|
||
// upstream-transient bucket (or are retried at the caller), not
|
||
// the user-error bucket. A sustained 429 (rate limit cliff) MUST
|
||
// still surface so we can react.
|
||
for raw in [
|
||
"Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout",
|
||
"Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"transient 4xx must NOT be classified as user-error: {raw}"
|
||
);
|
||
}
|
||
|
||
// 5xx is always actionable — server bugs need to reach Sentry.
|
||
for raw in [
|
||
"Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops",
|
||
"Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down",
|
||
"Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance",
|
||
"Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"5xx must NOT be classified as user-error: {raw}"
|
||
);
|
||
}
|
||
|
||
// A free-form message that mentions "400" but doesn't follow the
|
||
// `Backend returned <status>` prefix from the integrations /
|
||
// composio clients must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind("see HTTP 400 specification at https://example.com/400"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (400): bad request"),
|
||
None,
|
||
"provider-formatted 4xx must keep going through the provider classifier path"
|
||
);
|
||
}
|
||
|
||
#[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_custom_openai_upstream_bad_request_as_provider_user_state() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400 Bad Request): \
|
||
{\"error\":{\"message\":\"Bad request to upstream provider\",\
|
||
\"type\":\"upstream_error\",\"status\":400}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Wrapped by higher-level callers (`agent.run_single`,
|
||
// `rpc.invoke_method`) must still classify.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent.run_single failed: custom_openai API error (400 Bad Request): \
|
||
{\"error\":{\"message\":\"Bad request to upstream provider\",\
|
||
\"type\":\"upstream_error\",\"status\":400}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
/// Regression for CodeRabbit feedback on PR #2107: the matcher must
|
||
/// not demote unrelated errors that happen to contain both
|
||
/// "bad request to upstream provider" and "upstream_error" without
|
||
/// the `custom_openai API error (400` anchor.
|
||
#[test]
|
||
fn does_not_silence_unrelated_error_with_only_inner_substrings() {
|
||
// No `custom_openai API error (400` prefix → must NOT classify
|
||
// as ProviderUserState, otherwise we'd silence actionable bugs.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"internal panic in router: bad request to upstream provider \
|
||
(state=upstream_error)"
|
||
),
|
||
None,
|
||
);
|
||
|
||
// A future hypothetical provider envelope reusing one substring
|
||
// also must not classify.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"anthropic_api error: upstream_error encountered while \
|
||
forwarding bad request to upstream provider"
|
||
),
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[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 classifies_access_terminated_provider_policy_as_provider_user_state() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent turn failed: custom_openai API error (403): currently only available for coding agents"
|
||
),
|
||
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 classifies_provider_config_rejection() {
|
||
// #2079 — an OpenHuman abstract tier alias leaked to a custom
|
||
// provider; raised again by `agent.run_single` /
|
||
// `web_channel.run_chat_task` so it escapes the provider-layer
|
||
// demotion and reaches `report_error_or_expected` here.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent.run_single failed: custom_openai API error (400 Bad Request): \
|
||
The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \
|
||
but you passed reasoning-v1."
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
// #2076 — Moonshot Kimi K2 temperature constraint.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400): invalid temperature: only 1 is allowed for this model"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
// #2202 — unknown / stale model pin (OpenAI-compatible body).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400): Model 'claude-opus-4-7' is not available. \
|
||
Use GET /openai/v1/models to list available models."
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_embedding_endpoint_absent_as_config_rejection() {
|
||
// TAURI-RUST-5JR — custom embeddings provider pointed at a chat-only
|
||
// base URL (DeepSeek) that has no `/embeddings` route. Verbatim shape
|
||
// produced by `src/openhuman/embeddings/openai.rs` (prefix preserved
|
||
// even after the actionable-hint suffix is appended).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Embedding API error (404 Not Found): <html>not found</html> \
|
||
— this endpoint has no embeddings API; pick an embeddings-capable \
|
||
provider in Settings → Memory"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
// 405 Method Not Allowed — route exists for GET only / wrong verb.
|
||
assert_eq!(
|
||
expected_error_kind("Embedding API error (405 Method Not Allowed): {}"),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_demote_real_embedding_server_faults() {
|
||
// Polarity guard: a 500 from a VALID embeddings endpoint is a real
|
||
// server fault and must keep reaching Sentry — not demoted.
|
||
assert_eq!(
|
||
expected_error_kind("Embedding API error (500 Internal Server Error): upstream boom"),
|
||
None,
|
||
"embedding 500 is a real fault and must stay in Sentry"
|
||
);
|
||
// A 400 (e.g. oversized input — TAURI-RUST-4SA) is prevented at source
|
||
// by the chunk cap (#3598); a residual 400 must stay visible, NOT be
|
||
// swallowed by the 404/405-scoped endpoint-absent arm.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Embedding API error (400 Bad Request): {\"error\":{\"message\":\
|
||
\"maximum input length is 8192 tokens.\"}}"
|
||
),
|
||
None,
|
||
"embedding 400 must NOT be demoted by the endpoint-absent (404/405) arm"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_provider_failures_as_config_rejection() {
|
||
// Inverted polarity / scope guard: a 5xx or a generic 4xx with no
|
||
// config-rejection body must still reach Sentry as actionable.
|
||
// (The OpenHuman backend never emits these phrases, so the
|
||
// message-level predicate is intrinsically custom-provider scoped;
|
||
// the HTTP-layer twin enforces the non-backend guard explicitly.)
|
||
assert_eq!(
|
||
expected_error_kind("custom_openai API error (500): internal server error"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400 Bad Request): missing required field 'messages'"
|
||
),
|
||
None,
|
||
"generic 4xx without a config-rejection body must NOT demote"
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
// ── TAURI-RUST-X9 (#1166): composio-direct 401 / Invalid API key ────
|
||
|
||
#[test]
|
||
fn classifies_composio_direct_invalid_api_key_as_provider_user_state() {
|
||
// Canonical Sentry TAURI-RUST-X9 wire shape — the verbatim title
|
||
// body from the issue, captured 15,732 times in ~22h on a single
|
||
// user with a bad direct-mode key. The classifier must demote
|
||
// this to `ProviderUserState` so the polling layer's 5 s retry
|
||
// doesn't keep flooding Sentry.
|
||
let msg = "[composio-direct] list_connections failed: \
|
||
Composio v3 connected_accounts failed: \
|
||
HTTP 401: Invalid API key: ak_VsUvq*****";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"composio-direct HTTP 401 + Invalid API key must demote to ProviderUserState"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_composio_direct_invalid_api_key_for_other_ops() {
|
||
// Same arm must cover every op-name the direct branches emit —
|
||
// not just `list_connections`. The matcher gates on the
|
||
// `[composio-direct]` prefix, not on a specific op string, so
|
||
// `list_tools` / `authorize` / `list_connections` all demote.
|
||
let shapes = [
|
||
// list_tools prefetch fails before the actual list_tools call
|
||
"[composio-direct] list_tools: prefetch connections failed: \
|
||
Composio v3 connected_accounts failed: HTTP 401: Invalid API key: ak_…",
|
||
// direct authorize hits the v3 /connected_accounts/link wall
|
||
"[composio-direct] authorize failed: \
|
||
Composio v3 connected_accounts/link failed: HTTP 401: Invalid API key: ak_…",
|
||
// direct list_tools itself
|
||
"[composio-direct] list_tools failed: \
|
||
Composio v3 tools failed: HTTP 401: Invalid API key: ak_…",
|
||
// periodic-tick rendering (no "[composio-direct]" prefix because
|
||
// periodic.rs wraps differently, but the failure still gets the
|
||
// hook — handled by ops.rs's report path, not the
|
||
// expected_error_kind body shape, so we only verify the
|
||
// composio-direct branch here)
|
||
];
|
||
for msg in shapes {
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"every [composio-direct] op with HTTP 401 / Invalid API key must demote: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_composio_direct_with_invalid_api_key_only_no_http_401() {
|
||
// The matcher accepts EITHER `HTTP 401` OR `Invalid API key`
|
||
// alongside the `[composio-direct]` prefix. Catches the wire
|
||
// shape variant where the body anchor lands but the status text
|
||
// is rendered differently (e.g. "401 Unauthorized" instead of
|
||
// "HTTP 401") — same user-state condition.
|
||
let msg = "[composio-direct] list_connections failed: \
|
||
Composio v3 connected_accounts failed: \
|
||
401 Unauthorized: Invalid API key: ak_…";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"composio-direct + Invalid API key body must demote even without literal 'HTTP 401'"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_http_401_as_composio_direct_user_state() {
|
||
// Discrimination test: a generic 401 that does NOT carry the
|
||
// `[composio-direct]` prefix must NOT match this arm. This
|
||
// protects against the arm accidentally swallowing backend-mode
|
||
// composio 401s, unrelated integration 401s, or any other
|
||
// 401-containing message that lacks the direct-mode anchor.
|
||
//
|
||
// The backend-mode shape is `Backend returned 401 …`; it does
|
||
// not contain `[composio-direct]`, so the new arm rightly skips
|
||
// it. Backend-mode 401s remain a real Sentry signal (bad
|
||
// service-to-service auth, expired token, etc.).
|
||
let backend_401 = "[composio] list_connections failed: \
|
||
Backend returned 401 Unauthorized for GET \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/connections: \
|
||
Invalid API key";
|
||
assert_ne!(
|
||
expected_error_kind(backend_401),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"backend-mode 401 must NOT demote via the composio-direct arm"
|
||
);
|
||
|
||
let unrelated_401 = "GitHub API error: HTTP 401: Bad credentials";
|
||
assert_ne!(
|
||
expected_error_kind(unrelated_401),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"unrelated 401 (no [composio-direct] anchor) must NOT match the composio-direct arm"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_composio_direct_500_as_user_state() {
|
||
// Real bug shapes — a 500 from the direct v3 path with no auth
|
||
// body anchor — must still fall through to `None` so Sentry
|
||
// sees them. Without this guard the arm could be too permissive
|
||
// and silence genuine backend faults.
|
||
let msg = "[composio-direct] list_connections failed: \
|
||
Composio v3 connected_accounts failed: HTTP 500";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
None,
|
||
"composio-direct 500 with no auth body must NOT demote — it is a real bug shape"
|
||
);
|
||
}
|
||
|
||
// ── TAURI-RUST-34H: backend-wrapped Cloudflare anti-bot interstitial ─
|
||
|
||
#[test]
|
||
fn classifies_backend_cloudflare_antibot_wrap_as_provider_user_state() {
|
||
// Canonical Sentry TAURI-RUST-34H wire shape — the verbatim title
|
||
// body from the issue (8,851 events / 14d on self-hosted
|
||
// `tauri-rust`). The backend wraps an upstream Cloudflare 403
|
||
// anti-bot challenge as `Backend returned 500 … 403 <!DOCTYPE …
|
||
// Just a moment... … cloudflare …`. The 500 escapes the 4xx-only
|
||
// `is_backend_user_error_message` classifier, so this body-shape
|
||
// arm catches it and demotes to `ProviderUserState`.
|
||
let msg = r#"Backend returned 500 Internal Server Error for GET https://api.tinyhumans.ai/agent-integrations/composio/connections: 403 <!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="robots" content="noindex,nofollow"><meta name="viewport" content="width=device-width,initial-scale=1"><link href="/cdn-cgi/styles/challenges.css" rel="stylesheet"></head><body class="no-js"><div class="main-wrapper" role="main"><div class="main-content"><h1 class="zone-name-title h1"><img class="heading-favicon" src="/favicon.ico" onerror="this.onerror=null;this.parentNode.removeChild(this)" alt="Icon for api.tinyhumans.ai">api.tinyhumans.ai</h1>...Powered by Cloudflare..."#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() {
|
||
// Strip the wire shape down to just the two anchors — the
|
||
// matcher should still fire so future renderings (different
|
||
// line breaks, stripped HTML, alternate caller wrappers) still
|
||
// demote.
|
||
let msg = "Just a moment...\ncloudflare\n";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"minimal `Just a moment...` + `cloudflare` body must demote"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() {
|
||
// Discrimination test for the double-anchor: either half on its
|
||
// own must NOT match. This guards against unrelated bodies that
|
||
// happen to use either phrase out of context.
|
||
|
||
// Half-anchor 1: `just a moment` without `cloudflare` — e.g.
|
||
// a daemon restart spinner blurb.
|
||
let half_a = "Just a moment, while we restart the daemon";
|
||
assert_ne!(
|
||
expected_error_kind(half_a),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm"
|
||
);
|
||
|
||
// Half-anchor 2: `cloudflare` without `just a moment...` — e.g.
|
||
// a CF Workers footer mention elsewhere.
|
||
let half_b = "Powered by Cloudflare";
|
||
assert_ne!(
|
||
expected_error_kind(half_b),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_genuine_backend_500_without_cloudflare_body() {
|
||
// Real bug shape — a 500 from the same backend endpoint with no
|
||
// Cloudflare interstitial body — must still fall through so
|
||
// Sentry sees it. Without this guard the arm could be too
|
||
// permissive and silence genuine database / handler faults.
|
||
let msg = "Backend returned 500 Internal Server Error for GET \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/connections: \
|
||
database connection pool exhausted";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
None,
|
||
"genuine backend 500 without Cloudflare body must NOT demote — it is a real bug"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_list_models_404_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed
|
||
// a custom-provider's `/models` endpoint and the upstream server
|
||
// returned 404 because the base URL is wrong / doesn't host a models
|
||
// listing. User-config state — the model-dropdown probe already
|
||
// surfaces it inline. Pin the verbatim Sentry payload plus a few
|
||
// body-shape variants (different upstreams emit different 404 bodies)
|
||
// so the path-agnostic prefix anchor stays the source of truth.
|
||
for raw in [
|
||
// Verbatim shape from the Sentry event.
|
||
r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#,
|
||
// FastAPI-style: `{"detail":"Not Found"}`.
|
||
r#"provider returned 404: {"detail":"Not Found"}"#,
|
||
// Bare HTML — happens when the user pointed at a non-API origin
|
||
// (e.g. the provider's docs site).
|
||
"provider returned 404: <html><body>Not Found</body></html>",
|
||
// After `truncate_with_ellipsis(.., 300)` clips a longer body —
|
||
// prefix anchor must still match.
|
||
r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn couples_list_models_404_source_shape_to_classifier() {
|
||
// TAURI-RUST-8X3 coupling guard. Ties the TYPED SOURCE error shape
|
||
// emitted by `inference/provider/ops/models.rs` (the
|
||
// `provider returned 404: <body>` format) to the classifier, so a
|
||
// wording / prefix drift fails CI instead of silently leaking events.
|
||
//
|
||
// The wild Sentry message carried the `inference/ops.rs`
|
||
// `error!("[inference::ops] list_models:error: {err}")` PREFIX. The
|
||
// primary fix classifies the raw `err` at the source before that
|
||
// prefix is applied, but the `contains` widening above must ALSO
|
||
// catch the prefixed variant for any future prefixed re-report path.
|
||
// Assert BOTH the raw source shape and the prefixed log-line shape.
|
||
|
||
// (a) Raw source shape — exactly what `models.rs` returns for a Go
|
||
// default-handler 404 (`404 page not found`).
|
||
let raw_source = "provider returned 404: 404 page not found";
|
||
assert_eq!(
|
||
expected_error_kind(raw_source),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"raw list_models 404 source shape must classify as ProviderUserState"
|
||
);
|
||
|
||
// (b) Raw source shape WITH the actionable hint appended by
|
||
// `models.rs` for the 404 case — the prefix anchor must survive
|
||
// the suffix.
|
||
let raw_with_hint = "provider returned 404: 404 page not found — the configured base URL does not expose a `/models` endpoint; check the provider's base URL (it usually ends in `/v1`)";
|
||
assert_eq!(
|
||
expected_error_kind(raw_with_hint),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"list_models 404 + actionable hint must still classify as ProviderUserState"
|
||
);
|
||
|
||
// (c) Prefixed log-line shape — the exact pattern from
|
||
// `inference/ops.rs::inference_list_models` `error!`. The explicit
|
||
// `list_models:error: provider returned 404` anchor must catch this
|
||
// even though it does not start with `provider returned 404`. The
|
||
// anchor is the formatted prefix, not a bare `404` substring, so it
|
||
// does NOT mis-fire on a 500 whose body merely relays an upstream
|
||
// 404 (see `does_not_classify_non_404_list_models_failures_as_user_state`).
|
||
let prefixed =
|
||
"[inference::ops] list_models:error: provider returned 404: 404 page not found";
|
||
assert_eq!(
|
||
expected_error_kind(prefixed),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"prefixed list_models 404 log line must still classify as ProviderUserState (anchored prefix)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_non_404_list_models_failures_as_user_state() {
|
||
// Discrimination guard: only the 404 prefix demotes. Sibling 4xx /
|
||
// 5xx codes from the same `provider returned NNN:` emit site must
|
||
// stay actionable in Sentry — they map to BYO-key auth walls (401 /
|
||
// 403), client-shape bugs (400), and transient / server faults
|
||
// (429 / 5xx) respectively. Pinning each shape here protects the
|
||
// #2286 BYO-key 401 contract and prevents the arm from silently
|
||
// widening to all 4xx.
|
||
for raw in [
|
||
// BYO-key auth wall — must still escalate (`does_not_classify_byo_key_provider_401_as_session_expired` sibling guard).
|
||
r#"provider returned 401: {"error":"Invalid API key"}"#,
|
||
r#"provider returned 403: {"error":"Forbidden: API key revoked"}"#,
|
||
// Request-shape mismatch — likely a bug in our client.
|
||
r#"provider returned 400: {"error":"Bad Request"}"#,
|
||
// Transient — caught by retry/backoff at the provider layer,
|
||
// does NOT belong in the user-state bucket.
|
||
r#"provider returned 429: {"error":"rate_limited"}"#,
|
||
r#"provider returned 503: upstream temporarily unavailable"#,
|
||
// 500 — a real upstream bug; must reach Sentry.
|
||
r#"provider returned 500: {"error":"internal_server_error"}"#,
|
||
// TAURI-RUST-8X3 false-negative guard: a genuine 4xx/5xx whose
|
||
// *body* merely relays an upstream 404 phrase. The actual status
|
||
// is 500/400, so this is a real failure that MUST reach Sentry —
|
||
// the classifier anchors on the `provider returned 404:` prefix,
|
||
// not on any `404` occurrence in the body, so these must NOT demote.
|
||
r#"provider returned 500: {"error":"upstream provider returned 404 page not found"}"#,
|
||
r#"provider returned 400: gateway error — upstream provider returned 404"#,
|
||
// Prefixed log-line variant of the same trap: the genuine status is
|
||
// 500, the buried `... 404` is body text.
|
||
"[inference::ops] list_models:error: provider returned 500: upstream provider returned 404 not found",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"non-404 list_models failure must NOT demote to ProviderUserState: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_local_ai_binary_missing_errors() {
|
||
// OPENHUMAN-TAURI-9N: `local_ai_tts` returns this exact string
|
||
// from `service::speech::tts` when piper isn't on PATH or
|
||
// `PIPER_BIN` isn't set.
|
||
assert_eq!(
|
||
expected_error_kind("piper binary not found. Set PIPER_BIN or install piper."),
|
||
Some(ExpectedErrorKind::LocalAiBinaryMissing)
|
||
);
|
||
// Sibling shapes from the same service area share the anchor and
|
||
// must classify the same way — the user-facing remediation is
|
||
// identical (install / configure the binary).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"whisper.cpp binary not found. Set WHISPER_BIN or install whisper-cli."
|
||
),
|
||
Some(ExpectedErrorKind::LocalAiBinaryMissing)
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Ollama binary not found at '/usr/local/bin/ollama'. Provide a valid path to the ollama executable."
|
||
),
|
||
Some(ExpectedErrorKind::LocalAiBinaryMissing)
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("Ollama installed but binary not found on system"),
|
||
Some(ExpectedErrorKind::LocalAiBinaryMissing)
|
||
);
|
||
// Wrapped by the RPC dispatcher in production:
|
||
// `"rpc.invoke_method failed: piper binary not found. …"`.
|
||
// The classifier is substring-based, so caller context must not
|
||
// defeat it.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: piper binary not found. Set PIPER_BIN or install piper."
|
||
),
|
||
Some(ExpectedErrorKind::LocalAiBinaryMissing)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_binary_missing() {
|
||
// Pin the anchor: messages that talk about binaries in a
|
||
// different context (download failures, version mismatches)
|
||
// must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind("piper binary failed to spawn: permission denied"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("whisper.cpp returned empty transcript"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_session_expired_messages() {
|
||
// OPENHUMAN-TAURI-26: the canonical wire shape that `agent.run_single`
|
||
// and `web_channel.run_chat_task` re-emit via `report_error_or_expected`
|
||
// when the user's JWT expires mid-conversation. The classifier
|
||
// anchors on the literal `"session expired"` substring from the
|
||
// OpenHuman backend's 401 body — NOT on the bare `(401 Unauthorized)`
|
||
// status, which would also silence BYO-key OpenAI/Anthropic 401s
|
||
// that are actionable.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"#
|
||
),
|
||
Some(ExpectedErrorKind::SessionExpired)
|
||
);
|
||
|
||
// Wrapped by the agent / web-channel report sites in production —
|
||
// the classifier is substring-based so caller context must not
|
||
// defeat it.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Session expired. Please log in again."}"#
|
||
),
|
||
Some(ExpectedErrorKind::SessionExpired)
|
||
);
|
||
|
||
// Sentinel raised by `providers::openhuman_backend::resolve_bearer`
|
||
// when the scheduler-gate signed-out override is set
|
||
// (OPENHUMAN-TAURI-1T's cascade dampener returns this so callers
|
||
// get the same teardown path as a real backend 401).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"SESSION_EXPIRED: backend session not active — sign in to resume LLM work"
|
||
),
|
||
Some(ExpectedErrorKind::SessionExpired)
|
||
);
|
||
|
||
// Local pre-flight guards — OpenHuman-specific phrasing, safe to
|
||
// match regardless of caller wrapping.
|
||
for raw in [
|
||
"no backend session token; run auth_store_session first",
|
||
"session JWT required",
|
||
"composio unavailable: no backend session token. Sign in first (auth_store_session).",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"should classify as session-expired: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// OPENHUMAN-TAURI-SG (33 events, escalating, release `0.53.43+2b64ea8…`):
|
||
/// pre-#1763 leak of the `resolve_bearer` sentinel through
|
||
/// `agent.run_single`. PR #1763 (1fb0bef5) wired the `SessionExpired`
|
||
/// arm and the existing `classifies_session_expired_messages` test
|
||
/// covers the same byte string — this test pins the *Sentry-event
|
||
/// verbatim* shape (taken from the OPENHUMAN-TAURI-SG event payload)
|
||
/// so a future tweak to `is_session_expired_message` cannot regress
|
||
/// this exact wire form without a red test.
|
||
#[test]
|
||
fn session_expired_sg_wire_shape_matches() {
|
||
let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"OPENHUMAN-TAURI-SG wire shape must classify as SessionExpired — \
|
||
a regression here re-leaks 33+ events/cycle to Sentry"
|
||
);
|
||
}
|
||
|
||
/// The two sibling `SESSION_EXPIRED:` bail sites in
|
||
/// `providers::factory::verify_session_active` emit different message
|
||
/// suffixes but the same sentinel prefix. They route through the same
|
||
/// classifier as the run_single bail at
|
||
/// `providers::openhuman_backend::resolve_bearer`, and any matcher
|
||
/// tweak that breaks the family (e.g. moving from `contains` to a
|
||
/// stricter prefix/suffix match) would re-leak ALL of them. Pin every
|
||
/// variant the codebase actually emits so a future regression on the
|
||
/// matcher is caught for the whole family, not just the SG instance.
|
||
#[test]
|
||
fn session_expired_sibling_family_factory_strings_match() {
|
||
// src/openhuman/inference/provider/factory.rs:247
|
||
// (verify_session_active — scheduler_gate signed-out path)
|
||
let custom_providers_variant =
|
||
"SESSION_EXPIRED: backend session not active — sign in to use custom providers";
|
||
// src/openhuman/inference/provider/factory.rs:266
|
||
// (verify_session_active — empty auth-profile JWT path)
|
||
let no_backend_session_variant =
|
||
"SESSION_EXPIRED: no backend session — sign in to use OpenHuman";
|
||
|
||
for raw in [custom_providers_variant, no_backend_session_variant] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"factory.rs sibling sentinel must classify as SessionExpired: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// OPENHUMAN-TAURI-4P0: the OpenHuman backend rejects an expired/
|
||
/// revoked JWT with the envelope `{"success":false,"error":"Invalid
|
||
/// token"}` (vs. the explicit `"Session expired. Please log in again."`
|
||
/// body covered by `classifies_session_expired_messages`). Same emit
|
||
/// site, same wrapping by `web_channel.run_chat_task`, but the body
|
||
/// substring is different.
|
||
///
|
||
/// The matcher uses a conjunctive `"OpenHuman API error (401"` +
|
||
/// envelope-shaped `"\"error\":\"Invalid token\""` anchor pair so the
|
||
/// #2286 contract for bare `"Invalid token"` / BYO-key 401s is
|
||
/// preserved — `does_not_classify_byo_key_provider_401_as_session_expired`
|
||
/// pins that and must stay green.
|
||
#[test]
|
||
fn classifies_openhuman_invalid_token_401_as_session_expired() {
|
||
// Verbatim wire shape from the OPENHUMAN-TAURI-4P0 event payload.
|
||
let msg = r#"run_chat_task failed client_id=lssXhQidBfzGXG9k thread_id=thread-743193ba-f0c1-4008-b665-64d3030d1453 request_id=00696b71-fa05-4574-bcdb-5744a5dac6ea error=OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"OPENHUMAN-TAURI-4P0 verbatim wire shape must classify as SessionExpired"
|
||
);
|
||
|
||
// Unwrapped emit shape (without the run_chat_task prefix) — also
|
||
// appears at provider/agent layers; the substring matcher must
|
||
// catch it regardless of caller wrapping.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
r#"OpenHuman API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#
|
||
),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"unwrapped OpenHuman invalid-token envelope must classify as SessionExpired"
|
||
);
|
||
}
|
||
|
||
/// TAURI-RUST-4K5 (118 events, escalating on 0.56.0): the embedding
|
||
/// client at `src/openhuman/embeddings/openai.rs:139` wraps the same
|
||
/// OpenHuman backend `{"success":false,"error":"Invalid token"}` 401
|
||
/// envelope as 4P0, but with the `"Embedding API error"` prefix
|
||
/// instead of `"OpenHuman API error"` (different emit-site format
|
||
/// string, same underlying session-expired cause — see breadcrumb
|
||
/// `[scheduler_gate] signed_out false -> true` immediately preceding
|
||
/// the 401 in the event payload).
|
||
///
|
||
/// Uses the same conjunctive `"<prefix> (401"` + envelope-shaped
|
||
/// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the
|
||
/// #2286 / BYO-key contract is preserved — covered by
|
||
/// `does_not_classify_byo_key_provider_401_as_session_expired` and
|
||
/// `does_not_classify_embedding_byo_key_401_as_session_expired`
|
||
/// (below).
|
||
#[test]
|
||
fn classifies_embedding_api_invalid_token_401_as_session_expired() {
|
||
// Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry
|
||
// issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0,
|
||
// domain=embeddings operation=openai_embed status=401).
|
||
let msg =
|
||
r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired"
|
||
);
|
||
|
||
// The substring matcher must survive caller wrapping the same way
|
||
// the 4P0 web-channel `run_chat_task` test wraps the body — callers
|
||
// that re-emit through a tracing field or another layer prepend
|
||
// arbitrary context.
|
||
let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(wrapped),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"wrapped 4K5 envelope must still classify as SessionExpired"
|
||
);
|
||
}
|
||
|
||
/// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on
|
||
/// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman
|
||
/// backend `{"success":false,"error":"Invalid token"}` 401 envelope
|
||
/// with the `"OpenHuman streaming API error"` prefix (emitted at
|
||
/// `inference/provider/compatible.rs:949`) — distinct from the
|
||
/// non-streaming `"OpenHuman API error"` prefix (4P0) and the
|
||
/// `"Embedding API error"` prefix (4K5). The `streaming` token between
|
||
/// `OpenHuman` and `API error` means the 4P0 anchor
|
||
/// (`"OpenHuman API error (401"`) does not match it, so it needs its
|
||
/// own prefix arm.
|
||
#[test]
|
||
fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() {
|
||
// Verbatim wire shape from the TAURI-RUST-1EE event payload
|
||
// (domain=llm_provider operation=streaming_chat status=401
|
||
// provider=OpenHuman model=reasoning-v1).
|
||
let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired"
|
||
);
|
||
|
||
// Caller-wrapped (agent.run_single / web_channel.run_chat_task
|
||
// re-emit prepends context) must still classify.
|
||
let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(wrapped),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"wrapped 1EE streaming envelope must still classify as SessionExpired"
|
||
);
|
||
}
|
||
|
||
/// Polarity guard for the 1EE streaming arm — a third-party BYO-key
|
||
/// provider's streaming 401 (`"OpenAI streaming API error (401 …):
|
||
/// invalid_api_key"`) must STILL reach Sentry as actionable
|
||
/// misconfiguration. The `"OpenHuman streaming API error (401"` prefix
|
||
/// gate keeps the match OpenHuman-scoped.
|
||
#[test]
|
||
fn does_not_classify_streaming_byo_key_401_as_session_expired() {
|
||
for raw in [
|
||
"OpenAI streaming API error (401 Unauthorized): invalid_api_key",
|
||
r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
"Anthropic streaming API error (401): authentication_error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key streaming 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Polarity guard for the 4K5 arm. The classifier must NOT swallow
|
||
/// `"Embedding API error (401 …)"` shapes from third-party BYO-key
|
||
/// embedding providers (OpenAI / Voyage / Cohere upstream rejecting
|
||
/// the user's own API key). Those are actionable user-config errors
|
||
/// that need to reach Sentry — same contract as
|
||
/// `does_not_classify_byo_key_provider_401_as_session_expired` for
|
||
/// the OpenAI chat API.
|
||
#[test]
|
||
fn does_not_classify_embedding_byo_key_401_as_session_expired() {
|
||
for raw in [
|
||
"Embedding API error (401 Unauthorized): invalid_api_key",
|
||
r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
// Wire shape without the OpenHuman envelope — bare provider
|
||
// rejection prose. Must reach Sentry as actionable BYO-key
|
||
// misconfiguration.
|
||
"Embedding API error (401): authentication_error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key embedding 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_byo_key_provider_401_as_session_expired() {
|
||
// Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an
|
||
// actionable misconfiguration (wrong API key) that the user needs
|
||
// to fix in settings. It must reach Sentry as an error and must
|
||
// NOT be classified as session-expired at the agent layer — the
|
||
// strict classifier requires the OpenHuman backend's
|
||
// "session expired" body to anchor the match. The JSON-RPC
|
||
// dispatch-site classifier uses the same strict rule so these
|
||
// scoped provider failures never clear the app session either.
|
||
for raw in [
|
||
"OpenAI API error (401 Unauthorized): invalid_api_key",
|
||
"Anthropic API error (401 Unauthorized): authentication_error",
|
||
"OpenAI API error (401): unauthorized",
|
||
r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
// Generic "invalid token" without OpenHuman session phrasing —
|
||
// could mean a third-party provider rejected its own token.
|
||
"Invalid token",
|
||
"got an invalid token here",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key / generic 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_session_expired() {
|
||
// Bare numeric 401 (port number, runbook reference) must not be
|
||
// silenced.
|
||
assert_eq!(expected_error_kind("server returned 401"), None);
|
||
assert_eq!(
|
||
expected_error_kind("see runbook for 401 handling at https://example.com/401"),
|
||
None
|
||
);
|
||
// Provider 5xx — must reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (500): internal server error"),
|
||
None
|
||
);
|
||
// Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel
|
||
// is case-sensitive by design (matches the sentinel emitted by
|
||
// `providers::openhuman_backend::resolve_bearer` exactly).
|
||
assert_eq!(expected_error_kind("session_expired lowercase"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn report_error_does_not_panic_with_many_tags() {
|
||
let err = anyhow::anyhow!("multi-tag");
|
||
report_error(
|
||
&err,
|
||
"test",
|
||
"multi_tag",
|
||
&[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
|
||
);
|
||
}
|
||
|
||
fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> {
|
||
let mut event = sentry::protocol::Event::default();
|
||
let mut tags: std::collections::BTreeMap<String, String> =
|
||
std::collections::BTreeMap::new();
|
||
for (k, v) in pairs {
|
||
tags.insert((*k).to_string(), (*v).to_string());
|
||
}
|
||
event.tags = tags;
|
||
event
|
||
}
|
||
|
||
fn event_with_tags_and_message(
|
||
pairs: &[(&str, &str)],
|
||
message: &str,
|
||
) -> sentry::protocol::Event<'static> {
|
||
let mut event = event_with_tags(pairs);
|
||
event.message = Some(message.to_string());
|
||
event
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_drops_429_408_502_503_504() {
|
||
for status in ["429", "408", "502", "503", "504"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "llm_provider"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_transient_provider_http_failure(&event),
|
||
"status {status} must be classified as transient and filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn custom_openai_502_event_shape_is_transient_provider_http() {
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "llm_provider"),
|
||
("provider", "custom_openai"),
|
||
("failure", "non_2xx"),
|
||
("status", "502"),
|
||
],
|
||
"custom_openai API error (502 Bad Gateway): upstream gateway blip",
|
||
);
|
||
assert!(
|
||
is_transient_provider_http_failure(&event),
|
||
"custom_openai 502 attempts should be treated as transient provider HTTP noise"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_keeps_permanent_failures() {
|
||
for status in ["400", "401", "403", "404", "500"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "llm_provider"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
!is_transient_provider_http_failure(&event),
|
||
"status {status} must NOT be filtered — it's actionable"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_keeps_aggregate_all_exhausted() {
|
||
let event = event_with_tags(&[
|
||
("domain", "llm_provider"),
|
||
("failure", "all_exhausted"),
|
||
("status", "503"),
|
||
]);
|
||
assert!(
|
||
!is_transient_provider_http_failure(&event),
|
||
"aggregate all_exhausted events must surface (they are the cascade signal)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_keeps_events_with_no_status_tag() {
|
||
let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]);
|
||
assert!(
|
||
!is_transient_provider_http_failure(&event),
|
||
"missing status tag must not be silently dropped"
|
||
);
|
||
}
|
||
|
||
// Regression guard: the filter must scope to provider events only. Other
|
||
// subsystems emit `failure=non_2xx` (e.g.
|
||
// `providers/compatible.rs` uses the same marker for OAI-compatible
|
||
// error paths, but every site goes through `report_error(..,
|
||
// "llm_provider", ..)` so the domain tag is consistent), but the broader
|
||
// point is: any future caller that re-uses the same tag set for a
|
||
// different domain must NOT be silently dropped by this filter.
|
||
#[test]
|
||
fn transient_filter_keeps_events_with_no_domain_tag() {
|
||
let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]);
|
||
assert!(
|
||
!is_transient_provider_http_failure(&event),
|
||
"missing domain tag means the event isn't provider-originated — must surface"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_keeps_events_from_other_domains() {
|
||
let event = event_with_tags(&[
|
||
("domain", "scheduler"),
|
||
("failure", "non_2xx"),
|
||
("status", "503"),
|
||
]);
|
||
assert!(
|
||
!is_transient_provider_http_failure(&event),
|
||
"non-provider domain must surface even if failure/status tags collide"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn backend_api_filter_drops_transient_statuses() {
|
||
for status in TRANSIENT_HTTP_STATUSES {
|
||
let event = event_with_tags(&[
|
||
("domain", "backend_api"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_transient_backend_api_failure(&event),
|
||
"backend status {status} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn backend_api_filter_drops_transient_transport_phrases() {
|
||
for phrase in TRANSIENT_TRANSPORT_PHRASES {
|
||
let event = event_with_tags_and_message(
|
||
&[("domain", "backend_api"), ("failure", "transport")],
|
||
&format!("GET /teams failed: {phrase}"),
|
||
);
|
||
assert!(
|
||
is_transient_backend_api_failure(&event),
|
||
"backend transport phrase {phrase} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn backend_api_filter_keeps_non_transient_failures() {
|
||
for status in ["404", "500"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "backend_api"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
!is_transient_backend_api_failure(&event),
|
||
"backend status {status} must stay visible"
|
||
);
|
||
}
|
||
|
||
let wrong_domain = event_with_tags(&[
|
||
("domain", "scheduler"),
|
||
("failure", "non_2xx"),
|
||
("status", "503"),
|
||
]);
|
||
assert!(
|
||
!is_transient_backend_api_failure(&wrong_domain),
|
||
"domain scoping must keep unrelated transient-shaped events visible"
|
||
);
|
||
|
||
let non_matching_transport = event_with_tags_and_message(
|
||
&[("domain", "backend_api"), ("failure", "transport")],
|
||
"GET /teams failed: certificate verify failed",
|
||
);
|
||
assert!(
|
||
!is_transient_backend_api_failure(&non_matching_transport),
|
||
"transport failures without an allowlisted phrase must stay visible"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn integrations_filter_drops_transient_statuses() {
|
||
for status in TRANSIENT_HTTP_STATUSES {
|
||
let event = event_with_tags(&[
|
||
("domain", "integrations"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"integrations status {status} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn integrations_filter_drops_transient_transport_phrases() {
|
||
for phrase in TRANSIENT_TRANSPORT_PHRASES {
|
||
let event = event_with_tags_and_message(
|
||
&[("domain", "integrations"), ("failure", "transport")],
|
||
&format!("GET /agent-integrations/tools failed: {phrase}"),
|
||
);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"integrations transport phrase {phrase} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn integrations_filter_keeps_non_transient_failures() {
|
||
for status in ["404", "500"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "integrations"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
!is_transient_integrations_failure(&event),
|
||
"integrations status {status} must stay visible"
|
||
);
|
||
}
|
||
|
||
// Sibling-domain check: composio op-layer events MUST be silenced
|
||
// by the integrations filter — composio routes through the same
|
||
// `IntegrationClient` so the failure shape is identical, but
|
||
// op-level reporters that wrap and re-emit with their own domain
|
||
// tag would otherwise escape (OPENHUMAN-TAURI-35 / -2H).
|
||
let scheduler_domain = event_with_tags(&[
|
||
("domain", "scheduler"),
|
||
("failure", "non_2xx"),
|
||
("status", "503"),
|
||
]);
|
||
assert!(
|
||
!is_transient_integrations_failure(&scheduler_domain),
|
||
"domain scoping must keep unrelated transient-shaped events visible"
|
||
);
|
||
|
||
let non_matching_transport = event_with_tags_and_message(
|
||
&[("domain", "integrations"), ("failure", "transport")],
|
||
"GET /agent-integrations/tools failed: invalid certificate",
|
||
);
|
||
assert!(
|
||
!is_transient_integrations_failure(&non_matching_transport),
|
||
"transport failures without an allowlisted phrase must stay visible"
|
||
);
|
||
}
|
||
|
||
/// TAURI-RUST-CGE: skill-install fetch 4xx (a missing/renamed catalog
|
||
/// `SKILL.md`) is expected user-input state — the before_send net must drop
|
||
/// it, while a genuine 5xx remote failure and unrelated domains stay
|
||
/// reportable.
|
||
#[test]
|
||
fn skills_install_client_error_filter_drops_4xx_keeps_5xx() {
|
||
// 4xx (esp. 404/410) = missing skill / wrong URL → dropped.
|
||
for status in ["400", "403", "404", "410", "429"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "skills"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_skills_install_client_error_event(&event),
|
||
"skills install 4xx {status} must be dropped"
|
||
);
|
||
}
|
||
|
||
// 5xx = genuine remote failure → stays a Sentry signal.
|
||
for status in ["500", "502", "503"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "skills"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
!is_skills_install_client_error_event(&event),
|
||
"skills install 5xx {status} must stay reportable"
|
||
);
|
||
}
|
||
|
||
// Domain scoping: a 4xx in another domain must not be touched here.
|
||
let other_domain = event_with_tags(&[
|
||
("domain", "backend_api"),
|
||
("failure", "non_2xx"),
|
||
("status", "404"),
|
||
]);
|
||
assert!(
|
||
!is_skills_install_client_error_event(&other_domain),
|
||
"non-skills 4xx must not be swallowed by the skills filter"
|
||
);
|
||
|
||
// A skills event without the non_2xx failure marker (e.g. a transport
|
||
// failure) must not be dropped by this status-scoped filter.
|
||
let skills_transport = event_with_tags(&[("domain", "skills"), ("failure", "transport")]);
|
||
assert!(
|
||
!is_skills_install_client_error_event(&skills_transport),
|
||
"skills transport failures are out of scope for the 4xx filter"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn composio_domain_routes_through_integrations_filter() {
|
||
// OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events):
|
||
// `[composio] list_connections failed: Backend returned 502 …` —
|
||
// composio op-layer wrappers (e.g. `composio_list_connections`) emit
|
||
// errors under `domain="composio"` so the original
|
||
// `domain="integrations"` filter let them through. Routing the
|
||
// composio domain through the same transient classifier closes
|
||
// that gap; the underlying transport / non_2xx semantics are
|
||
// identical because both layers share the same `IntegrationClient`.
|
||
for status in TRANSIENT_HTTP_STATUSES {
|
||
let event = event_with_tags(&[
|
||
("domain", "composio"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"composio status {status} must be classified as transient"
|
||
);
|
||
}
|
||
|
||
// Transport-phrase variant — composio also surfaces reqwest
|
||
// transport failures (timeouts, connection resets) once the op
|
||
// wrapper has tagged the event with `failure=transport`.
|
||
for phrase in TRANSIENT_TRANSPORT_PHRASES {
|
||
let event = event_with_tags_and_message(
|
||
&[("domain", "composio"), ("failure", "transport")],
|
||
&format!("[composio] execute failed: {phrase}"),
|
||
);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"composio transport phrase {phrase} must be classified as transient"
|
||
);
|
||
}
|
||
|
||
// Non-transient composio statuses (404 / 500) must still surface —
|
||
// actionable bugs even when reported under the composio domain.
|
||
for status in ["404", "500"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "composio"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
!is_transient_integrations_failure(&event),
|
||
"composio status {status} must stay visible"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn composio_list_connections_503_504_wrappers_stay_filtered() {
|
||
for (status, reason) in [("503", "Service Unavailable"), ("504", "Gateway Timeout")] {
|
||
let message = format!(
|
||
"[composio] list_connections failed: Backend returned {status} {reason} \
|
||
for GET /agent-integrations/composio/connections"
|
||
);
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "composio"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
],
|
||
&message,
|
||
);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"wrapped composio list_connections {status} failures must be filtered"
|
||
);
|
||
}
|
||
|
||
for (status, reason) in [("503", "Service Unavailable"), ("504", "Gateway Timeout")] {
|
||
let message = format!(
|
||
"Backend returned {status} {reason} \
|
||
for GET /agent-integrations/composio/connections"
|
||
);
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "integrations"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
],
|
||
&message,
|
||
);
|
||
assert!(
|
||
is_transient_integrations_failure(&event),
|
||
"raw integrations list_connections {status} failures must be filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn updater_transient_403_is_dropped() {
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "update"),
|
||
("operation", "check_releases"),
|
||
("failure", "non_2xx"),
|
||
("status", "403"),
|
||
],
|
||
"[observability] update.check_releases failed: GitHub API error: 403 Forbidden",
|
||
);
|
||
assert!(
|
||
is_updater_transient_event(&event),
|
||
"GitHub 403 updater checks are unactionable transient/rate-limit noise"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn updater_github_403_message_only_shapes_are_dropped() {
|
||
for event in [
|
||
event_with_message("GitHub API error: 403 Forbidden"),
|
||
event_with_exception_value("GitHub API error: 403 Forbidden"),
|
||
] {
|
||
assert!(
|
||
is_updater_transient_event(&event),
|
||
"message-only GitHub 403 updater failures must be filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn updater_transient_502_is_dropped() {
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "update.check_releases"),
|
||
("failure", "non_2xx"),
|
||
("status", "502"),
|
||
],
|
||
"GitHub API error: 502 Bad Gateway",
|
||
);
|
||
assert!(
|
||
is_updater_transient_event(&event),
|
||
"GitHub 5xx updater checks must be filtered as transient"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn updater_real_panic_still_reported() {
|
||
let event = event_with_tags_and_message(
|
||
&[("domain", "update"), ("operation", "check_releases")],
|
||
"thread 'main' panicked at src/openhuman/update/core.rs: index out of bounds",
|
||
);
|
||
assert!(
|
||
!is_updater_transient_event(&event),
|
||
"update-domain events without a transient updater shape must still reach Sentry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn updater_endpoint_non_success_message_is_dropped() {
|
||
// TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater`
|
||
// logs `update endpoint did not respond with a successful status code`
|
||
// (updater.rs) on any non-2xx response and discards the status, so the
|
||
// captured event has NO `domain`/`status` tag — only the bare message.
|
||
// It can therefore only be matched via the message fast-path.
|
||
assert!(is_updater_transient_message(
|
||
"update endpoint did not respond with a successful status code"
|
||
));
|
||
|
||
let event = event_with_tags_and_message(
|
||
&[],
|
||
"update endpoint did not respond with a successful status code",
|
||
);
|
||
assert!(
|
||
is_updater_transient_event(&event),
|
||
"the plugin's status-blind, domain-less non-success log line is unactionable updater noise"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() {
|
||
// The new anchor is the literal plugin string. Other updater failures
|
||
// that DO carry an actionable signal (signature/permission failures on
|
||
// apply, deserialize errors) and unrelated non-updater errors that
|
||
// merely mention a status code MUST NOT be dropped by it. Pin the
|
||
// rejection contract so a future refactor doesn't loosen the substring.
|
||
for msg in [
|
||
"failed to apply update: signature verification failed",
|
||
"failed to deserialize update response: missing field `version`",
|
||
"backend request to /agent-integrations failed with status code 500",
|
||
"tool exited with non-zero status code 1",
|
||
] {
|
||
let event = event_with_tags_and_message(&[], msg);
|
||
assert!(
|
||
!is_updater_transient_event(&event),
|
||
"unrelated/actionable error must still reach Sentry: {msg}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn message_failure_classifier_matches_canonical_status_phrases() {
|
||
for msg in [
|
||
"rpc.invoke_method failed: GET /teams failed (502 Bad Gateway)",
|
||
"GET /teams/me/usage failed (503 Service Unavailable)",
|
||
"downstream returned (504 Gateway Timeout): retry budget exhausted",
|
||
"OpenHuman API error (520 <unknown status code>): cf",
|
||
"POST /channels/telegram/typing failed (429 Too Many Requests)",
|
||
"auth connect failed: 503 Service Unavailable",
|
||
] {
|
||
assert!(
|
||
is_transient_message_failure(msg),
|
||
"{msg:?} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn message_failure_classifier_matches_transport_phrases() {
|
||
for msg in [
|
||
"integrations.get failed: composio/tools → operation timed out",
|
||
"GET https://api.example.com → connection forcibly closed (os 10054)",
|
||
"POST /v1/foo → tls handshake eof",
|
||
"error sending request for url (https://api.example.com)",
|
||
] {
|
||
assert!(
|
||
is_transient_message_failure(msg),
|
||
"{msg:?} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn message_failure_classifier_keeps_unrelated_messages() {
|
||
for msg in [
|
||
"rpc.invoke_method failed: schema validation error",
|
||
"process 502 exited unexpectedly",
|
||
"GET /teams failed (404 Not Found)",
|
||
"GET /teams failed (500 Internal Server Error)",
|
||
"unrelated error with port 5023",
|
||
"",
|
||
] {
|
||
assert!(
|
||
!is_transient_message_failure(msg),
|
||
"{msg:?} must not be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_drops_budget_message_on_tagged_400() {
|
||
let event = event_with_tags_and_message(
|
||
&[("failure", "non_2xx"), ("status", "400")],
|
||
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#,
|
||
);
|
||
|
||
assert!(is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_drops_budget_exception_on_tagged_400() {
|
||
let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]);
|
||
event.exception.values.push(sentry::protocol::Exception {
|
||
value: Some("Budget exceeded — add credits to continue".to_string()),
|
||
..Default::default()
|
||
});
|
||
|
||
assert!(is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_keeps_non_budget_400() {
|
||
let event = event_with_tags_and_message(
|
||
&[("failure", "non_2xx"), ("status", "400")],
|
||
"Bad request: missing field",
|
||
);
|
||
|
||
assert!(!is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_requires_non_2xx_failure_and_400_status() {
|
||
let message = "Budget exceeded — add credits to continue";
|
||
for tags in [
|
||
vec![("failure", "transport"), ("status", "400")],
|
||
vec![("failure", "non_2xx"), ("status", "500")],
|
||
vec![("failure", "non_2xx")],
|
||
] {
|
||
let event = event_with_tags_and_message(&tags, message);
|
||
assert!(!is_budget_event(&event));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn report_error_or_expected_does_not_panic() {
|
||
report_error_or_expected(
|
||
"local ai is disabled",
|
||
"rpc",
|
||
"invoke_method",
|
||
&[("method", "openhuman.inference_prompt")],
|
||
);
|
||
report_error_or_expected(
|
||
"ollama API key not set",
|
||
"agent",
|
||
"provider_chat",
|
||
&[("provider", "ollama")],
|
||
);
|
||
// #2079 / #2076 / #2202 — exercises the expected_error_kind
|
||
// ProviderConfigRejection branch AND the report_expected_message
|
||
// skip-log arm (the agent/web-channel re-report demotion path).
|
||
report_error_or_expected(
|
||
"agent.run_single failed: custom_openai API error (400 Bad Request): \
|
||
The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \
|
||
but you passed reasoning-v1.",
|
||
"agent",
|
||
"native_chat",
|
||
&[("provider", "custom_openai")],
|
||
);
|
||
report_error_or_expected(
|
||
"custom_openai API error (400): invalid temperature: only 1 is allowed for this model",
|
||
"web_channel",
|
||
"run_chat_task",
|
||
&[("provider", "custom_openai")],
|
||
);
|
||
}
|
||
|
||
fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> {
|
||
let mut event = sentry::protocol::Event::default();
|
||
event.message = Some(msg.to_string());
|
||
event
|
||
}
|
||
|
||
fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> {
|
||
let mut event = sentry::protocol::Event::default();
|
||
event.exception = vec![sentry::protocol::Exception {
|
||
value: Some(value.to_string()),
|
||
..Default::default()
|
||
}]
|
||
.into();
|
||
event
|
||
}
|
||
|
||
#[test]
|
||
fn quota_exhausted_filter_matches_500_wrapped_kiro_event() {
|
||
// TAURI-RUST-C9A: verbatim message as formatted by the provider emit
|
||
// site — a 500 envelope around an inner 402 / MONTHLY_REQUEST_COUNT.
|
||
// The status-agnostic quota filter must catch it on the message path
|
||
// and the exception path.
|
||
let body = "kiro API error (500 Internal Server Error): {\"error\":{\"message\":\
|
||
\"HTTP 402 from Kiro IDE: {\\\"reason\\\":\\\"MONTHLY_REQUEST_COUNT\\\"}\",\
|
||
\"type\":\"server_error\"}}";
|
||
assert!(is_quota_exhausted_event(&event_with_message(body)));
|
||
assert!(is_quota_exhausted_event(&event_with_exception_value(body)));
|
||
assert!(is_quota_exhausted_message(body));
|
||
}
|
||
|
||
#[test]
|
||
fn quota_exhausted_filter_matches_responses_usage_limit_reached_event() {
|
||
// TAURI-RUST-AFE: verbatim message as formatted by the `chat_via_responses`
|
||
// emit site — the Codex/ChatGPT OAuth `/responses` plan cap. Mirrors the
|
||
// real production shape `"<name> Responses API error (<status>): <body>"`
|
||
// (`compatible_helpers.rs:135`), including the `(429)` status segment and
|
||
// the full AFE payload (`plan_type` + `resets_at`) from `ops/http_error.rs`,
|
||
// so this stays coupled to the actual wire format rather than a loose
|
||
// substring. No "monthly"/"quota" co-marker, so it exercises the AFE
|
||
// phrase extension reaching the before_send net on both message and
|
||
// exception paths (the subconscious loop retries until `resets_at`).
|
||
let body = "openai Responses API error (429): {\"error\":{\"type\":\
|
||
\"usage_limit_reached\",\"message\":\"The usage limit has been reached\",\
|
||
\"plan_type\":\"plus\",\"resets_at\":1750000000}}";
|
||
assert!(is_quota_exhausted_event(&event_with_message(body)));
|
||
assert!(is_quota_exhausted_event(&event_with_exception_value(body)));
|
||
assert!(is_quota_exhausted_message(body));
|
||
}
|
||
|
||
#[test]
|
||
fn quota_exhausted_filter_ignores_generic_500_and_rate_limit() {
|
||
// A generic 500 outage and a 429 rate-limit are not plan-quota
|
||
// exhaustion — they must keep reaching Sentry / their own handling.
|
||
assert!(!is_quota_exhausted_event(&event_with_message(
|
||
"kiro API error (500 Internal Server Error): upstream connection reset"
|
||
)));
|
||
assert!(!is_quota_exhausted_event(&event_with_message(
|
||
"provider API error (429 Too Many Requests): rate_limit_exceeded"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn insufficient_credits_filter_matches_message_path() {
|
||
// Verbatim TAURI-RUST-C62 message as formatted by the provider emit
|
||
// sites: "<provider> API error (402 Payment Required): <body>".
|
||
let event = event_with_message(
|
||
"myopenrouter API error (402 Payment Required): This request requires more credits, \
|
||
or fewer max_tokens. You requested up to 65536 tokens, but can only afford 49732.",
|
||
);
|
||
assert!(is_insufficient_credits_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn insufficient_credits_filter_matches_exception_path() {
|
||
let event = event_with_exception_value(
|
||
"myopenrouter API error (402 Payment Required): insufficient balance",
|
||
);
|
||
assert!(is_insufficient_credits_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn insufficient_credits_filter_requires_both_402_and_credit_phrase() {
|
||
// A 402 with no credit phrase must NOT be swallowed (could be another
|
||
// payment semantic) ...
|
||
assert!(!is_insufficient_credits_event(&event_with_message(
|
||
"provider API error (402): some unrelated condition"
|
||
)));
|
||
// ... and a credit phrase without a 402 must NOT be swallowed (e.g. a
|
||
// 400/500 that merely mentions balance) so a real defect still pages.
|
||
assert!(!is_insufficient_credits_event(&event_with_message(
|
||
"provider API error (500): internal error, insufficient memory"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn insufficient_credits_filter_ignores_402_digits_in_a_non_402_body() {
|
||
// A non-402 error whose body merely contains the digits "402" and a
|
||
// credit phrase must NOT be suppressed — the 402 must be the status,
|
||
// not an arbitrary number in the body.
|
||
assert!(!is_insufficient_credits_event(&event_with_message(
|
||
"provider API error (400): can only afford 402 tokens"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn is_insufficient_credits_message_matches_verbatim_cron_402() {
|
||
// Verbatim TAURI-RUST-514 body as it reaches the cron `report_error`
|
||
// call site (`domain=cron`, `operation=agent_job`): the message-level
|
||
// matcher must catch it so the cron halt skips the leaking report.
|
||
assert!(is_insufficient_credits_message(
|
||
"openrouter API error (402 Payment Required): {\"error\":{\"message\":\"This \
|
||
request requires more credits, or fewer max_tokens. You requested up to 65536 \
|
||
tokens, but can only afford 5081.\"}}",
|
||
));
|
||
assert!(is_insufficient_credits_message(
|
||
"custom_openai API error (402 Payment Required): insufficient balance",
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_insufficient_credits_message_requires_402_and_credit_phrase() {
|
||
// A 402 without a credit phrase, and a credit phrase without a 402
|
||
// status, must both stay reportable (could be a real defect).
|
||
assert!(!is_insufficient_credits_message(
|
||
"provider API error (402): some unrelated condition"
|
||
));
|
||
assert!(!is_insufficient_credits_message(
|
||
"provider API error (500): internal error, insufficient memory"
|
||
));
|
||
// The status must be the 402, not a digit in the body.
|
||
assert!(!is_insufficient_credits_message(
|
||
"provider API error (400): can only afford 402 tokens"
|
||
));
|
||
// codex P2: the status prefix "(402 Payment Required)" itself contains
|
||
// the phrase "payment required". An unrelated body behind a real 402
|
||
// must NOT be classified as insufficient-credits (else the cron halt
|
||
// would suppress a genuine 402 defect). The credit signal must live in
|
||
// the BODY, not the formatted status prefix.
|
||
assert!(!is_insufficient_credits_message(
|
||
"provider API error (402 Payment Required): some unrelated condition"
|
||
));
|
||
// But a body that literally carries the credit signal still matches,
|
||
// even when the status prefix also says "Payment Required".
|
||
assert!(is_insufficient_credits_message(
|
||
"provider API error (402 Payment Required): your account has insufficient balance"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn is_insufficient_credits_event_delegates_to_message_matcher() {
|
||
// Parity: the event-level filter is now a thin wrapper over the
|
||
// message-level matcher across both the message and exception paths.
|
||
let body = "myopenrouter API error (402 Payment Required): This request requires \
|
||
more credits, or fewer max_tokens.";
|
||
assert!(is_insufficient_credits_message(body));
|
||
assert!(is_insufficient_credits_event(&event_with_message(body)));
|
||
assert!(is_insufficient_credits_event(&event_with_exception_value(
|
||
body
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn ollama_cloud_internal_500_reraise_routes_through_expected_path() {
|
||
// TAURI-RUST-5MV — the actionable message the emit sites raise must
|
||
// demote to `TransientUpstreamHttp` when re-reported at the agent / RPC
|
||
// boundary (`provider_chat` → `report_error_or_expected`), so the
|
||
// `domain=agent` half of the flood is suppressed too.
|
||
let reraise = crate::openhuman::inference::provider::ollama_cloud_internal_500_user_message(
|
||
Some("minimax-m3:cloud"),
|
||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(&reraise),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ollama_cloud_internal_500_before_send_matches_raw_and_reraised_shapes() {
|
||
// The outermost net catches BOTH the raw emit body (any compatible
|
||
// path that bypassed the cascade) and the actionable re-raise.
|
||
let raw = "ollama API error (500 Internal Server Error): \
|
||
{\"error\":\"Internal Server Error (ref: df512dcb-d915-493b-8f2d-e8d3dfa640c1)\"}";
|
||
assert!(is_ollama_cloud_internal_500_event(&event_with_message(raw)));
|
||
assert!(is_ollama_cloud_internal_500_event(
|
||
&event_with_exception_value(raw)
|
||
));
|
||
|
||
let reraise = crate::openhuman::inference::provider::ollama_cloud_internal_500_user_message(
|
||
None,
|
||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||
);
|
||
assert!(is_ollama_cloud_internal_500_event(&event_with_message(
|
||
&reraise
|
||
)));
|
||
|
||
// A local Ollama 500 without the `ref:` envelope, and a non-ollama 500,
|
||
// both stay reportable.
|
||
assert!(!is_ollama_cloud_internal_500_event(&event_with_message(
|
||
"ollama API error (500 Internal Server Error): {\"error\":\"out of memory\"}"
|
||
)));
|
||
assert!(!is_ollama_cloud_internal_500_event(&event_with_message(
|
||
"openai API error (500): Internal Server Error (ref: abc)"
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn session_expired_before_send_matches_core_401_events() {
|
||
let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work";
|
||
for event in [
|
||
event_with_tags_and_message(&[("domain", "llm_provider"), ("status", "401")], msg),
|
||
{
|
||
let mut event = event_with_exception_value(msg);
|
||
event.tags.insert("domain".into(), "backend_api".into());
|
||
event.tags.insert("status".into(), "401".into());
|
||
event
|
||
},
|
||
] {
|
||
assert!(
|
||
is_session_expired_event(&event),
|
||
"core/backend session-expired 401 events should be filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn session_expired_before_send_stays_domain_scoped() {
|
||
let event = event_with_tags_and_message(
|
||
&[("domain", "composio"), ("status", "401")],
|
||
"SESSION_EXPIRED: backend session not active — sign in to resume LLM work",
|
||
);
|
||
assert!(
|
||
!is_session_expired_event(&event),
|
||
"non-core domains must not be filtered as backend session expiry"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn max_iterations_filter_matches_message_path() {
|
||
// `report_error_message` calls `sentry::capture_message`, which
|
||
// populates `event.message`. The filter must see the canonical
|
||
// phrase on that field path.
|
||
let event = event_with_message("Agent exceeded maximum tool iterations (8)");
|
||
assert!(is_max_iterations_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn max_iterations_filter_matches_exception_path() {
|
||
// sentry-tracing with attach_stacktrace=true populates the
|
||
// exception list instead of (or in addition to) `event.message`.
|
||
// Filter must still catch the noise.
|
||
let event = event_with_exception_value(
|
||
"agent.run_single failed: Agent exceeded maximum tool iterations (10)",
|
||
);
|
||
assert!(is_max_iterations_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn max_iterations_filter_keeps_unrelated_events() {
|
||
assert!(!is_max_iterations_event(&event_with_message(
|
||
"provider returned 503"
|
||
)));
|
||
assert!(!is_max_iterations_event(&event_with_message("")));
|
||
assert!(!is_max_iterations_event(&sentry::protocol::Event::default()));
|
||
}
|
||
|
||
// ── is_channel_message_not_found_event (TAURI-R7) ────────────────────────
|
||
|
||
fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> {
|
||
let mut event = sentry::protocol::Event::default();
|
||
event.tags.insert("domain".into(), "backend_api".into());
|
||
event.tags.insert("failure".into(), "non_2xx".into());
|
||
event.tags.insert("status".into(), "404".into());
|
||
event.tags.insert("method".into(), method.into());
|
||
event.message = Some(
|
||
"PATCH /channels/telegram/messages/1103 failed (404); response_body_len=172"
|
||
.to_string(),
|
||
);
|
||
event
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_matches_patch() {
|
||
// Canonical TAURI-R7 shape: PATCH 404 on a channel-message path.
|
||
assert!(is_channel_message_not_found_event(
|
||
&channel_message_404_event("PATCH")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_matches_delete() {
|
||
assert!(is_channel_message_not_found_event(
|
||
&channel_message_404_event("DELETE")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_ignores_get_404() {
|
||
// GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal.
|
||
assert!(!is_channel_message_not_found_event(
|
||
&channel_message_404_event("GET")
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_ignores_non_channel_path() {
|
||
let mut event = channel_message_404_event("PATCH");
|
||
event.message = Some("PATCH /auth/profile failed (404); response_body_len=42".to_string());
|
||
assert!(!is_channel_message_not_found_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_ignores_wrong_status() {
|
||
let mut event = channel_message_404_event("PATCH");
|
||
event.tags.insert("status".into(), "403".into());
|
||
assert!(!is_channel_message_not_found_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_ignores_wrong_domain() {
|
||
let mut event = channel_message_404_event("PATCH");
|
||
event.tags.insert("domain".into(), "channels".into());
|
||
assert!(!is_channel_message_not_found_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_message_not_found_filter_matches_exception_path() {
|
||
// sentry-tracing with attach_stacktrace=true populates exception list.
|
||
let mut event = sentry::protocol::Event::default();
|
||
event.tags.insert("domain".into(), "backend_api".into());
|
||
event.tags.insert("failure".into(), "non_2xx".into());
|
||
event.tags.insert("status".into(), "404".into());
|
||
event.tags.insert("method".into(), "PATCH".into());
|
||
event.exception = vec![sentry::protocol::Exception {
|
||
value: Some("PATCH /channels/discord/messages/abc failed (404): Not Found".to_string()),
|
||
..Default::default()
|
||
}]
|
||
.into();
|
||
assert!(is_channel_message_not_found_event(&event));
|
||
}
|
||
|
||
// ── LoopbackUnavailable (TAURI-R5, TAURI-R6) ─────────────────────────────
|
||
|
||
/// Verbatim body shape from OPENHUMAN-TAURI-R5 (~2.5k events): the
|
||
/// `integrations.get` site reaches the embedded core's `127.0.0.1:18474`
|
||
/// listener during the boot window and reqwest's source chain renders as
|
||
/// `error sending request for url (…) → client error (Connect) → tcp
|
||
/// connect error → Connection refused (os error 61)`.
|
||
const R5_BODY: &str = "error sending request for url \
|
||
(http://127.0.0.1:18474/agent-integrations/composio/connections) \
|
||
→ client error (Connect) → tcp connect error → Connection refused (os error 61)";
|
||
|
||
/// Verbatim body shape from OPENHUMAN-TAURI-R6 (~2.5k events): the same
|
||
/// transport failure as R5, re-wrapped one frame up by the composio
|
||
/// op-layer and re-emitted at the `rpc.invoke_method` site so it lands in
|
||
/// Sentry under `domain=rpc` instead of `domain=integrations`.
|
||
const R6_BODY: &str = "[composio] list_connections failed: \
|
||
GET http://127.0.0.1:18474/agent-integrations/composio/connections failed: \
|
||
error sending request for url \
|
||
(http://127.0.0.1:18474/agent-integrations/composio/connections) \
|
||
→ client error (Connect) → tcp connect error → Connection refused (os error 61)";
|
||
|
||
#[test]
|
||
fn classifies_r5_loopback_connect_refused_as_loopback_unavailable() {
|
||
assert_eq!(
|
||
expected_error_kind(R5_BODY),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable),
|
||
"R5 body must classify as LoopbackUnavailable, not the broader NetworkUnreachable bucket"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_r6_rpc_wrapped_loopback_connect_refused_as_loopback_unavailable() {
|
||
assert_eq!(
|
||
expected_error_kind(R6_BODY),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable),
|
||
"R6 body (rpc.invoke_method re-wrap) must classify as LoopbackUnavailable"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_loopback_connect_refused_across_platforms() {
|
||
// Linux WSL / native: os error 111. Windows WSAECONNREFUSED: 10061.
|
||
// Both must classify so the matcher works regardless of where the
|
||
// user's desktop happens to be running.
|
||
for raw in [
|
||
"error sending request for url (http://127.0.0.1:18474/x) \
|
||
→ tcp connect error → Connection refused (os error 111)",
|
||
"error sending request for url (http://localhost:18474/x) \
|
||
→ tcp connect error → Connection refused (os error 10061)",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable),
|
||
"should classify as LoopbackUnavailable across platforms: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn loopback_unavailable_precedence_over_network_unreachable() {
|
||
// Precedence guard: a loopback `Connection refused (os error 61)`
|
||
// body would ALSO match `is_network_unreachable_message` because the
|
||
// broader matcher catches both `error sending request for url` and
|
||
// `connection refused`. The ladder must route through the
|
||
// loopback-specific bucket first so the two error classes stay
|
||
// distinguishable in Sentry.
|
||
let kind = expected_error_kind(R5_BODY);
|
||
assert_eq!(kind, Some(ExpectedErrorKind::LoopbackUnavailable));
|
||
assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable));
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_loopback_url_with_different_error_class_as_loopback() {
|
||
// A real upstream HTTP failure that happens to hit a developer's
|
||
// local proxy on `127.0.0.1:` (e.g. `mitmproxy`, `Charles`,
|
||
// `ngrok http`) must NOT be silenced as loopback noise — the body
|
||
// shape is a 503 status, not a transport-level connect-refused, and
|
||
// is actionable for Sentry.
|
||
let raw = "Backend returned 503 Service Unavailable for GET \
|
||
http://127.0.0.1:8080/agent-integrations/composio/connections: \
|
||
upstream timed out";
|
||
assert!(
|
||
!matches!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable)
|
||
),
|
||
"loopback URL with non-transport error must not classify as LoopbackUnavailable"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_non_loopback_connect_refused_as_loopback() {
|
||
// A `Connection refused` against a non-loopback host (DNS resolved
|
||
// to a remote IP, ISP-level block, captive portal) must fall
|
||
// through to `NetworkUnreachable`, not into the loopback bucket.
|
||
let raw = "error sending request for url \
|
||
(https://api.tinyhumans.ai/agent-integrations/composio/connections) \
|
||
→ tcp connect error → Connection refused (os error 61)";
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn loopback_matcher_requires_both_host_and_errno_anchors() {
|
||
// Defense against the matcher being too eager: bodies that satisfy
|
||
// only one of the two conjunctive anchors must not classify into the
|
||
// loopback bucket. They may still demote via the broader
|
||
// `NetworkUnreachable` matcher — that is the correct fall-through —
|
||
// but the bucket must stay distinct so Sentry's "what class is
|
||
// spiking?" signal is preserved.
|
||
let loopback_host_no_errno =
|
||
"doctor: probed 127.0.0.1:18474 and got connection refused without errno detail";
|
||
assert_ne!(
|
||
expected_error_kind(loopback_host_no_errno),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable),
|
||
"loopback host without `(os error N)` errno must not classify as LoopbackUnavailable"
|
||
);
|
||
|
||
let errno_no_loopback_host = "note: connection refused (os error 61) on retry";
|
||
assert_ne!(
|
||
expected_error_kind(errno_no_loopback_host),
|
||
Some(ExpectedErrorKind::LoopbackUnavailable),
|
||
"errno without loopback host anchor must not classify as LoopbackUnavailable"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn report_error_or_expected_routes_r5_r6_through_expected_path() {
|
||
// Smoke test: both verbatim Sentry bodies flow through
|
||
// `report_error_or_expected` without panicking. The classifier
|
||
// routes them to `report_expected_message` (debug breadcrumb,
|
||
// metadata-only) instead of `report_error_message`
|
||
// (`sentry::capture_message` at error level). We can't observe the
|
||
// Sentry hub from this test, but exercising the call path catches
|
||
// any future regression that re-introduces a panic or mis-types
|
||
// the arm.
|
||
report_error_or_expected(
|
||
R5_BODY,
|
||
"integrations",
|
||
"get",
|
||
&[
|
||
("path", "/agent-integrations/composio/connections"),
|
||
("failure", "transport"),
|
||
],
|
||
);
|
||
report_error_or_expected(
|
||
R6_BODY,
|
||
"rpc",
|
||
"invoke_method",
|
||
&[("method", "openhuman.composio_list_connections")],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_channel_supervisor_restart_english_discord_gateway() {
|
||
// TAURI-RUST-15 (~11.4k events / 14d on self-hosted `tauri-rust`):
|
||
// verbatim wrapper from `channels::runtime::supervision::spawn_supervised_listener`
|
||
// around the Discord gateway transport error. The English body
|
||
// would otherwise match `is_network_unreachable_message` (which
|
||
// demotes to `warn!` — still a Sentry event); the supervisor
|
||
// wrap precedence routes it to `ChannelSupervisorRestart`
|
||
// (info-only breadcrumb).
|
||
let body = "Channel discord error: error sending request for url \
|
||
(https://discord.com/api/v10/gateway/bot); restarting";
|
||
assert_eq!(
|
||
expected_error_kind(body),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_channel_supervisor_restart_chinese_windows_wsaetimedout() {
|
||
// TAURI-RUST-BB (~815 events / 14d): same supervisor wrapper,
|
||
// OS-localized inner WSAETIMEDOUT body on Chinese Windows. The
|
||
// English-only `is_network_unreachable_message` anchors miss
|
||
// this inner message, so without the language-agnostic
|
||
// supervisor matcher it would escape classification entirely
|
||
// and emit a full Sentry error. The wrapper-anchored predicate
|
||
// catches it regardless of OS locale.
|
||
let body = "Channel discord error: IO error: \
|
||
由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。 \
|
||
(os error 10060); restarting";
|
||
assert_eq!(
|
||
expected_error_kind(body),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_restart_matches_multiple_channel_names() {
|
||
// The wrapper format is `"Channel <name> error: <inner>; restarting"`.
|
||
// The name slot varies by provider (discord, slack, telegram,
|
||
// whatsapp, gmessages, …). The matcher must classify all of them —
|
||
// language-agnostic, name-agnostic.
|
||
for raw in [
|
||
"Channel slack error: gateway disconnect; restarting",
|
||
"Channel telegram error: tls handshake eof; restarting",
|
||
"Channel whatsapp error: connection reset by peer (os error 54); restarting",
|
||
"Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"should classify as channel-supervisor-restart: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_restart_precedence_over_network_unreachable() {
|
||
// Pin the precedence: a supervisor-wrap body that ALSO contains
|
||
// the canonical `"error sending request for url"` anchor (which
|
||
// would by itself classify as `NetworkUnreachable`) MUST route
|
||
// to `ChannelSupervisorRestart`. The supervisor's own backoff
|
||
// handles the condition; `NetworkUnreachable` would demote to
|
||
// `warn!` (still a Sentry event), whereas
|
||
// `ChannelSupervisorRestart` demotes to `info!` (no event).
|
||
let body = "Channel discord error: error sending request for url \
|
||
(https://discord.com/api/v10/gateway/bot); restarting";
|
||
let kind = expected_error_kind(body);
|
||
assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart));
|
||
assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() {
|
||
// Defense against the matcher being too eager: bodies that
|
||
// contain `"; restarting"` but NOT the `"Channel <name> error:"`
|
||
// preamble must NOT classify — those are generic restart logs
|
||
// from other subsystems where Sentry signal may still be
|
||
// actionable. The matcher requires all three anchors together
|
||
// (`"channel "` prefix + `" error:"` separator + `"; restarting"`
|
||
// trailer).
|
||
for raw in [
|
||
// No `Channel <name>` preamble.
|
||
"systemd: docker.service; restarting",
|
||
// No `Channel <name>` preamble even though `; restarting`
|
||
// appears.
|
||
"Connection refused; restarting",
|
||
// The string `channel` appears but not as the leading
|
||
// `"Channel <name> error:"` wrapper — must not classify.
|
||
"channels::runtime::dispatch failed: error: provider exhausted; restarting",
|
||
// The wrapper prefix is present but the trailer is not —
|
||
// a half-formed log line must not classify.
|
||
"Channel discord error: gateway disconnect",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"must NOT classify as channel-supervisor-restart: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() {
|
||
// Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through
|
||
// `report_error_or_expected` without panicking. The classifier
|
||
// routes it to `report_expected_message` (info breadcrumb) instead
|
||
// of `report_error_message` (`sentry::capture_message` at error
|
||
// level). We can't observe the Sentry hub from this test, but
|
||
// exercising the call path catches any future regression that
|
||
// re-introduces a panic or mis-types the arm.
|
||
report_error_or_expected(
|
||
"Channel discord error: error sending request for url \
|
||
(https://discord.com/api/v10/gateway/bot); restarting",
|
||
"channels",
|
||
"supervised_listener",
|
||
&[("channel", "discord")],
|
||
);
|
||
}
|
||
|
||
// ── #870 managed-backend errorCode Sentry ownership (F2/F4/F7/F8) ──
|
||
|
||
fn managed_body(status: &str, code: &str) -> String {
|
||
format!(
|
||
"OpenHuman API error ({status}): {{\"error\":{{\"errorCode\":\"{code}\",\"message\":\"x\"}}}}"
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn expected_kind_demotes_every_backend_owned_error_code() {
|
||
for (status, code) in [
|
||
("429", "RATE_LIMITED"),
|
||
("402", "USER_INSUFFICIENT_CREDITS"),
|
||
("503", "UPSTREAM_UNAVAILABLE"),
|
||
("404", "MODEL_UNAVAILABLE"),
|
||
("400", "BAD_REQUEST"),
|
||
("500", "INTERNAL_ERROR"),
|
||
] {
|
||
let body = managed_body(status, code);
|
||
assert_eq!(
|
||
expected_error_kind(&body),
|
||
Some(ExpectedErrorKind::BackendErrorCodeOwned),
|
||
"errorCode={code} must be backend-owned (no FE Sentry)"
|
||
);
|
||
}
|
||
|
||
// Client-guard-leak codes page (None = capture), even with realistic
|
||
// explanatory text that a later substring matcher would otherwise
|
||
// re-demote into a suppressed bucket.
|
||
let payload = "OpenHuman API error (413 Payload Too Large): \
|
||
{\"error\":{\"errorCode\":\"PAYLOAD_TOO_LARGE\",\"message\":\"request entity too large\"}}";
|
||
assert_eq!(
|
||
expected_error_kind(payload),
|
||
None,
|
||
"PAYLOAD_TOO_LARGE is a client guard leak and must page"
|
||
);
|
||
|
||
// This body's message matches `is_context_window_exceeded_message`, so
|
||
// without the early guard-leak bypass it would re-demote to the
|
||
// suppressed `ContextWindowExceeded` bucket (CodeRabbit).
|
||
let context = "OpenHuman API error (400 Bad Request): \
|
||
{\"error\":{\"errorCode\":\"CONTEXT_LENGTH_EXCEEDED\",\"message\":\"This model's maximum context length is 128000 tokens, however you requested more\"}}";
|
||
assert_eq!(
|
||
expected_error_kind(context),
|
||
None,
|
||
"CONTEXT_LENGTH_EXCEEDED must page even with context-window wording"
|
||
);
|
||
|
||
// Proof the bypass is load-bearing, not a no-op: this body's text DOES
|
||
// match the context-window matcher, so without the early guard-leak
|
||
// bypass `expected_error_kind` would have re-demoted it to the
|
||
// suppressed `ContextWindowExceeded` bucket.
|
||
assert!(
|
||
crate::openhuman::inference::provider::is_context_window_exceeded_message(context),
|
||
"test body must actually trigger the matcher the bypass guards against"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn expected_kind_lets_malformed_bad_request_page() {
|
||
// F8: the one errorCode case that still pages — the backend flagged a
|
||
// client-built payload as unparseable. `expected_error_kind` must NOT
|
||
// classify it as expected, so capture proceeds.
|
||
let body = "OpenHuman API error (400 Bad Request): \
|
||
{\"error\":{\"errorCode\":\"BAD_REQUEST\",\"malformed\":true}}";
|
||
assert_eq!(expected_error_kind(body), None);
|
||
}
|
||
|
||
#[test]
|
||
fn expected_kind_ignores_byo_errors_with_no_code() {
|
||
// A BYO 401 carries no errorCode — it must NOT be swallowed by the
|
||
// backend-owned branch (it routes through its own matchers / capture).
|
||
let body = "openai API error (401 Unauthorized): \
|
||
{\"error\":{\"message\":\"Incorrect API key provided\"}}";
|
||
assert_ne!(
|
||
expected_error_kind(body),
|
||
Some(ExpectedErrorKind::BackendErrorCodeOwned)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn expected_kind_ignores_byo_errors_that_carry_an_error_code_token() {
|
||
// CodeRabbit: a BYO / direct-provider envelope whose body happens to
|
||
// carry an `errorCode`-shaped field must NOT be demoted — the
|
||
// managed-envelope gate keeps it reaching Sentry.
|
||
let body = "custom_openai API error (500 Internal Server Error): \
|
||
{\"error\":{\"errorCode\":\"INTERNAL_ERROR\"}}";
|
||
assert_ne!(
|
||
expected_error_kind(body),
|
||
Some(ExpectedErrorKind::BackendErrorCodeOwned)
|
||
);
|
||
let event = event_with_message(body);
|
||
assert!(
|
||
!is_backend_error_code_event(&event),
|
||
"BYO body with an errorCode token must not be dropped by before_send"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn before_send_filter_drops_backend_owned_error_code_events() {
|
||
for code in [
|
||
"RATE_LIMITED",
|
||
"UPSTREAM_UNAVAILABLE",
|
||
"MODEL_UNAVAILABLE",
|
||
"INTERNAL_ERROR",
|
||
"BAD_REQUEST",
|
||
] {
|
||
let event = event_with_message(&managed_body("500", code));
|
||
assert!(
|
||
is_backend_error_code_event(&event),
|
||
"errorCode={code} event must be dropped by before_send"
|
||
);
|
||
}
|
||
|
||
// Client-guard-leak codes survive before_send and page (the client
|
||
// should have caught the limit before sending) — including a realistic
|
||
// CONTEXT_LENGTH_EXCEEDED body whose wording matches the context-window
|
||
// substring matcher (the filter keys on the errorCode, not the text).
|
||
let payload = "OpenHuman API error (413 Payload Too Large): \
|
||
{\"error\":{\"errorCode\":\"PAYLOAD_TOO_LARGE\",\"message\":\"request entity too large\"}}";
|
||
let context = "OpenHuman API error (400 Bad Request): \
|
||
{\"error\":{\"errorCode\":\"CONTEXT_LENGTH_EXCEEDED\",\"message\":\"This model's maximum context length is 128000 tokens\"}}";
|
||
for body in [payload, context] {
|
||
let event = event_with_message(body);
|
||
assert!(
|
||
!is_backend_error_code_event(&event),
|
||
"client guard leak must survive before_send: {body}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn before_send_filter_keeps_malformed_bad_request_event() {
|
||
let event = event_with_message(
|
||
"OpenHuman API error (400 Bad Request): \
|
||
{\"error\":{\"errorCode\":\"BAD_REQUEST\",\"malformed\":true}}",
|
||
);
|
||
assert!(
|
||
!is_backend_error_code_event(&event),
|
||
"malformed BAD_REQUEST must survive before_send and page (F8)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn before_send_filter_matches_error_code_in_exception_value() {
|
||
let event = event_with_exception_value(&managed_body("500", "INTERNAL_ERROR"));
|
||
assert!(is_backend_error_code_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn transient_provider_transport_filter_drops_flaky_network_blips() {
|
||
// F7: a streaming transport timeout/reset under
|
||
// domain=llm_provider, failure=transport is recovered by
|
||
// retry/fallback — drop it.
|
||
for phrase in [
|
||
"error sending request for url (https://api.tinyhumans.ai/v1/chat): \
|
||
operation timed out",
|
||
"connection reset by peer",
|
||
"tls handshake eof",
|
||
] {
|
||
for operation in ["stream_chat", "stream_chat_history"] {
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "llm_provider"),
|
||
("failure", "transport"),
|
||
("operation", operation),
|
||
],
|
||
phrase,
|
||
);
|
||
assert!(
|
||
is_transient_provider_transport_failure(&event),
|
||
"transient transport phrase must be dropped: {phrase} ({operation})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn transient_provider_transport_filter_keeps_non_transient_transport() {
|
||
// A genuine, non-transient transport failure (e.g. an unexpected
|
||
// protocol error) is NOT a flaky-network blip — keep paging.
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "llm_provider"),
|
||
("failure", "transport"),
|
||
("operation", "stream_chat"),
|
||
],
|
||
"invalid URL scheme: unsupported protocol",
|
||
);
|
||
assert!(!is_transient_provider_transport_failure(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn transient_provider_transport_filter_scoped_to_streaming_operations() {
|
||
// CodeRabbit: a NON-streaming llm_provider transport failure with the
|
||
// same domain/failure tags but a different operation must keep paging
|
||
// — the filter is intentionally scoped to the streaming emits.
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "llm_provider"),
|
||
("failure", "transport"),
|
||
("operation", "chat_completions"),
|
||
],
|
||
"operation timed out",
|
||
);
|
||
assert!(
|
||
!is_transient_provider_transport_failure(&event),
|
||
"non-streaming llm_provider transport must not be suppressed"
|
||
);
|
||
|
||
// And with no operation tag at all (older/foreign emit) — keep paging.
|
||
let no_op = event_with_tags_and_message(
|
||
&[("domain", "llm_provider"), ("failure", "transport")],
|
||
"operation timed out",
|
||
);
|
||
assert!(!is_transient_provider_transport_failure(&no_op));
|
||
}
|
||
|
||
#[test]
|
||
fn transient_provider_transport_filter_scoped_to_llm_provider() {
|
||
// Same shape under a different domain must not be claimed by this
|
||
// provider-scoped filter.
|
||
let event = event_with_tags_and_message(
|
||
&[
|
||
("domain", "backend_api"),
|
||
("failure", "transport"),
|
||
("operation", "stream_chat"),
|
||
],
|
||
"operation timed out",
|
||
);
|
||
assert!(!is_transient_provider_transport_failure(&event));
|
||
}
|
||
|
||
// ── is_auth_get_me_opaque_transport_event ────────────────────────────
|
||
// Covers the TAURI-RUST-10 fingerprint shape: `domain=rpc`,
|
||
// `operation=invoke_method`, `method=openhuman.auth_get_me`, message
|
||
// body = exactly "GET /auth/me" (no underlying chain). See the
|
||
// function docstring + the `auth_get_me` fix in
|
||
// `openhuman::credentials::ops::auth_get_me` for the broader
|
||
// context.
|
||
|
||
fn auth_get_me_tags() -> Vec<(&'static str, &'static str)> {
|
||
vec![
|
||
("domain", "rpc"),
|
||
("operation", "invoke_method"),
|
||
("method", "openhuman.auth_get_me"),
|
||
("elapsed_ms", "5003"),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_drops_bare_method_path_message() {
|
||
let event = event_with_tags_and_message(&auth_get_me_tags(), "GET /auth/me");
|
||
assert!(
|
||
is_auth_get_me_opaque_transport_event(&event),
|
||
"bare 'GET /auth/me' message must be dropped (TAURI-RUST-10 shape)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_tolerates_surrounding_whitespace() {
|
||
let event = event_with_tags_and_message(&auth_get_me_tags(), " GET /auth/me ");
|
||
assert!(
|
||
is_auth_get_me_opaque_transport_event(&event),
|
||
"trimmed equality must still match the opaque shape"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_keeps_full_anyhow_chain_message() {
|
||
// Post-fix shape from `auth_get_me` now using `format!("{e:#}")`.
|
||
let event = event_with_tags_and_message(
|
||
&auth_get_me_tags(),
|
||
"GET /auth/me: error sending request for url \
|
||
(https://api.tinyhumans.ai/auth/me): operation timed out",
|
||
);
|
||
assert!(
|
||
!is_auth_get_me_opaque_transport_event(&event),
|
||
"messages carrying the underlying transport chain must surface — \
|
||
the transient classifier handles those at the rpc dispatcher"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_keeps_other_rpc_methods() {
|
||
// Same opaque shape but for a different RPC must NOT be dropped —
|
||
// we don't have evidence the same anti-pattern exists elsewhere,
|
||
// and a path-only body might be a legitimate distinct error for a
|
||
// future endpoint.
|
||
for method in [
|
||
"openhuman.consume_login_token",
|
||
"openhuman.auth_create_channel_link_token",
|
||
"openhuman.thread_list",
|
||
] {
|
||
let mut tags = auth_get_me_tags();
|
||
// Replace the method tag.
|
||
if let Some(slot) = tags.iter_mut().find(|(k, _)| *k == "method") {
|
||
slot.1 = method;
|
||
}
|
||
let event = event_with_tags_and_message(&tags, "GET /auth/me");
|
||
assert!(
|
||
!is_auth_get_me_opaque_transport_event(&event),
|
||
"filter must be scoped strictly to method=openhuman.auth_get_me \
|
||
— saw method={method}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_requires_rpc_invoke_method_domain() {
|
||
// Wrong domain → must surface.
|
||
let mut tags = auth_get_me_tags();
|
||
if let Some(slot) = tags.iter_mut().find(|(k, _)| *k == "domain") {
|
||
slot.1 = "backend_api";
|
||
}
|
||
let event = event_with_tags_and_message(&tags, "GET /auth/me");
|
||
assert!(!is_auth_get_me_opaque_transport_event(&event));
|
||
|
||
// Wrong operation → must surface.
|
||
let mut tags = auth_get_me_tags();
|
||
if let Some(slot) = tags.iter_mut().find(|(k, _)| *k == "operation") {
|
||
slot.1 = "post";
|
||
}
|
||
let event = event_with_tags_and_message(&tags, "GET /auth/me");
|
||
assert!(!is_auth_get_me_opaque_transport_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_matches_exception_value_path() {
|
||
// sentry-tracing path: message empty, exception last value carries
|
||
// the body. The filter must still match.
|
||
let mut event = event_with_tags(&auth_get_me_tags());
|
||
event.exception.values.push(sentry::protocol::Exception {
|
||
value: Some("GET /auth/me".to_string()),
|
||
..Default::default()
|
||
});
|
||
assert!(
|
||
is_auth_get_me_opaque_transport_event(&event),
|
||
"must also catch the exception-value shape (sentry-tracing bridge)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn auth_get_me_opaque_filter_ignores_empty_and_unrelated() {
|
||
// No message and no exception → false.
|
||
let event = event_with_tags(&auth_get_me_tags());
|
||
assert!(!is_auth_get_me_opaque_transport_event(&event));
|
||
|
||
// Unrelated message body with the right tags → false.
|
||
let event = event_with_tags_and_message(
|
||
&auth_get_me_tags(),
|
||
"session JWT verified via GET /auth/me on https://api.tinyhumans.ai",
|
||
);
|
||
assert!(
|
||
!is_auth_get_me_opaque_transport_event(&event),
|
||
"substring match must NOT trigger — strict equality only"
|
||
);
|
||
}
|
||
}
|