diff --git a/src/core/observability.rs b/src/core/observability.rs index 2c023034f..061c8bdc4 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -294,6 +294,27 @@ pub enum ExpectedErrorKind { /// couldn't parse, and the FE *does* page for it, F8). See /// [`crate::openhuman::inference::provider::backend_error_code_skips_sentry`]. BackendErrorCodeOwned, + /// A provider embedding call (Cohere `/v2/embed`, OpenAI/Voyage embed, + /// custom OpenAI-compatible embed) returned a **403/Forbidden gateway + /// HTML page** instead of the provider's JSON error envelope — the + /// signature of an edge/CDN/WAF or regional block sitting *in front of* + /// the provider API (the request never reached the provider app). The + /// endpoint is correct and a key was sent (the empty-key fast-fail guard + /// already passed), so there is no local lever: retry won't clear an edge + /// policy keyed on the user's network reputation / geo / IP. The + /// embedding caller already degrades gracefully and the UI surfaces the + /// failure; Sentry has no remediation path. + /// + /// Anchored on HTML gateway markers (`403`, + /// `403 forbidden`) **and the absence of a JSON envelope** so an + /// actionable JSON 4xx (Cohere's `{"message": …}`, a real request-shape + /// bug in our client) still classifies `None` and reaches Sentry. See + /// [`is_upstream_edge_block_message`]. + /// + /// Drops Sentry TAURI-RUST-8S3 (~1.7 k events / 3 users on + /// openhuman@0.57.53, `Cohere embed API error (403 Forbidden): + /// …403…`). + UpstreamEdgeBlock, /// `approval_decide` (`src/openhuman/approval/rpc.rs`) resolved a request_id /// whose pending row was **already decided, lazily expired, or superseded** /// — `store::decide` updated 0 rows because `decided_at` was already set, @@ -446,6 +467,14 @@ pub fn expected_error_kind(message: &str) -> Option { if is_network_unreachable_message(&lower) { return Some(ExpectedErrorKind::NetworkUnreachable); } + // Check `is_upstream_edge_block_message` BEFORE the transient/backend + // matchers: an HTML 403 gateway page from a provider embed call carries a + // `403`/`forbidden` token that no other matcher claims, but routing it to + // its own bucket keeps "edge/CDN block" distinct from genuine transient + // 5xx and from the actionable JSON 4xx that must still page (TAURI-RUST-8S3). + if is_upstream_edge_block_message(&lower) { + return Some(ExpectedErrorKind::UpstreamEdgeBlock); + } if is_transient_upstream_http_message(&lower) { return Some(ExpectedErrorKind::TransientUpstreamHttp); } @@ -1331,6 +1360,57 @@ fn is_transient_upstream_http_message(lower: &str) -> bool { }) } +/// Detect a non-2xx **HTML 403/Forbidden gateway page** returned to a provider +/// embedding call — the signature of an edge/CDN/WAF or regional block sitting +/// in front of the provider API, where the request never reached the provider +/// app and the body is a generic gateway error page rather than the provider's +/// JSON error envelope. +/// +/// Canonical wire shape (TAURI-RUST-8S3, `CohereEmbedding::embed` emit site): +/// `"Cohere embed API error (403 Forbidden): …403403 Forbidden"`. +/// The same shape also covers the OpenAI/Voyage and custom OpenAI-compatible +/// embed paths when their upstream is fronted by the same edge tier, so we do +/// not pin to a single provider prefix. +/// +/// Two conditions must BOTH hold: +/// +/// 1. A **403 token** is present — either the `403` status inside the embed +/// error prefix or a bare `403 forbidden` in the body. +/// 2. An **HTML gateway marker** is present (`403`) — proving the body is a gateway page, not the provider's +/// structured error. +/// +/// And the body must **not** look like a JSON envelope (no `{` … `"message"` / +/// `"error"` JSON shape). This is the discrimination guard: Cohere's real JSON +/// 403 (`{"message": "invalid api key"}`) and any genuine request-shape bug in +/// our client that round-trips a JSON 4xx stay classified `None` and keep +/// reaching Sentry. Only the bodiless edge HTML page is demoted. +/// +/// `genuinely-unpreventable`: endpoint correct, key sent, retry can't clear an +/// edge policy keyed on the user's network reputation / geo / IP — Sentry has +/// no remediation path. +fn is_upstream_edge_block_message(lower: &str) -> bool { + // Must originate from an embedding call so we don't silence an HTML 403 + // surfaced by some unrelated path. + let is_embed = lower.contains("embed"); + if !is_embed { + return false; + } + // A JSON envelope means the provider's app answered with a structured + // error — actionable, must page. Don't demote. + let looks_like_json = lower.contains('{') + && (lower.contains("\"message\"") + || lower.contains("\"error\"") + || lower.contains("\"detail\"")); + if looks_like_json { + return false; + } + let has_403 = lower.contains("(403 ") || lower.contains("403 forbidden"); + let has_html_marker = + lower.contains("403"); + has_403 && has_html_marker +} + /// Detect non-2xx HTTP failures returned from the backend integrations / composio /// clients that are by definition user-input or user-auth-state problems — not /// bugs Sentry can act on. @@ -2113,6 +2193,21 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, "[observability] {domain}.{operation} skipped backend-owned errorCode={code} error: {message}" ); } + ExpectedErrorKind::UpstreamEdgeBlock => { + // Provider embed call hit an edge/CDN/WAF or regional 403 block — + // the body is a generic HTML gateway page, not the provider's JSON + // error. Endpoint correct, key sent, retry can't clear an edge + // policy; the embedding caller degrades gracefully and the UI + // surfaces it. Demote at `warn!` so the breadcrumb retains the + // shape for triage without spawning an event (TAURI-RUST-8S3). + tracing::warn!( + domain = domain, + operation = operation, + kind = "upstream_edge_block", + error = %message, + "[observability] {domain}.{operation} skipped upstream edge/CDN 403 block: {message}" + ); + } ExpectedErrorKind::ApprovalNoPendingRace => { // Benign approval race (TAURI-RUST-5EH): the request_id was already // decided / lazily expired / superseded — `store::get_decision` @@ -3249,6 +3344,86 @@ mod tests { ); } + #[test] + fn classifies_embedding_html_403_edge_block_as_upstream_edge_block() { + // TAURI-RUST-8S3: edge/CDN/WAF or regional 403 block in front of + // api.cohere.com — the body is a generic HTML gateway page, not + // Cohere's JSON error envelope. Endpoint correct, key sent, no local + // lever → demote. + let cohere_html_403 = "Cohere embed API error (403 Forbidden): \ + 403403 Forbidden"; + assert_eq!( + expected_error_kind(cohere_html_403), + Some(ExpectedErrorKind::UpstreamEdgeBlock), + "HTML 403 gateway page from an embed call must classify as UpstreamEdgeBlock: {cohere_html_403}" + ); + + // Generic embed shape (openai/voyage/custom embed path) fronted by the + // same edge tier — also demoted (matcher is not pinned to one provider). + let openai_html_403 = + "Embedding API error (403 Forbidden): 403 Forbidden"; + assert_eq!( + expected_error_kind(openai_html_403), + Some(ExpectedErrorKind::UpstreamEdgeBlock), + "generic embed HTML 403 must classify as UpstreamEdgeBlock: {openai_html_403}" + ); + } + + #[test] + fn does_not_classify_embedding_json_403_as_edge_block() { + // A JSON 403 envelope means the provider's app answered with a + // structured, actionable error (bad key, blocked org) — must stay in + // Sentry. The discrimination guard for TAURI-RUST-8S3. + let cohere_json_403 = + r#"Cohere embed API error (403 Forbidden): {"message": "invalid api token"}"#; + assert_eq!( + expected_error_kind(cohere_json_403), + None, + "JSON 403 envelope must NOT be demoted — stays actionable: {cohere_json_403}" + ); + + // A non-embed HTML 403 from some unrelated path must not be silenced by + // this embed-scoped matcher. + let non_embed_html_403 = + "page fetch failed (403 Forbidden): 403"; + assert_eq!( + expected_error_kind(non_embed_html_403), + None, + "non-embed HTML 403 must not match the embed edge-block matcher: {non_embed_html_403}" + ); + } + + #[test] + fn report_error_or_expected_demotes_embedding_html_403_edge_block() { + // Drive the full demote path (`report_error_or_expected` → + // `report_expected_message`'s `UpstreamEdgeBlock` arm) so the added + // logging branch is exercised, not just the classifier. The HTML 403 + // edge-block body must take the demoted `warn!` arm and must NOT fall + // through to the hard `report_error_message` capture path. + let cohere_html_403 = anyhow::anyhow!( + "Cohere embed API error (403 Forbidden): \ + 403403 Forbidden" + ); + // Confirm the classifier routes it to the demoted arm… + assert_eq!( + expected_error_kind(&format!("{cohere_html_403:#}")), + Some(ExpectedErrorKind::UpstreamEdgeBlock), + "edge-block 403 must classify so report_error_or_expected demotes it" + ); + // …then actually invoke the report path to cover the logging arm. + report_error_or_expected(&cohere_html_403, "embeddings", "embed", &[]); + + // Companion: a JSON 403 envelope is actionable — it must NOT be demoted + // (classifier returns None, so the hard report path is taken instead). + let cohere_json_403 = + r#"Cohere embed API error (403 Forbidden): {"message": "invalid api token"}"#; + assert_eq!( + expected_error_kind(cohere_json_403), + None, + "JSON 403 envelope must stay actionable — not routed to the demote arm" + ); + } + #[test] fn does_not_classify_unrelated_is_not_configured_messages() { assert_eq!(