mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
3550 lines
147 KiB
Rust
3550 lines
147 KiB
Rust
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: <path>"`. Not yet observed
|
||
// in Sentry but shares the same RPC validation polarity as
|
||
// vault_create's `root_path` check. Anchoring on
|
||
// `"path is not a directory:"` (with trailing colon) covers
|
||
// both without two separate matchers.
|
||
assert_eq!(
|
||
expected_error_kind("hosted path is not a directory: /var/www/static-site"),
|
||
Some(ExpectedErrorKind::FilesystemUserPathInvalid)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_path_messages_as_filesystem_user_path_invalid() {
|
||
// Polarity contract — the anchor requires a trailing colon
|
||
// after `"is not a directory"`, which discriminates user input
|
||
// (path follows the colon) from other shapes:
|
||
//
|
||
// 1. The `skills::ops_install:475` SAFETY GUARD —
|
||
// `"<path> is not a directory — refusing to remove"` — must
|
||
// stay actionable. It catches an `rm -rf` invariant violation
|
||
// (the target should have been a directory but wasn't),
|
||
// which is a code bug, not user input.
|
||
// 2. A narrative log line that happens to mention the phrase
|
||
// without the user-path colon suffix is not a validation
|
||
// failure and must not be silenced.
|
||
// 3. The dot-prefix variant from POSIX `EISDIR`/`ENOTDIR`
|
||
// renderings (`"Is a directory (os error 21)"`) is the
|
||
// inverse condition — different code path entirely.
|
||
for raw in [
|
||
// Safety guard — must NOT classify.
|
||
"/tmp/openhuman-cache is not a directory — refusing to remove",
|
||
// Narrative log line — must NOT classify.
|
||
"checked that path is not a directory before mkdir",
|
||
// Inverse condition (os error 21: EISDIR) — must NOT classify.
|
||
"open /etc/passwd failed: Is a directory (os error 21)",
|
||
// Bare path with no `directory` mention — must NOT classify.
|
||
"root_path must be absolute: ./relative/path",
|
||
// Generic body with the trailing colon but no known vault/http_host
|
||
// prefix — must NOT classify (future provider/storage errors that
|
||
// happen to embed "path is not a directory: ..." should reach Sentry).
|
||
"input config path is not a directory: /etc/foo",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"polarity contract: must NOT classify as FilesystemUserPathInvalid: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── EmptyProviderResponse (TAURI-RUST-4Z1) ─────────────────────────────
|
||
|
||
#[test]
|
||
fn classifies_empty_provider_response_web_channel_rereport() {
|
||
// TAURI-RUST-4Z1: the web-channel re-report of the agent harness's
|
||
// empty-provider-response bail. `run_chat_task` wraps the flattened
|
||
// string and routes it through `report_error_or_expected` — the
|
||
// agent-layer typed suppression (PR #2790) can't reach it, so this
|
||
// string classifier must.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"run_chat_task failed client_id=l1uxaLd20_1mAdhp \
|
||
thread_id=thread-8f03e7f7-3477-42cd-9283-f0bacd4bfbca \
|
||
request_id=a73716a3-a85a-4045-984b-315772c5b3b8 \
|
||
error=The model returned an empty response. Please try again."
|
||
),
|
||
Some(ExpectedErrorKind::EmptyProviderResponse)
|
||
);
|
||
|
||
// Bare user-facing string (the verbatim `turn.rs` emission), in case
|
||
// a different call site re-reports it without the run_chat_task wrap.
|
||
assert_eq!(
|
||
expected_error_kind("The model returned an empty response. Please try again."),
|
||
Some(ExpectedErrorKind::EmptyProviderResponse)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_empty_response_phrases() {
|
||
// Polarity contract: the anchor is `"model returned an empty
|
||
// response"`, NOT the looser `"empty response"`. The sibling paths
|
||
// below use different subjects or phrasings and are not user-facing
|
||
// failures — they must stay out of this bucket so a real regression
|
||
// in those paths still reaches Sentry.
|
||
for raw in [
|
||
// payload_summarizer.rs:261 — internal fall-through, not a failure.
|
||
"[payload_summarizer] summarizer returned empty response, falling through",
|
||
// subagent_runner/extract_tool.rs:379 — graceful empty extraction.
|
||
"[extract_from_result] provider returned an empty response; returning empty extraction",
|
||
// Generic mention without the model-subject anchor.
|
||
"warning: empty response body from health probe",
|
||
// channels/bus.rs:185 — channel-inbound graceful fallback (routes
|
||
// through report_error_or_expected; subject is "agent", not "model").
|
||
"[channel-inbound] agent returned empty response — finalizing draft with fallback",
|
||
// memory/query/walk.rs:292 — debug-level memory walk, not a failure.
|
||
"[memory_tree_walk] turn=3 LLM gave up (empty response)",
|
||
// learning/reflection.rs:576 — reflection skip, not a failure.
|
||
"[learning] reflection skipped (empty response — gate off or local AI unavailable)",
|
||
// agent/harness/session/turn.rs:811 — "provider returned an empty
|
||
// final response" uses subject "provider", not "model"; must not match.
|
||
"[agent_loop] provider returned an empty final response (i=2, no text, no tool calls)",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT classify as EmptyProviderResponse: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_memory_store_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: <inner>"` and the supervisor's
|
||
// sustained-outage escalation wraps again. The existing
|
||
// `"tls handshake"` arm misses it because the words are not
|
||
// contiguous in this render (`"tls error"` … `"during handshake"`).
|
||
// Same user-environment shape as the other handshake-stage entries:
|
||
// the socket supervisor already retries with exponential backoff and
|
||
// Sentry has no actionable signal beyond that.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: TLS error: native-tls error: unexpected EOF during handshake"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
|
||
// Bare native-tls render (no socket-supervisor wrap) — fires when the
|
||
// same handshake EOF escapes through a non-supervisor call site. The
|
||
// classifier runs on the full anyhow chain, so the shorter form must
|
||
// also match.
|
||
assert_eq!(
|
||
expected_error_kind("TLS error: native-tls error: unexpected EOF during handshake"),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_ws_protocol_wrong_http_version_as_network_unreachable() {
|
||
// CORE-RUST-DP (~2 events / 24h on `openhuman@0.56.0+e8968077aeb5`,
|
||
// self-hosted `core-rust`): tungstenite renders
|
||
// `ProtocolError::WrongHttpVersion` as
|
||
// `"WebSocket protocol error: HTTP version must be 1.1 or higher"`,
|
||
// wrapped by `socket::ws_loop::run_connection` as
|
||
// `"WebSocket connect: <inner>"` and then by the supervisor's
|
||
// sustained-outage escalation as
|
||
// `"[socket] Connection failed (sustained outage after N attempts):
|
||
// WebSocket connect: WebSocket protocol error: HTTP version must be
|
||
// 1.1 or higher"`.
|
||
//
|
||
// The handshake requires HTTP/1.1; a server or intermediary proxy
|
||
// that responds with HTTP/2+ to the upgrade is misconfigured
|
||
// upstream — same shape as the existing `"tls handshake"` /
|
||
// `"certificate verify failed"` user-environment entries. The
|
||
// supervisor already retries with exponential backoff; Sentry has
|
||
// no actionable signal to add.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: WebSocket protocol error: HTTP version must be 1.1 or higher"
|
||
),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
|
||
// Bare tungstenite render (no socket-supervisor wrap) — fires when
|
||
// the same protocol error escapes through a non-supervisor call
|
||
// site. The classifier runs on the full anyhow chain, so the
|
||
// shorter form must also match.
|
||
assert_eq!(
|
||
expected_error_kind("WebSocket protocol error: HTTP version must be 1.1 or higher"),
|
||
Some(ExpectedErrorKind::NetworkUnreachable)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tls_handshake_eof_anchor_does_not_silence_unrelated_log_lines() {
|
||
// The anchor is the literal `"unexpected eof during handshake"`
|
||
// phrase. A bare data-phase `"unexpected EOF"` (server closed
|
||
// mid-stream, parser truncation, …) MUST NOT classify — those are
|
||
// outside the handshake stage and may carry actionable signal. Pin
|
||
// the rejection contract so a future refactor doesn't loosen the
|
||
// substring into a generic `"unexpected eof"` matcher.
|
||
for raw in [
|
||
"stream closed: unexpected EOF",
|
||
"reqwest: unexpected EOF while reading body",
|
||
"json parser: unexpected EOF at byte 1024",
|
||
"decoder hit unexpected eof mid-frame",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"non-handshake unexpected-EOF log line must NOT classify: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wrong_http_version_anchor_does_not_silence_unrelated_log_lines() {
|
||
// The anchor is the literal tungstenite Display string. Adjacent
|
||
// log lines that mention HTTP version in any other context
|
||
// (`"upgrading from HTTP/1.0 to HTTP/2"`, `"HTTP/1.1 only"`,
|
||
// `"server requires HTTP version 2.0"`) MUST NOT classify — those
|
||
// are unrelated transport / negotiation traces and may carry
|
||
// actionable signal. Pin the rejection contract so a future
|
||
// refactor doesn't loosen the substring into a generic
|
||
// `"http version"` matcher.
|
||
for raw in [
|
||
"[transport] upgrading from HTTP/1.0 to HTTP/2",
|
||
"server advertises HTTP version 2.0 (h2 alpn)",
|
||
"client supports HTTP/1.1 only",
|
||
"version mismatch: requires HTTP/1.2 or higher",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"unrelated HTTP-version log line must NOT classify: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_transient_upstream_http_errors() {
|
||
// OPENHUMAN-TAURI-5Z: the canonical shape emitted by
|
||
// `providers::ops::api_error` and re-raised through `agent.run_single`.
|
||
assert_eq!(
|
||
expected_error_kind("OpenHuman API error (504 Gateway Timeout): error code: 504"),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// Every transient code must classify, whether the status renders as
|
||
// bare digits or "<digits> <reason>".
|
||
for raw in [
|
||
"OpenHuman API error (408): request timeout",
|
||
"OpenAI API error (429 Too Many Requests): rate limit",
|
||
"Anthropic API error (502 Bad Gateway): upstream unhealthy",
|
||
"OpenHuman API error (503): service unavailable",
|
||
"Provider API error (504): upstream timed out",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify as transient upstream HTTP: {raw}"
|
||
);
|
||
}
|
||
|
||
// Wrapped in an anyhow chain (as it reaches the agent layer) must
|
||
// still classify — `expected_error_kind` is substring-based.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent turn failed: OpenHuman API error (504 Gateway Timeout): \
|
||
error code: 504"
|
||
),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// TAURI-RUST-H (~1360 events, 504) / TAURI-RUST-2T (~310 events, 502):
|
||
// legacy no-paren wire shape from older `embeddings::openai` /
|
||
// `embeddings::cohere` emit-site formats that predate the
|
||
// parenthesised `({status})` rendering. Anchored on the trailing
|
||
// space after the status code so unrelated digit runs don't match.
|
||
for raw in [
|
||
"Embedding API error 504 Gateway Timeout: error code: 504",
|
||
"Embedding API error 502 Bad Gateway: error code: 502",
|
||
"Cohere embed API error 503 Service Unavailable: error code: 503",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify legacy no-paren transient shape: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_digit_runs_as_transient() {
|
||
// The legacy no-paren matcher anchors `api error <code> ` with a
|
||
// trailing space so adjacent digit runs (`api error 5042…`) and
|
||
// non-transient codes (400/401/403/404) don't get silenced.
|
||
assert_eq!(
|
||
expected_error_kind("OpenHuman API error 400 Bad Request: malformed body"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("provider returned api error 5042 (custom internal sentinel)"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn integrations_post_composio_timeout_dropped() {
|
||
// OPENHUMAN-TAURI-18 / -G regression guard. The integrations
|
||
// client at `crate::openhuman::integrations::client::IntegrationClient::post`
|
||
// builds the reqwest error chain and routes it through
|
||
// `report_error_or_expected(.., "integrations", "post", &[("failure",
|
||
// "transport")])`. The chain text contains the
|
||
// `"error sending request for url"` anchor so
|
||
// `is_network_unreachable_message` matches first and demotes to
|
||
// `NetworkUnreachable` (functionally equivalent to
|
||
// `TransientUpstreamHttp` for Sentry suppression — both routes
|
||
// skip the report path via `report_expected_message`).
|
||
//
|
||
// Pinning this exact wire shape catches a future refactor that
|
||
// drops the URL anchor (e.g. a chain-flatten helper that strips
|
||
// it for "PII safety"), which would silently re-open the leak.
|
||
let chain = "error sending request for url \
|
||
(https://api.tinyhumans.ai/agent-integrations/composio/execute) → \
|
||
client error (SendRequest) → connection error → \
|
||
Operation timed out (os error 60)";
|
||
assert_eq!(
|
||
expected_error_kind(chain),
|
||
Some(ExpectedErrorKind::NetworkUnreachable),
|
||
"TAURI-18 chain shape must classify as NetworkUnreachable"
|
||
);
|
||
|
||
// If the URL anchor is ever dropped, the transport-phrase
|
||
// fallback (`operation timed out` from
|
||
// `TRANSIENT_TRANSPORT_PHRASES`) catches it via the message
|
||
// classifier helper used at upstream re-emit sites — confirm
|
||
// both paths so the regression surface is fully pinned.
|
||
assert!(
|
||
is_transient_message_failure(chain),
|
||
"TAURI-18 chain must also satisfy upstream message classifier \
|
||
(defense-in-depth for sites that lose the URL anchor)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_operation_timed_out_classifies_as_expected() {
|
||
// OPENHUMAN-TAURI-EM (128 events) + TAURI-RUST-15/-BB: `channels::runtime::supervision`
|
||
// wraps a channel listener failure as
|
||
// `format!("Channel {} error: {e:#}; restarting", ch.name())` and
|
||
// routes the message through `report_error_or_expected`. The
|
||
// newer `ChannelSupervisorRestart` classifier (added for the
|
||
// broader 11.4k-event Sentry leak) anchors on the supervisor
|
||
// wrapper shape itself — `"Channel <name> error: …; restarting"`
|
||
// — and takes precedence over `NetworkUnreachable`. That single
|
||
// arm now covers every ETIMEDOUT / WSAETIMEDOUT / hyper-prose
|
||
// shape the old narrower anchor pinned, plus OS-localized
|
||
// variants the English-only `NetworkUnreachable` would miss.
|
||
//
|
||
// Demotion tier difference: `ChannelSupervisorRestart` emits at
|
||
// `info!` (breadcrumb only, no Sentry event) where
|
||
// `NetworkUnreachable` emitted at `warn!` (still captured as a
|
||
// Sentry warn event). Sustained outages still page via
|
||
// `health.bus` / `FAIL_ESCALATE_THRESHOLD`.
|
||
for raw in [
|
||
// macOS (os error 60 = ETIMEDOUT on BSD)
|
||
"Channel discord error: IO error: Operation timed out (os error 60); restarting",
|
||
// Linux (os error 110 = ETIMEDOUT)
|
||
"Channel discord error: IO error: Operation timed out (os error 110); restarting",
|
||
// Windows (os error 10060 = WSAETIMEDOUT)
|
||
"Channel discord error: IO error: Operation timed out (os error 10060); restarting",
|
||
// Same shape on other channels — supervisor wrapper is provider-agnostic.
|
||
"Channel slack error: IO error: Operation timed out (os error 60); restarting",
|
||
"Channel telegram error: IO error: Operation timed out (os error 110); restarting",
|
||
// Bare prose form (no errno suffix) from hyper / tungstenite layers
|
||
// that render `std::io::Error` without `raw_os_error()`.
|
||
"Channel discord error: WebSocket connect: IO error: Operation timed out; restarting",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"channel supervisor timeout shape must classify as ChannelSupervisorRestart \
|
||
(precedence over NetworkUnreachable; got {:?} for {raw:?})",
|
||
expected_error_kind(raw)
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn operation_timed_out_negative_cases_still_report() {
|
||
// Counter-case: a configuration/validation message that mentions
|
||
// "timeout" as a knob name (not transport state) and has no other
|
||
// classifier anchor must still reach Sentry. The substring chosen
|
||
// for the new matcher is `"operation timed out"`, not `"timeout"`,
|
||
// precisely so unrelated mentions of the word do not collide.
|
||
assert_eq!(
|
||
expected_error_kind("config rejected: timeout must be a positive integer"),
|
||
None,
|
||
"config validation noise (no 'operation timed out' anchor) must still reach Sentry"
|
||
);
|
||
// Bare empty string — no anchors at all.
|
||
assert_eq!(expected_error_kind(""), None);
|
||
}
|
||
|
||
#[test]
|
||
fn channels_dispatch_re_emit_of_provider_502_classifies_as_transient() {
|
||
// OPENHUMAN-TAURI-4F (~157 events) / -1C (~87 events) / -8F
|
||
// (~39 events): the reliable provider layer retried 5xx, the
|
||
// agent re-raised the error, and `channels::runtime::dispatch`
|
||
// re-emitted it under `domain="channels", operation="dispatch_llm_error"`
|
||
// via raw `report_error` (which skips classification). Switching
|
||
// that site to `report_error_or_expected` routes the chain
|
||
// through this classifier — but only works if the canonical
|
||
// `"OpenHuman API error (NNN ...)"` substring still anchors the
|
||
// match through the channels-layer wrapping.
|
||
//
|
||
// The wrapping shape at the dispatch site is the agent error
|
||
// chain rendered via `format!("{e:#}")`. For a backend 502 from
|
||
// `providers::ops::api_error`, that resolves to:
|
||
// "OpenHuman API error (502 Bad Gateway): error code: 502"
|
||
// possibly prepended with a runner / iteration prefix. Both
|
||
// shapes must classify as transient so the dispatch re-emit
|
||
// gets demoted.
|
||
for raw in [
|
||
"OpenHuman API error (502 Bad Gateway): error code: 502",
|
||
"agent.provider_chat failed: OpenHuman API error (503 Service Unavailable): retry budget exhausted",
|
||
"all providers exhausted: OpenHuman API error (504 Gateway Timeout): error code: 504",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"channels.dispatch re-emit of {raw:?} must classify as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_socket_transient_http_errors() {
|
||
// OPENHUMAN-TAURI-5P / -EZ: tungstenite's `WsError::Http(response)`
|
||
// surfaces during the WebSocket upgrade handshake when the backend
|
||
// load balancer returns 502 / 504. The socket reconnect loop wraps
|
||
// it as `format!("WebSocket connect: {e}")`, producing
|
||
// `"WebSocket connect: HTTP error: <status> <reason>"`. Each
|
||
// sustained-outage threshold escalation routes the formatted reason
|
||
// through `report_error_or_expected`, which must classify as
|
||
// transient so the per-client noise stops reaching Sentry.
|
||
for raw in [
|
||
"WebSocket connect: HTTP error: 502 Bad Gateway",
|
||
"WebSocket connect: HTTP error: 503 Service Unavailable",
|
||
"WebSocket connect: HTTP error: 504 Gateway Timeout",
|
||
"[socket] Connection failed (sustained outage after 5 attempts): \
|
||
WebSocket connect: HTTP error: 502 Bad Gateway",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp),
|
||
"should classify as transient upstream HTTP (socket shape): {raw}"
|
||
);
|
||
}
|
||
|
||
// Trailing-colon separator (chained error formatting).
|
||
// Note: avoid words like "connection refused" or "timeout" in the
|
||
// suffix — those would also match `is_network_unreachable_message` /
|
||
// `TRANSIENT_TRANSPORT_PHRASES` and the order in `expected_error_kind`
|
||
// would route through `NetworkUnreachable` first, defeating the
|
||
// assertion. Both classifications silence the event so production
|
||
// behavior is identical, but the test is anchored on the canonical
|
||
// socket shape so a future regression in `is_transient_upstream_http_message`
|
||
// surfaces here, not behind another classifier.
|
||
assert_eq!(
|
||
expected_error_kind("WebSocket connect: HTTP error: 502: upstream returned bad gateway"),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
|
||
// Trailing-newline separator (multi-line error chain).
|
||
assert_eq!(
|
||
expected_error_kind("WebSocket connect: HTTP error: 504\nupstream gateway"),
|
||
Some(ExpectedErrorKind::TransientUpstreamHttp)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_http_error_text_as_transient_socket() {
|
||
// Bare numeric "HTTP error: 5023" (port number, runbook ID) without
|
||
// a separator must NOT silence — pin the matcher to space/newline/colon.
|
||
assert_eq!(expected_error_kind("HTTP error: 5023"), None);
|
||
// Non-transient HTTP statuses must not match — `WsError::Http` for
|
||
// a 401 / 403 / 404 is genuinely actionable (auth / routing bug).
|
||
for raw in [
|
||
"WebSocket connect: HTTP error: 401 Unauthorized",
|
||
"WebSocket connect: HTTP error: 403 Forbidden",
|
||
"WebSocket connect: HTTP error: 404 Not Found",
|
||
"WebSocket connect: HTTP error: 500 Internal Server Error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT silence actionable socket HTTP error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_actionable_provider_errors_as_transient_upstream() {
|
||
// 4xx (other than 408/429) and non-transient 5xx must continue to
|
||
// reach Sentry — those are real bugs (wrong model name, malformed
|
||
// request, internal exception) that need to be triaged.
|
||
for raw in [
|
||
"OpenAI API error (400): bad request",
|
||
"OpenAI API error (401): unauthorized",
|
||
"OpenAI API error (403): forbidden",
|
||
"OpenAI API error (404): model not found",
|
||
"OpenAI API error (500): internal server error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"must NOT silence actionable provider error: {raw}"
|
||
);
|
||
}
|
||
|
||
// A free-form message that merely mentions "504" without the
|
||
// `api error (` prefix must not be classified — pin the match to
|
||
// the canonical shape from `ops::api_error`.
|
||
assert_eq!(
|
||
expected_error_kind("see runbook for 504 handling at https://example.com/504"),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_backend_user_error_responses() {
|
||
// OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the user
|
||
// didn't fill in the required Tenant Name field. After the
|
||
// ProviderUserState classifier was added (#1472 wave E), this
|
||
// canonical shape now lands in the more specific
|
||
// ProviderUserState bucket — `"missing required fields"` wins
|
||
// over the generic 4xx matcher. Either expected-kind silences
|
||
// Sentry; the dedicated bucket gives operators a finer-grained
|
||
// `kind="provider_user_state"` info-log facet for triage.
|
||
let bc = "Backend returned 400 Bad Request for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/authorize: \
|
||
Composio authorization failed: 400 \
|
||
{\"error\":{\"message\":\"Missing required fields: Tenant Name\",\
|
||
\"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}";
|
||
assert_eq!(
|
||
expected_error_kind(bc),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"OPENHUMAN-TAURI-BC wire shape must classify as ProviderUserState (the \
|
||
more specific bucket once #1472 wave E added it)"
|
||
);
|
||
|
||
// Cover the rest of the 4xx surface produced by integrations /
|
||
// composio clients — all user-input / auth-state failures that
|
||
// Sentry can't action.
|
||
for raw in [
|
||
"Backend returned 400 Bad Request for POST https://api.example.com/x: bad input",
|
||
"Backend returned 401 Unauthorized for GET https://api.example.com/x: token expired",
|
||
"Backend returned 403 Forbidden for GET https://api.example.com/x: permission denied",
|
||
"Backend returned 404 Not Found for GET https://api.example.com/x: missing",
|
||
"Backend returned 422 Unprocessable Entity for POST https://api.example.com/x: validation failed",
|
||
"Backend returned 451 Unavailable for Legal Reasons for GET https://api.example.com/x: blocked",
|
||
// Lowercased context wrapping is irrelevant — substring match is case-insensitive.
|
||
"[observability] integrations.post failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/x: detail",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::BackendUserError),
|
||
"must classify as backend user-error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_transient_or_server_backend_errors_as_user_error() {
|
||
// 408 / 429 are transient — they belong to the
|
||
// upstream-transient bucket (or are retried at the caller), not
|
||
// the user-error bucket. A sustained 429 (rate limit cliff) MUST
|
||
// still surface so we can react.
|
||
for raw in [
|
||
"Backend returned 408 Request Timeout for POST https://api.example.com/x: timeout",
|
||
"Backend returned 429 Too Many Requests for POST https://api.example.com/x: slow down",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"transient 4xx must NOT be classified as user-error: {raw}"
|
||
);
|
||
}
|
||
|
||
// 5xx is always actionable — server bugs need to reach Sentry.
|
||
for raw in [
|
||
"Backend returned 500 Internal Server Error for POST https://api.example.com/x: oops",
|
||
"Backend returned 502 Bad Gateway for POST https://api.example.com/x: upstream down",
|
||
"Backend returned 503 Service Unavailable for POST https://api.example.com/x: maintenance",
|
||
"Backend returned 504 Gateway Timeout for POST https://api.example.com/x: slow upstream",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"5xx must NOT be classified as user-error: {raw}"
|
||
);
|
||
}
|
||
|
||
// A free-form message that mentions "400" but doesn't follow the
|
||
// `Backend returned <status>` prefix from the integrations /
|
||
// composio clients must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind("see HTTP 400 specification at https://example.com/400"),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (400): bad request"),
|
||
None,
|
||
"provider-formatted 4xx must keep going through the provider classifier path"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_trigger_type_not_found_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug
|
||
// isn't in the trigger registry. Backend wraps the upstream
|
||
// composio 4xx as 500, so this would otherwise escape the
|
||
// 4xx-only `is_backend_user_error_message` matcher.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/triggers: \
|
||
Trigger type GITHUB_PUSH_EVENT not found"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Wrapped by `rpc.invoke_method` / `[composio] sync(toolkit) failed: …`
|
||
// — substring match must survive caller context.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"rpc.invoke_method failed: Backend returned 500 Internal Server Error \
|
||
for POST /agent-integrations/composio/triggers: \
|
||
Trigger type SLACK_NEW_MESSAGE not found"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Alternate phrasing observed from the same cluster.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"composio: Cannot enable trigger 'GITHUB_PUSH_EVENT': trigger not found in registry"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_toolkit_not_enabled_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-34: 400 from composio because the user hasn't
|
||
// enabled the toolkit. Must classify as ProviderUserState (more
|
||
// specific) rather than the generic BackendUserError bucket — the
|
||
// ordering in `expected_error_kind` enforces that.
|
||
let msg = "Backend returned 400 Bad Request for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/execute: \
|
||
Toolkit \"get\" is not enabled";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Wrapped variant (anyhow chain through the agent runtime).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"tool.invoke failed: Backend returned 400 Bad Request for POST \
|
||
/agent-integrations/composio/execute: Toolkit \"linear\" is not enabled \
|
||
for this account"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_custom_openai_upstream_bad_request_as_provider_user_state() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400 Bad Request): \
|
||
{\"error\":{\"message\":\"Bad request to upstream provider\",\
|
||
\"type\":\"upstream_error\",\"status\":400}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Wrapped by higher-level callers (`agent.run_single`,
|
||
// `rpc.invoke_method`) must still classify.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent.run_single failed: custom_openai API error (400 Bad Request): \
|
||
{\"error\":{\"message\":\"Bad request to upstream provider\",\
|
||
\"type\":\"upstream_error\",\"status\":400}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
/// Regression for CodeRabbit feedback on PR #2107: the matcher must
|
||
/// not demote unrelated errors that happen to contain both
|
||
/// "bad request to upstream provider" and "upstream_error" without
|
||
/// the `custom_openai API error (400` anchor.
|
||
#[test]
|
||
fn does_not_silence_unrelated_error_with_only_inner_substrings() {
|
||
// No `custom_openai API error (400` prefix → must NOT classify
|
||
// as ProviderUserState, otherwise we'd silence actionable bugs.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"internal panic in router: bad request to upstream provider \
|
||
(state=upstream_error)"
|
||
),
|
||
None,
|
||
);
|
||
|
||
// A future hypothetical provider envelope reusing one substring
|
||
// also must not classify.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"anthropic_api error: upstream_error encountered while \
|
||
forwarding bad request to upstream provider"
|
||
),
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_missing_required_fields_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-97: composio authorize with a blank required
|
||
// field. Backend wraps the composio 400 as 500 with the inner
|
||
// body embedded as a JSON-stringified error message.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for POST \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/authorize: \
|
||
400 {\"error\":{\"message\":\"Missing required fields: Your Subdomain\"}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Sibling toolkits surface the same shape with different field names.
|
||
for raw in [
|
||
"Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: WABA ID",
|
||
"Backend returned 500 Internal Server Error for POST /authorize: Missing required fields: Tenant Name",
|
||
"Backend returned 400 Bad Request for POST /authorize: Missing required fields: Domain URL",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"missing-required-fields shape must classify: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_insufficient_scopes_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-33: gmail sync surfaced the upstream Google
|
||
// OAuth scopes error verbatim through composio. Reaches the RPC
|
||
// dispatch site via `[composio] sync(gmail) failed: [composio:gmail]
|
||
// GMAIL_FETCH_EMAILS page 0: HTTP 403: Request had insufficient
|
||
// authentication scopes.`.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"[composio:gmail] GMAIL_FETCH_EMAILS page 0: HTTP 403: \
|
||
Request had insufficient authentication scopes."
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
// Bare upstream shape (in case any future caller forwards without
|
||
// the gmail prefix).
|
||
assert_eq!(
|
||
expected_error_kind("HTTP 403: Request had insufficient authentication scopes."),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_access_terminated_provider_policy_as_provider_user_state() {
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (403 Forbidden): {\"error\":{\"message\":\"Kimi For Coding is currently only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code, Kilo Code, etc.\",\"type\":\"access_terminated_error\"}}"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent turn failed: custom_openai API error (403): currently only available for coding agents"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderUserState)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_500s_as_provider_user_state() {
|
||
// Sanity check: a generic 500 with no provider-user-state body
|
||
// shape must continue to reach Sentry as an actionable event.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for POST \
|
||
/agent-integrations/composio/triggers: random panic in handler"
|
||
),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"Backend returned 500 Internal Server Error for GET /teams: database connection lost"
|
||
),
|
||
None
|
||
);
|
||
|
||
// Free-form text that mentions "not found" / "is not enabled" out
|
||
// of context must not be silenced.
|
||
assert_eq!(
|
||
expected_error_kind("file not found at /tmp/x.json"),
|
||
None,
|
||
"bare 'not found' without 'trigger type' anchor must NOT classify"
|
||
);
|
||
assert_eq!(
|
||
expected_error_kind("the cache is not enabled in this build"),
|
||
None,
|
||
"bare 'is not enabled' without 'toolkit ' anchor must NOT classify"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_provider_config_rejection() {
|
||
// #2079 — an OpenHuman abstract tier alias leaked to a custom
|
||
// provider; raised again by `agent.run_single` /
|
||
// `web_channel.run_chat_task` so it escapes the provider-layer
|
||
// demotion and reaches `report_error_or_expected` here.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"agent.run_single failed: custom_openai API error (400 Bad Request): \
|
||
The supported API model names are deepseek-v4-pro or deepseek-v4-flash, \
|
||
but you passed reasoning-v1."
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
// #2076 — Moonshot Kimi K2 temperature constraint.
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400): invalid temperature: only 1 is allowed for this model"
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
// #2202 — unknown / stale model pin (OpenAI-compatible body).
|
||
assert_eq!(
|
||
expected_error_kind(
|
||
"custom_openai API error (400): Model 'claude-opus-4-7' is not available. \
|
||
Use GET /openai/v1/models to list available models."
|
||
),
|
||
Some(ExpectedErrorKind::ProviderConfigRejection)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn 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 <!DOCTYPE …
|
||
// Just a moment... … cloudflare …`. The 500 escapes the 4xx-only
|
||
// `is_backend_user_error_message` classifier, so this body-shape
|
||
// arm catches it and demotes to `ProviderUserState`.
|
||
let msg = r#"Backend returned 500 Internal Server Error for GET https://api.tinyhumans.ai/agent-integrations/composio/connections: 403 <!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="robots" content="noindex,nofollow"><meta name="viewport" content="width=device-width,initial-scale=1"><link href="/cdn-cgi/styles/challenges.css" rel="stylesheet"></head><body class="no-js"><div class="main-wrapper" role="main"><div class="main-content"><h1 class="zone-name-title h1"><img class="heading-favicon" src="/favicon.ico" onerror="this.onerror=null;this.parentNode.removeChild(this)" alt="Icon for api.tinyhumans.ai">api.tinyhumans.ai</h1>...Powered by Cloudflare..."#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"backend-wrapped Cloudflare anti-bot interstitial must demote to ProviderUserState"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_minimal_cloudflare_antibot_body_as_provider_user_state() {
|
||
// Strip the wire shape down to just the two anchors — the
|
||
// matcher should still fire so future renderings (different
|
||
// line breaks, stripped HTML, alternate caller wrappers) still
|
||
// demote.
|
||
let msg = "Just a moment...\ncloudflare\n";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"minimal `Just a moment...` + `cloudflare` body must demote"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_half_anchor_cloudflare_messages_as_user_state() {
|
||
// Discrimination test for the double-anchor: either half on its
|
||
// own must NOT match. This guards against unrelated bodies that
|
||
// happen to use either phrase out of context.
|
||
|
||
// Half-anchor 1: `just a moment` without `cloudflare` — e.g.
|
||
// a daemon restart spinner blurb.
|
||
let half_a = "Just a moment, while we restart the daemon";
|
||
assert_ne!(
|
||
expected_error_kind(half_a),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"`Just a moment` without `cloudflare` must NOT match the CF anti-bot arm"
|
||
);
|
||
|
||
// Half-anchor 2: `cloudflare` without `just a moment...` — e.g.
|
||
// a CF Workers footer mention elsewhere.
|
||
let half_b = "Powered by Cloudflare";
|
||
assert_ne!(
|
||
expected_error_kind(half_b),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"`cloudflare` without `Just a moment...` must NOT match the CF anti-bot arm"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_genuine_backend_500_without_cloudflare_body() {
|
||
// Real bug shape — a 500 from the same backend endpoint with no
|
||
// Cloudflare interstitial body — must still fall through so
|
||
// Sentry sees it. Without this guard the arm could be too
|
||
// permissive and silence genuine database / handler faults.
|
||
let msg = "Backend returned 500 Internal Server Error for GET \
|
||
https://api.tinyhumans.ai/agent-integrations/composio/connections: \
|
||
database connection pool exhausted";
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
None,
|
||
"genuine backend 500 without Cloudflare body must NOT demote — it is a real bug"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn classifies_list_models_404_as_provider_user_state() {
|
||
// OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed
|
||
// a custom-provider's `/models` endpoint and the upstream server
|
||
// returned 404 because the base URL is wrong / doesn't host a models
|
||
// listing. User-config state — the model-dropdown probe already
|
||
// surfaces it inline. Pin the verbatim Sentry payload plus a few
|
||
// body-shape variants (different upstreams emit different 404 bodies)
|
||
// so the path-agnostic prefix anchor stays the source of truth.
|
||
for raw in [
|
||
// Verbatim shape from the Sentry event.
|
||
r#"provider returned 404: {"error":"path \"/api/v1/models\" not found"}"#,
|
||
// FastAPI-style: `{"detail":"Not Found"}`.
|
||
r#"provider returned 404: {"detail":"Not Found"}"#,
|
||
// Bare HTML — happens when the user pointed at a non-API origin
|
||
// (e.g. the provider's docs site).
|
||
"provider returned 404: <html><body>Not Found</body></html>",
|
||
// After `truncate_with_ellipsis(.., 300)` clips a longer body —
|
||
// prefix anchor must still match.
|
||
r#"provider returned 404: {"error":{"message":"The requested URL /api/v1/models was not found on this server. Please check the URL or co…"#,
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ProviderUserState),
|
||
"OPENHUMAN-TAURI-YJ list_models 404 must classify as ProviderUserState: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn 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 `"<prefix> (401"` + envelope-shaped
|
||
/// `"\"error\":\"Invalid token\""` anchor pattern as 4P0 so the
|
||
/// #2286 / BYO-key contract is preserved — covered by
|
||
/// `does_not_classify_byo_key_provider_401_as_session_expired` and
|
||
/// `does_not_classify_embedding_byo_key_401_as_session_expired`
|
||
/// (below).
|
||
#[test]
|
||
fn classifies_embedding_api_invalid_token_401_as_session_expired() {
|
||
// Verbatim wire shape from the TAURI-RUST-4K5 event payload (Sentry
|
||
// issue 5230, latest event 2026-05-27 20:49 on openhuman@0.56.0,
|
||
// domain=embeddings operation=openai_embed status=401).
|
||
let msg =
|
||
r#"Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"TAURI-RUST-4K5 verbatim wire shape must classify as SessionExpired"
|
||
);
|
||
|
||
// The substring matcher must survive caller wrapping the same way
|
||
// the 4P0 web-channel `run_chat_task` test wraps the body — callers
|
||
// that re-emit through a tracing field or another layer prepend
|
||
// arbitrary context.
|
||
let wrapped = r#"openai_embed failed error=Embedding API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(wrapped),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"wrapped 4K5 envelope must still classify as SessionExpired"
|
||
);
|
||
}
|
||
|
||
/// TAURI-RUST-1EE (Sentry issue 1807, 110 events, 109 on
|
||
/// openhuman@0.56.0): the streaming-chat path wraps the same OpenHuman
|
||
/// backend `{"success":false,"error":"Invalid token"}` 401 envelope
|
||
/// with the `"OpenHuman streaming API error"` prefix (emitted at
|
||
/// `inference/provider/compatible.rs:949`) — distinct from the
|
||
/// non-streaming `"OpenHuman API error"` prefix (4P0) and the
|
||
/// `"Embedding API error"` prefix (4K5). The `streaming` token between
|
||
/// `OpenHuman` and `API error` means the 4P0 anchor
|
||
/// (`"OpenHuman API error (401"`) does not match it, so it needs its
|
||
/// own prefix arm.
|
||
#[test]
|
||
fn classifies_openhuman_streaming_invalid_token_401_as_session_expired() {
|
||
// Verbatim wire shape from the TAURI-RUST-1EE event payload
|
||
// (domain=llm_provider operation=streaming_chat status=401
|
||
// provider=OpenHuman model=reasoning-v1).
|
||
let msg = r#"OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(msg),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"TAURI-RUST-1EE verbatim streaming wire shape must classify as SessionExpired"
|
||
);
|
||
|
||
// Caller-wrapped (agent.run_single / web_channel.run_chat_task
|
||
// re-emit prepends context) must still classify.
|
||
let wrapped = r#"run_chat_task failed error=OpenHuman streaming API error (401 Unauthorized): {"success":false,"error":"Invalid token"}"#;
|
||
assert_eq!(
|
||
expected_error_kind(wrapped),
|
||
Some(ExpectedErrorKind::SessionExpired),
|
||
"wrapped 1EE streaming envelope must still classify as SessionExpired"
|
||
);
|
||
}
|
||
|
||
/// Polarity guard for the 1EE streaming arm — a third-party BYO-key
|
||
/// provider's streaming 401 (`"OpenAI streaming API error (401 …):
|
||
/// invalid_api_key"`) must STILL reach Sentry as actionable
|
||
/// misconfiguration. The `"OpenHuman streaming API error (401"` prefix
|
||
/// gate keeps the match OpenHuman-scoped.
|
||
#[test]
|
||
fn does_not_classify_streaming_byo_key_401_as_session_expired() {
|
||
for raw in [
|
||
"OpenAI streaming API error (401 Unauthorized): invalid_api_key",
|
||
r#"OpenAI streaming API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
"Anthropic streaming API error (401): authentication_error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key streaming 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Polarity guard for the 4K5 arm. The classifier must NOT swallow
|
||
/// `"Embedding API error (401 …)"` shapes from third-party BYO-key
|
||
/// embedding providers (OpenAI / Voyage / Cohere upstream rejecting
|
||
/// the user's own API key). Those are actionable user-config errors
|
||
/// that need to reach Sentry — same contract as
|
||
/// `does_not_classify_byo_key_provider_401_as_session_expired` for
|
||
/// the OpenAI chat API.
|
||
#[test]
|
||
fn does_not_classify_embedding_byo_key_401_as_session_expired() {
|
||
for raw in [
|
||
"Embedding API error (401 Unauthorized): invalid_api_key",
|
||
r#"Embedding API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
// Wire shape without the OpenHuman envelope — bare provider
|
||
// rejection prose. Must reach Sentry as actionable BYO-key
|
||
// misconfiguration.
|
||
"Embedding API error (401): authentication_error",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key embedding 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_byo_key_provider_401_as_session_expired() {
|
||
// Critical: a BYO-key 401 from OpenAI / Anthropic etc. is an
|
||
// actionable misconfiguration (wrong API key) that the user needs
|
||
// to fix in settings. It must reach Sentry as an error and must
|
||
// NOT be classified as session-expired at the agent layer — the
|
||
// strict classifier requires the OpenHuman backend's
|
||
// "session expired" body to anchor the match. The JSON-RPC
|
||
// dispatch-site classifier uses the same strict rule so these
|
||
// scoped provider failures never clear the app session either.
|
||
for raw in [
|
||
"OpenAI API error (401 Unauthorized): invalid_api_key",
|
||
"Anthropic API error (401 Unauthorized): authentication_error",
|
||
"OpenAI API error (401): unauthorized",
|
||
r#"OpenAI API error (401 Unauthorized): {"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#,
|
||
// Generic "invalid token" without OpenHuman session phrasing —
|
||
// could mean a third-party provider rejected its own token.
|
||
"Invalid token",
|
||
"got an invalid token here",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
None,
|
||
"BYO-key / generic 401 must reach Sentry as actionable error: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn does_not_classify_unrelated_messages_as_session_expired() {
|
||
// Bare numeric 401 (port number, runbook reference) must not be
|
||
// silenced.
|
||
assert_eq!(expected_error_kind("server returned 401"), None);
|
||
assert_eq!(
|
||
expected_error_kind("see runbook for 401 handling at https://example.com/401"),
|
||
None
|
||
);
|
||
// Provider 5xx — must reach Sentry.
|
||
assert_eq!(
|
||
expected_error_kind("OpenAI API error (500): internal server error"),
|
||
None
|
||
);
|
||
// Lowercase sentinel must NOT match — the SESSION_EXPIRED sentinel
|
||
// is case-sensitive by design (matches the sentinel emitted by
|
||
// `providers::openhuman_backend::resolve_bearer` exactly).
|
||
assert_eq!(expected_error_kind("session_expired lowercase"), None);
|
||
}
|
||
|
||
/// 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<String, String> = std::collections::BTreeMap::new();
|
||
for (k, v) in pairs {
|
||
tags.insert((*k).to_string(), (*v).to_string());
|
||
}
|
||
event.tags = tags;
|
||
event
|
||
}
|
||
|
||
fn event_with_tags_and_message(
|
||
pairs: &[(&str, &str)],
|
||
message: &str,
|
||
) -> sentry::protocol::Event<'static> {
|
||
let mut event = event_with_tags(pairs);
|
||
event.message = Some(message.to_string());
|
||
event
|
||
}
|
||
|
||
#[test]
|
||
fn transient_filter_drops_429_408_502_503_504() {
|
||
for status in ["429", "408", "502", "503", "504"] {
|
||
let event = event_with_tags(&[
|
||
("domain", "llm_provider"),
|
||
("failure", "non_2xx"),
|
||
("status", status),
|
||
]);
|
||
assert!(
|
||
is_transient_provider_http_failure(&event),
|
||
"status {status} must be classified as transient and filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn 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 <unknown status code>): cf",
|
||
"POST /channels/telegram/typing failed (429 Too Many Requests)",
|
||
"auth connect failed: 503 Service Unavailable",
|
||
] {
|
||
assert!(
|
||
is_transient_message_failure(msg),
|
||
"{msg:?} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn message_failure_classifier_matches_transport_phrases() {
|
||
for msg in [
|
||
"integrations.get failed: composio/tools → operation timed out",
|
||
"GET https://api.example.com → connection forcibly closed (os 10054)",
|
||
"POST /v1/foo → tls handshake eof",
|
||
"error sending request for url (https://api.example.com)",
|
||
] {
|
||
assert!(
|
||
is_transient_message_failure(msg),
|
||
"{msg:?} must be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn message_failure_classifier_keeps_unrelated_messages() {
|
||
for msg in [
|
||
"rpc.invoke_method failed: schema validation error",
|
||
"process 502 exited unexpectedly",
|
||
"GET /teams failed (404 Not Found)",
|
||
"GET /teams failed (500 Internal Server Error)",
|
||
"unrelated error with port 5023",
|
||
"",
|
||
] {
|
||
assert!(
|
||
!is_transient_message_failure(msg),
|
||
"{msg:?} must not be classified as transient"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_drops_budget_message_on_tagged_400() {
|
||
let event = event_with_tags_and_message(
|
||
&[("failure", "non_2xx"), ("status", "400")],
|
||
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#,
|
||
);
|
||
|
||
assert!(is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_drops_budget_exception_on_tagged_400() {
|
||
let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]);
|
||
event.exception.values.push(sentry::protocol::Exception {
|
||
value: Some("Budget exceeded — add credits to continue".to_string()),
|
||
..Default::default()
|
||
});
|
||
|
||
assert!(is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_keeps_non_budget_400() {
|
||
let event = event_with_tags_and_message(
|
||
&[("failure", "non_2xx"), ("status", "400")],
|
||
"Bad request: missing field",
|
||
);
|
||
|
||
assert!(!is_budget_event(&event));
|
||
}
|
||
|
||
#[test]
|
||
fn budget_filter_requires_non_2xx_failure_and_400_status_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 <name> error: <inner>; restarting"`.
|
||
// The name slot varies by provider (discord, slack, telegram,
|
||
// whatsapp, gmessages, …). The matcher must classify all of them —
|
||
// language-agnostic, name-agnostic.
|
||
for raw in [
|
||
"Channel slack error: gateway disconnect; restarting",
|
||
"Channel telegram error: tls handshake eof; restarting",
|
||
"Channel whatsapp error: connection reset by peer (os error 54); restarting",
|
||
"Channel gmessages error: WebSocket connect: HTTP error: 502 Bad Gateway; restarting",
|
||
] {
|
||
assert_eq!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"should classify as channel-supervisor-restart: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_restart_precedence_over_network_unreachable() {
|
||
// Pin the precedence: a supervisor-wrap body that ALSO contains
|
||
// the canonical `"error sending request for url"` anchor (which
|
||
// would by itself classify as `NetworkUnreachable`) MUST route
|
||
// to `ChannelSupervisorRestart`. The supervisor's own backoff
|
||
// handles the condition; `NetworkUnreachable` would demote to
|
||
// `warn!` (still a Sentry event), whereas
|
||
// `ChannelSupervisorRestart` demotes to `info!` (no event).
|
||
let body = "Channel discord error: error sending request for url \
|
||
(https://discord.com/api/v10/gateway/bot); restarting";
|
||
let kind = expected_error_kind(body);
|
||
assert_eq!(kind, Some(ExpectedErrorKind::ChannelSupervisorRestart));
|
||
assert_ne!(kind, Some(ExpectedErrorKind::NetworkUnreachable));
|
||
}
|
||
|
||
#[test]
|
||
fn channel_supervisor_restart_does_not_classify_unrelated_restart_notes() {
|
||
// Defense against the matcher being too eager: bodies that
|
||
// contain `"; restarting"` but NOT the `"Channel <name> error:"`
|
||
// preamble must NOT classify — those are generic restart logs
|
||
// from other subsystems where Sentry signal may still be
|
||
// actionable. The matcher requires all three anchors together
|
||
// (`"channel "` prefix + `" error:"` separator + `"; restarting"`
|
||
// trailer).
|
||
for raw in [
|
||
// No `Channel <name>` preamble.
|
||
"systemd: docker.service; restarting",
|
||
// No `Channel <name>` preamble even though `; restarting`
|
||
// appears.
|
||
"Connection refused; restarting",
|
||
// The string `channel` appears but not as the leading
|
||
// `"Channel <name> error:"` wrapper — must not classify.
|
||
"channels::runtime::dispatch failed: error: provider exhausted; restarting",
|
||
// The wrapper prefix is present but the trailer is not —
|
||
// a half-formed log line must not classify.
|
||
"Channel discord error: gateway disconnect",
|
||
] {
|
||
assert_ne!(
|
||
expected_error_kind(raw),
|
||
Some(ExpectedErrorKind::ChannelSupervisorRestart),
|
||
"must NOT classify as channel-supervisor-restart: {raw}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn report_error_or_expected_routes_channel_supervisor_restart_through_expected_path() {
|
||
// Smoke test: the verbatim TAURI-RUST-15 Sentry body flows through
|
||
// `report_error_or_expected` without panicking. The classifier
|
||
// routes it to `report_expected_message` (info breadcrumb) instead
|
||
// of `report_error_message` (`sentry::capture_message` at error
|
||
// level). We can't observe the Sentry hub from this test, but
|
||
// exercising the call path catches any future regression that
|
||
// re-introduces a panic or mis-types the arm.
|
||
report_error_or_expected(
|
||
"Channel discord error: error sending request for url \
|
||
(https://discord.com/api/v10/gateway/bot); restarting",
|
||
"channels",
|
||
"supervised_listener",
|
||
&[("channel", "discord")],
|
||
);
|
||
}
|