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"); } #[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 ); } /// 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 classifies_backend_env_api_key_not_configured() { // TAURI-RUST-2H5 (~5 K events): backend embedding endpoint returns a // 400 with `{"success":false,"error":"VOYAGE_API_KEY is not configured"}` // whenever the backend env var is absent. This is a known server-side // config state, not an app error — silence it the same way we silence // other `ApiKeyMissing` variants. for raw in [ r#"Embedding API error (400 Bad Request): {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, r#"Embedding API error 400 Bad Request: {"success":false,"error":"VOYAGE_API_KEY is not configured"}"#, // Future-proof: same shape for any other backend-managed embedder. r#"Embedding API error (400 Bad Request): {"success":false,"error":"COHERE_API_KEY is not configured"}"#, ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::ApiKeyMissing), "should classify backend env api-key missing: {raw}" ); } } #[test] fn does_not_classify_unrelated_is_not_configured_messages() { // The `_api_key` anchor must keep prose that merely says "is not // configured" from being silenced — only env-var-style key names // should match. assert_eq!( expected_error_kind("workspace path is not configured for this user"), None ); assert_eq!( expected_error_kind("embedding model is not configured"), 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-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). // Kept as a regression guard against the broader // `is_embedding_backend_auth_failure` matcher re-claiming this // shape — see the guard in that function. 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 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 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 historically emitted by the // now-removed knowledge-vault `vault_create` path when the // user-picked folder didn't resolve to an existing directory. // Retained as a classifier fixture in case the shape recurs from // another caller. 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: "`. 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 — // `" 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_pii_rejection_errors() { // TAURI-RUST-54T: ~915 events from one user where the PII guard // rejected memory-store writes on namespace/key values that look like // personal identifiers. All three canonical wire shapes — from // `documents.rs` (upsert_document / upsert_document_metadata_only) // and `kv.rs` (kv_set_global / kv_set_namespace) — must classify as // expected so they stop reaching Sentry. for raw in [ "document namespace/key cannot contain personal identifiers", "kv key cannot contain personal identifiers", "kv namespace/key cannot contain personal identifiers", ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::MemoryStorePiiRejection), "should classify as memory-store PII rejection: {raw}" ); } // Wrapped by the RPC dispatch layer — substring match must survive the // `rpc.invoke_method failed: ` prefix that `jsonrpc.rs` prepends. assert_eq!( expected_error_kind( "rpc.invoke_method failed: document namespace/key cannot contain personal identifiers" ), Some(ExpectedErrorKind::MemoryStorePiiRejection) ); } #[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)", ] { 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 ); } #[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,); } 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 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_messages_as_memory_pii_rejection() { // A generic "personal identifiers" mention without the "cannot contain" // anchor must not be silenced. assert_eq!( expected_error_kind("processing personal identifiers"), None, "must not match a bare 'personal identifiers' mention" ); // The secret-rejection variant uses different wording and must not be // swallowed by the PII classifier. assert_eq!( expected_error_kind("document namespace/key cannot contain secrets"), None, "secret rejection must remain unclassified" ); } #[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 ); } // ── Upstream rate-limit suppression (OPENHUMAN-TAURI-S / -6Y / -2E / -RQ) ─ /// Canonical Anthropic / OpenAI body with a structured `"rate_limit_error"` /// type — OPENHUMAN-TAURI-2E (~1 482 events) and -RQ (~741 events). #[test] fn classifies_rate_limit_error_type_as_transient() { for raw in [ // Direct 429 from the embeddings path (OPENHUMAN-TAURI-RQ): r#"Embedding API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, // Via llm_provider.api_error (OPENHUMAN-TAURI-2E): r#"[observability] llm_provider.api_error failed: OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded. Please retry after a brief wait.","type":"rate_limit_error"}}"#, // Re-reported by agent.run_single: r#"run_chat_task failed client_id=abc thread_id=t1 request_id=r1 error=OpenHuman API error (429 Too Many Requests): {"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}"#, ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::TransientUpstreamHttp), "should classify rate_limit_error body as transient: {raw}" ); } } /// OpenHuman backend wrapping an upstream 429 as HTTP 500 with a /// `"upstream rate limit exceeded"` body — OPENHUMAN-TAURI-6Y (~19 849 /// events). #[test] fn classifies_upstream_rate_limit_in_500_body_as_transient() { for raw in [ r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly."}"#, r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'. Please retry shortly.","details":{"provider":"gmi","upstreamModel":"deepseek-ai/DeepSeek-V3-0324"}}"#, // Re-wrapped by rpc.invoke_method: r#"rpc.invoke_method failed: LLM summarisation failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"Upstream rate limit exceeded for model 'summarization-v1'."}"#, ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::TransientUpstreamHttp), "should classify upstream-rate-limit-in-500 as transient: {raw}" ); } } /// Backend returning HTTP 500 with a numeric `"429 rate limit exceeded"` /// body — OPENHUMAN-TAURI-S (~6 984 events). #[test] fn classifies_429_rate_limit_in_500_body_as_transient() { for raw in [ r#"OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, r#"[observability] llm_provider.api_error failed: OpenHuman API error (500 Internal Server Error): {"success":false,"error":"429 rate limit exceeded, please try again later"}"#, ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::TransientUpstreamHttp), "should classify 429-in-500-body as transient: {raw}" ); } } /// The security::policy `"Rate limit exceeded: action budget exhausted"` /// must NOT be silenced — it's a user-facing hard stop, not a transient /// upstream quota hit. #[test] fn does_not_classify_security_policy_rate_limit_as_transient() { let msg = "Rate limit exceeded: action budget exhausted (0 actions/hour). \ Increase the limit in Settings -> Advanced -> Agent autonomy"; assert_eq!( expected_error_kind(msg), None, "security policy action-budget error must reach Sentry: {msg}" ); // Wrapped by rpc.invoke_method — the prefix must not accidentally // trigger the `api error (` anchor. assert_eq!( expected_error_kind(&format!("rpc.invoke_method failed: {msg}")), None, "wrapped security policy action-budget error must reach Sentry" ); } /// Standalone `"rate limit exceeded"` without the `"api error ("` anchor /// must NOT be silenced — keeps loose phrases from accidentally demoting /// unrelated errors. #[test] fn does_not_classify_bare_rate_limit_exceeded_as_transient() { assert_eq!( expected_error_kind("rate limit exceeded"), None, "bare 'rate limit exceeded' without API error anchor must reach Sentry" ); } /// `is_upstream_rate_limit_message` predicate unit tests — verifies the /// polarity contract independently of `expected_error_kind`. #[test] fn upstream_rate_limit_predicate_matches_expected_shapes() { for lower in [ r#"{"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, "upstream rate limit exceeded for model 'summarization-v1'", "429 rate limit exceeded, please try again later", r#"openai api error (429 too many requests): {"error":{"message":"rate limit exceeded.","type":"rate_limit_error"}}"#, ] { assert!( is_upstream_rate_limit_message(lower), "should match: {lower}" ); } } #[test] fn upstream_rate_limit_predicate_does_not_match_unrelated() { for lower in [ // security::policy budget message — must not be swallowed "rate limit exceeded: action budget exhausted (0 actions/hour)", // bare phrase without anchor "rate limit exceeded", // unrelated 500 body r#"{"success":false,"error":"internal server error"}"#, // budget exhausted — different concept "budget exhausted, add credits to continue", ] { assert!( !is_upstream_rate_limit_message(lower), "should not match: {lower}" ); } } #[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 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: "` 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: "` 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 " ". 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", "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 ` 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 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: "`. 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 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 ` 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 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-322 (#2929): composio-direct 403 (key missing perms) ─ #[test] fn classifies_composio_direct_403_as_provider_user_state() { // Canonical Sentry TAURI-RUST-322 wire shape — the verbatim // title body from the issue (1,021 events, multi-release). The // Composio v3 `/connected_accounts` endpoint returns HTTP 403 // when the BYO API key exists but lacks `connected_accounts:read` // permission. This is a user-state condition; Sentry has no // remediation path. let msg = "[composio-direct] list_connections failed: \ Composio v3 connected_accounts failed: HTTP 403"; assert_eq!( expected_error_kind(msg), Some(ExpectedErrorKind::ProviderUserState), "composio-direct HTTP 403 must demote to ProviderUserState (TAURI-RUST-322)" ); } #[test] fn classifies_composio_direct_403_for_other_ops() { // The `[composio-direct]` + `HTTP 403` arm must cover every op // that can hit a 403 from the Composio v3 tenant (list_tools // prefetch, authorize, etc.) — not just list_connections. let shapes = [ // list_tools prefetch of connections hits the 403 wall "[composio-direct] list_tools: prefetch connections failed: \ Composio v3 connected_accounts failed: HTTP 403", // list_connections itself (the primary source of the leak) "[composio-direct] list_connections (direct) failed: \ Composio v3 connected_accounts failed: HTTP 403", // any future direct-mode op that hits a 403 "[composio-direct] composio_list_connections (direct) failed: \ Composio v3 connected_accounts failed: HTTP 403", ]; for msg in shapes { assert_eq!( expected_error_kind(msg), Some(ExpectedErrorKind::ProviderUserState), "every [composio-direct] op with HTTP 403 must demote to ProviderUserState: {msg}" ); } } #[test] fn does_not_classify_unrelated_http_403_as_composio_direct_user_state() { // Discrimination test: a 403 that does NOT carry the // `[composio-direct]` prefix must NOT match this arm. Backend-mode // composio 403s and unrelated 403s must remain visible in Sentry. let backend_403 = "[composio] list_connections failed: \ Backend returned 403 Forbidden for GET \ https://api.tinyhumans.ai/agent-integrations/composio/connections"; // The backend-mode shape passes through `is_backend_user_error_message` // (4xx matcher), not this arm. Verify it does NOT match this arm. assert!( !lower_contains_composio_direct_auth_wall(backend_403), "backend-mode 403 must NOT match the composio-direct arm" ); let unrelated_403 = "GitHub API error: HTTP 403: rate limit exceeded"; assert_ne!( expected_error_kind(unrelated_403), Some(ExpectedErrorKind::ProviderUserState), "unrelated 403 (no [composio-direct] anchor) must NOT match the composio-direct arm" ); } // Helper used only in the discrimination test above — mirrors the // exact condition in `is_provider_user_state_message` without // requiring access to the private function. fn lower_contains_composio_direct_auth_wall(msg: &str) -> bool { let lower = msg.to_ascii_lowercase(); lower.contains("[composio-direct]") && (lower.contains("http 401") || lower.contains("http 403") || lower.contains("invalid api key")) } // ── 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 Just a moment...

Icon for api.tinyhumans.aiapi.tinyhumans.ai

...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: Not Found", // 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 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"}"#, ] { 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 `" (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); } /// TAURI-RUST-E (~1 437 events): billing poll fires `report_error_or_expected` /// on every refresh cycle once the user's JWT lapses because the /// `BackendApiError::Unauthorized` typed error was stringified to /// `"backend rejected session token on GET /payments/stripe/currentPlan"` by /// `billing::ops::get_authed_value(..).map_err(|e| e.to_string())` before the /// phrase was added to `is_session_expired_message`. /// /// The phrase `"backend rejected session token"` is uniquely produced by /// `BackendApiError::Unauthorized`'s `Display` impl in `api::rest` — no /// third-party provider path emits it — so no conjunctive guard is needed. #[test] fn classifies_billing_401_as_session_expired() { // Exact wire shape from `billing_get_current_plan` — the most common // event in TAURI-RUST-E. assert_eq!( expected_error_kind("backend rejected session token on GET /payments/stripe/currentPlan"), Some(ExpectedErrorKind::SessionExpired), "TAURI-RUST-E: billing_get_current_plan 401 must classify as SessionExpired" ); // Other billing methods share the same `BackendApiError::Unauthorized` // display shape — pin them so a wording change in `rest.rs` would catch // every billing call site. for path in [ "/payments/credits/balance", "/payments/credits/transactions?limit=20&offset=0", "/payments/credits/auto-recharge", "/payments/credits/auto-recharge/cards", "/payments/stripe/purchasePlan", "/payments/stripe/portal", "/coupons/me", ] { let msg = format!("backend rejected session token on GET {path}"); assert_eq!( expected_error_kind(&msg), Some(ExpectedErrorKind::SessionExpired), "billing 401 must classify as SessionExpired: {msg}" ); } // POST / PATCH / DELETE variants are also produced by `authed_json`. for raw in [ "backend rejected session token on POST /payments/credits/top-up", "backend rejected session token on PATCH /payments/credits/auto-recharge", "backend rejected session token on DELETE /payments/credits/auto-recharge/cards/pm_123", ] { assert_eq!( expected_error_kind(raw), Some(ExpectedErrorKind::SessionExpired), "TAURI-RUST-E variant must classify as SessionExpired: {raw}" ); } } /// `"backend rejected session token"` is scoped to `BackendApiError::Unauthorized` /// in `api::rest`. Ensure unrelated messages containing the individual /// words don't accidentally match. #[test] fn does_not_classify_unrelated_rejected_messages_as_session_expired() { // Third-party provider errors that mention a token being rejected but // do not contain the exact OpenHuman `BackendApiError::Unauthorized` // display phrase. for raw in [ "Discord API error: token rejected by upstream", "Stripe webhook signature rejected — bad secret", "API token rejected: please regenerate", ] { assert_eq!( expected_error_kind(raw), None, "unrelated token-rejected message must NOT suppress Sentry: {raw}" ); } } #[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 = 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 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" ); } #[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 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_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 ): 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_for_loose_phrases() { // Tier 1 requires both tags for the loose budget phrases ("budget exceeded", // "add credits", …) that might coincidentally appear in unrelated product // copy. Tier 2 (text-only) only fires for the exact backend phrase // "insufficient budget" — so the loose phrases still need both tags. 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)); } } /// Tier-2 defense-in-depth: drop the exact "Insufficient budget" phrase /// the OpenHuman backend returns regardless of which tags are set. /// Regression guard for OPENHUMAN-CORE-N / TAURI-RUST-1P (GitHub #2935). #[test] fn budget_filter_drops_insufficient_budget_without_tags() { // The exact JSON wire body from the OpenHuman backend. let message = r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#; // No tags at all. let event = event_with_message(message); assert!( is_budget_event(&event), "tier-2 must drop 'Insufficient budget' even without failure/status tags" ); // Wrong status tag only. let event = event_with_tags_and_message(&[("status", "400")], message); assert!( is_budget_event(&event), "tier-2 must drop 'Insufficient budget' when only status tag is set" ); // Wrong failure tag only. let event = event_with_tags_and_message(&[("failure", "transport")], message); assert!( is_budget_event(&event), "tier-2 must drop 'Insufficient budget' when failure tag doesn't match" ); } /// Regression guard: tier-2 must also catch the exception/tracing path /// (`sentry-tracing` with `attach_stacktrace=true` may populate the /// exception list rather than `event.message`). #[test] fn budget_filter_drops_insufficient_budget_exception_without_tags() { // Exact wire body from OpenHuman backend wrapped in an exception value. let event = event_with_exception_value( r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"INSUFFICIENT BUDGET"}"#, ); assert!( is_budget_event(&event), "tier-2 must drop exception-path 'Insufficient budget' case-insensitively" ); // Mixed case variant. let event = event_with_exception_value( r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient Budget"}"#, ); assert!( is_budget_event(&event), "tier-2 must drop exception-path 'Insufficient Budget' case-insensitively" ); // Wrong failure/status tags should not prevent tier-2 from matching. let mut event = event_with_exception_value( r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#, ); event .tags .insert("failure".to_string(), "transport".to_string()); assert!( is_budget_event(&event), "tier-2 must drop exception-path even when failure tag does not match" ); // Unrelated exception values must not match. let event = event_with_exception_value("retry budget exhausted after 3 attempts"); assert!( !is_budget_event(&event), "tier-2 must not match unrelated exception value" ); } /// Tier-2 only matches the tight phrase — unrelated 400 errors with "budget" /// in an unrelated context should not be silently dropped. #[test] fn budget_filter_tier2_does_not_match_unrelated_messages() { for msg in [ "retry budget exhausted after 3 attempts", "bad request: missing field", "budget_id=42 not found", "", ] { let event = event_with_message(msg); assert!( !is_budget_event(&event), "tier-2 must not match unrelated message: {msg:?}" ); } } /// CORE-RUST-EK (~827 events): every 401 from the embeddings call path /// (`domain=embeddings`, `failure=non_2xx`, `status=401`) must be filtered /// before it reaches Sentry. Covers both the OpenHuman-backend "Invalid /// token" shape (already handled by the primary `is_session_expired_message` /// classifier) and third-party provider body shapes (OpenAI /// `invalid_api_key`, plain `Unauthorized`) that fall through the string /// classifier. #[test] fn embeddings_401_filter_drops_domain_embeddings_status_401() { // Canonical CORE-RUST-EK wire shape: OpenAI `text-embedding-3-large` // key is stale. `report_error_or_expected` sets // domain=embeddings / operation=openai_embed / failure=non_2xx / // status=401 / model=text-embedding-3-large. let event = event_with_tags_and_message( &[ ("domain", "embeddings"), ("operation", "openai_embed"), ("failure", "non_2xx"), ("status", "401"), ("model", "text-embedding-3-large"), ], r#"Embedding API error (401 Unauthorized): {"error":{"message":"Incorrect API key provided. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#, ); assert!( is_embeddings_api_key_401_event(&event), "CORE-RUST-EK: domain=embeddings status=401 must be filtered" ); } /// Any other embedding status (e.g. 429 rate-limit, 500 server error) /// must not be filtered by the embeddings-401 guard — those have their /// own handlers (rate-limit filter, general error reporting). #[test] fn embeddings_401_filter_passes_other_statuses() { for status in ["429", "500", "400"] { let event = event_with_tags_and_message( &[ ("domain", "embeddings"), ("failure", "non_2xx"), ("status", status), ], "Embedding API error", ); assert!( !is_embeddings_api_key_401_event(&event), "domain=embeddings status={status} must NOT be filtered by the 401 guard" ); } } /// Non-embeddings domains must not be filtered even if status=401 — the /// guard is scoped specifically to `domain=embeddings` so that provider-chat /// and backend-API 401s remain subject to their own classifiers. #[test] fn embeddings_401_filter_passes_non_embeddings_domains() { for domain in ["llm_provider", "backend_api", "rpc", "composio"] { let event = event_with_tags_and_message( &[ ("domain", domain), ("failure", "non_2xx"), ("status", "401"), ], "API error (401 Unauthorized): some body", ); assert!( !is_embeddings_api_key_401_event(&event), "domain={domain} status=401 must NOT be swallowed by the embeddings-401 guard" ); } } /// The guard requires both `failure=non_2xx` and `status=401` to be /// present; missing either tag must cause the filter to pass the event /// through. #[test] fn embeddings_401_filter_requires_failure_and_status_tags() { let no_failure_tag = event_with_tags_and_message( &[("domain", "embeddings"), ("status", "401")], "Embedding API error (401 Unauthorized): unauthorized", ); assert!( !is_embeddings_api_key_401_event(&no_failure_tag), "missing failure tag must not trigger the guard" ); let no_status_tag = event_with_tags_and_message( &[("domain", "embeddings"), ("failure", "non_2xx")], "Embedding API error (401 Unauthorized): unauthorized", ); assert!( !is_embeddings_api_key_401_event(&no_status_tag), "missing status tag must not trigger the guard" ); } #[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 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 error: ; 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 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 ` preamble. "systemd: docker.service; restarting", // No `Channel ` preamble even though `; restarting` // appears. "Connection refused; restarting", // The string `channel` appears but not as the leading // `"Channel 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")], ); }