From 623466474478b2136ecd8585076560a02a519a25 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Thu, 14 May 2026 16:38:59 +0530 Subject: [PATCH 1/3] feat(api): surface BackendApiError::MessageNotFound on /channels 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user deletes their telegram/discord/slack message provider-side, follow-up PATCH/DELETE/POST against the same id returns 404. The existing authed_json path funnels every non-transient non_2xx into report_error, which means OPENHUMAN-TAURI-2Y has accumulated ~454 events for a state that is fundamentally a UI-recovery cue, not a backend bug. Add a typed BackendApiError::MessageNotFound variant carrying the provider segment + message id parsed from the URL, and short-circuit the non_2xx branch when status == 404 AND the path matches /channels//messages/. The path matcher is intentionally narrow (exact 4-segment shape + literal 'channels' / 'messages' anchors) so 404s on any other route — auth probes, integration endpoints, mis-routed proxies — keep their Sentry signal. The drop is emitted as tracing::info at the call site so triage sweeps stay grep-visible without crossing the report_error threshold. Refs OPENHUMAN-TAURI-2Y. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/rest.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/api/rest.rs b/src/api/rest.rs index ee34ab39ba..d068041540 100644 --- a/src/api/rest.rs +++ b/src/api/rest.rs @@ -10,6 +10,36 @@ use std::time::Duration; use super::jwt::bearer_authorization_value; +/// Typed errors surfaced by `authed_json` for expected backend states that +/// callers should recover from in-flow rather than funnel into Sentry. +#[derive(Debug, thiserror::Error)] +pub enum BackendApiError { + /// Edit / delete of a channel message returned 404. Happens when the + /// user deletes the message on the provider side (Telegram, Discord, + /// Slack, …) but our local `StreamingState` still has the id, or when + /// the backend GC'd the relay row before we got around to editing it. + /// Callers should clear stale state and skip the retry. Targets + /// `OPENHUMAN-TAURI-2Y` (~454 events on `/channels/telegram/messages/`). + #[error("message not found on {provider}: {message_id}")] + MessageNotFound { + /// Channel provider segment (e.g. `"telegram"`, `"discord"`). + provider: String, + /// Provider-specific message id from the URL. + message_id: String, + }, +} + +/// Extract `(provider, message_id)` from a backend channel path of the +/// shape `/channels//messages/`. Returns `None` for paths +/// with a different segment count or non-`channels` first segment. +fn parse_message_path(path: &str) -> Option<(&str, &str)> { + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segments.len() == 4 && segments[0] == "channels" && segments[2] == "messages" { + return Some((segments[1], segments[3])); + } + None +} + const CLIENT_VERSION_HEADER_MAX_LEN: usize = 64; fn sanitize_client_version(raw: &str) -> Option { @@ -471,6 +501,32 @@ impl BackendOAuthClient { if !status.is_success() { let status_code = status.as_u16(); let status_str = status_code.to_string(); + + // 404 on `/channels//messages/` is an expected + // state (user deleted the message provider-side, or backend + // GC'd the relay row) — not a code bug. Surface a typed + // `BackendApiError::MessageNotFound` so callers (`bus.rs` + // streaming/thinking/delete/final paths) can clear stale + // ids and skip retry, without funneling the 404 into + // `report_error`. Targets `OPENHUMAN-TAURI-2Y` (~454 events). + if status_code == 404 { + if let Some((provider, message_id)) = parse_message_path(url.path()) { + tracing::info!( + domain = "backend_api", + operation = "authed_json", + provider = provider, + message_id = message_id, + "[backend_api] message-not-found 404 on {} {} — surfacing typed error", + method.as_str(), + url.path(), + ); + return Err(anyhow::Error::new(BackendApiError::MessageNotFound { + provider: provider.to_string(), + message_id: message_id.to_string(), + })); + } + } + // These are transient infrastructure errors (proxy/CDN/backend // temporarily unavailable). They are not code bugs and callers already // implement retry/disable logic, so skip Sentry to avoid noise. From 3df7c57270cf4d27e893f3bcd8e370c877e02bc2 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Thu, 14 May 2026 16:39:09 +0530 Subject: [PATCH 2/3] fix(channels): clear stale message-id on provider-side delete (MessageNotFound recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four call sites in channels::bus consume the new BackendApiError::MessageNotFound and recover in-flow instead of counting it against MAX_EDIT_FAILURES or surfacing a generic warning: flush_streaming_edit — clear state.message_id, latch state.edit_disabled, return early so subsequent dirty-buffer ticks fall back to atomic delivery flush_thinking_message — clear state.thinking_message_id, latch state.thinking_edit_disabled (cloned borrow so the mutation is allowed) delete_channel_message — demote the warn! to info! since 'message already gone' is a no-op success, not a cleanup failure finalize_channel_reply — skip the orphan-draft delete (there's nothing to delete) and go straight to the fresh atomic send so the user still sees the canonical reply Each branch keeps the existing non-404 error path intact, so genuine backend failures retain their warn! signal. Refs OPENHUMAN-TAURI-2Y. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/openhuman/channels/bus.rs | 91 ++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/src/openhuman/channels/bus.rs b/src/openhuman/channels/bus.rs index 2e1ffb2976..ce5282cd26 100644 --- a/src/openhuman/channels/bus.rs +++ b/src/openhuman/channels/bus.rs @@ -438,6 +438,18 @@ async fn flush_streaming_edit(channel: &str, state: &mut StreamingState) { } Err(err) => { state.edit_failures += 1; + if let Some(crate::api::rest::BackendApiError::MessageNotFound { .. }) = + err.downcast_ref::() + { + tracing::info!( + "[channel-inbound][stream] edit channel='{}' msg_id={} — message gone provider-side (404), clearing stale id and disabling further edits", + channel, + message_id, + ); + state.message_id = None; + state.edit_disabled = true; + return; + } tracing::warn!( "[channel-inbound][stream] edit failed channel='{}' msg_id={} err={} (failures={}/{})", channel, @@ -545,16 +557,28 @@ async fn flush_thinking_message(channel: &str, state: &mut StreamingState) { return; }; - if let Some(ref msg_id) = state.thinking_message_id { + if let Some(msg_id) = state.thinking_message_id.clone() { // Edit existing thinking message with updated content. let body = json!({ "text": text }); - if let Err(err) = client.send_channel_edit(channel, msg_id, &jwt, body).await { - tracing::debug!( - "[channel-inbound][thinking] edit failed channel='{}' msg_id={} err={}", - channel, - msg_id, - err, - ); + if let Err(err) = client.send_channel_edit(channel, &msg_id, &jwt, body).await { + if let Some(crate::api::rest::BackendApiError::MessageNotFound { .. }) = + err.downcast_ref::() + { + tracing::info!( + "[channel-inbound][thinking] edit channel='{}' msg_id={} — thinking msg gone provider-side (404), clearing id and disabling further thinking edits", + channel, + msg_id, + ); + state.thinking_message_id = None; + state.thinking_edit_disabled = true; + } else { + tracing::debug!( + "[channel-inbound][thinking] edit failed channel='{}' msg_id={} err={}", + channel, + msg_id, + err, + ); + } } } else { // Send initial thinking message. @@ -694,12 +718,22 @@ async fn delete_channel_message(channel: &str, message_id: &str) { ); } Err(err) => { - tracing::warn!( - "[channel-inbound] failed to delete ephemeral msg channel='{}' msg_id={} err={}", - channel, - message_id, - err, - ); + if let Some(crate::api::rest::BackendApiError::MessageNotFound { .. }) = + err.downcast_ref::() + { + tracing::info!( + "[channel-inbound] delete channel='{}' msg_id={} — message already gone provider-side (404), nothing to clean up", + channel, + message_id, + ); + } else { + tracing::warn!( + "[channel-inbound] failed to delete ephemeral msg channel='{}' msg_id={} err={}", + channel, + message_id, + err, + ); + } } } } @@ -742,15 +776,26 @@ async fn finalize_channel_reply(channel: &str, state: &mut StreamingState, final ); } Err(err) => { - tracing::warn!( - "[channel-inbound] final edit failed channel='{}' msg_id={} err={} — deleting orphan draft and sending fresh atomic reply so user still sees the canonical response", - channel, - message_id, - err, - ); - let orphan = message_id.clone(); - delete_channel_message(channel, &orphan).await; - send_channel_reply(channel, final_text).await; + if let Some(crate::api::rest::BackendApiError::MessageNotFound { .. }) = + err.downcast_ref::() + { + tracing::info!( + "[channel-inbound] final edit channel='{}' msg_id={} — draft already gone provider-side (404), sending fresh atomic reply", + channel, + message_id, + ); + send_channel_reply(channel, final_text).await; + } else { + tracing::warn!( + "[channel-inbound] final edit failed channel='{}' msg_id={} err={} — deleting orphan draft and sending fresh atomic reply so user still sees the canonical response", + channel, + message_id, + err, + ); + let orphan = message_id.clone(); + delete_channel_message(channel, &orphan).await; + send_channel_reply(channel, final_text).await; + } } } } else { From 28a024caff0884a083d6367cc8e81ae9bd735152 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Thu, 14 May 2026 16:39:52 +0530 Subject: [PATCH 3/3] test(api): cover MessageNotFound 404 surfacing + non-channel 404 fallthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two integration tests via the existing axum test scaffolding: authed_json_surfaces_message_not_found_on_404 — POST to both /channels/telegram/messages/1103 (Sentry shape) and /channels/discord/messages/abc (proves provider-agnostic parse_message_path) authed_json_404_outside_messages_path_still_reports — GET on /auth/profile returning 404 must NOT downcast to MessageNotFound (guards against accidentally swallowing a real routing bug) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/api/rest_tests.rs | 88 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/src/api/rest_tests.rs b/src/api/rest_tests.rs index a51f63e5a2..35b558d8dd 100644 --- a/src/api/rest_tests.rs +++ b/src/api/rest_tests.rs @@ -1,10 +1,11 @@ -use super::{key_bytes_from_string, sanitize_client_version, BackendOAuthClient}; +use super::{key_bytes_from_string, sanitize_client_version, BackendApiError, BackendOAuthClient}; use axum::extract::State; use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::Engine; +use reqwest::Method; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; @@ -268,3 +269,88 @@ async fn backend_raw_client_inherits_x_core_version_default_header() { sanitize_client_version(env!("CARGO_PKG_VERSION")).unwrap() ); } + +#[tokio::test] +async fn authed_json_surfaces_message_not_found_on_404() { + let app = Router::new() + .route( + "/channels/telegram/messages/1103", + post(|| async { (axum::http::StatusCode::NOT_FOUND, "Not Found") }), + ) + .route( + "/channels/discord/messages/abc", + post(|| async { (axum::http::StatusCode::NOT_FOUND, "Not Found") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let base_url = format!("http://{addr}"); + let client = BackendOAuthClient::new(&base_url).unwrap(); + + // Telegram path — matches OPENHUMAN-TAURI-2Y shape. + let err = client + .authed_json( + "mock-jwt", + Method::POST, + "/channels/telegram/messages/1103", + None, + ) + .await + .unwrap_err(); + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::MessageNotFound { + provider, + message_id, + } = typed; + assert_eq!(provider, "telegram"); + assert_eq!(message_id, "1103"); + + // Discord path — proves the helper is provider-agnostic. + let err = client + .authed_json( + "mock-jwt", + Method::POST, + "/channels/discord/messages/abc", + None, + ) + .await + .unwrap_err(); + let typed = err.downcast_ref::().unwrap(); + let BackendApiError::MessageNotFound { + provider, + message_id, + } = typed; + assert_eq!(provider, "discord"); + assert_eq!(message_id, "abc"); +} + +#[tokio::test] +async fn authed_json_404_outside_messages_path_still_reports() { + // 404 on a non-`/channels//messages/` path should NOT be + // demoted to MessageNotFound — it's a real backend bug or routing + // mistake and must keep its Sentry signal. + let app = Router::new().route( + "/auth/profile", + get(|| async { (axum::http::StatusCode::NOT_FOUND, "Not Found") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let base_url = format!("http://{addr}"); + let client = BackendOAuthClient::new(&base_url).unwrap(); + + let err = client + .authed_json("mock-jwt", Method::GET, "/auth/profile", None) + .await + .unwrap_err(); + assert!( + err.downcast_ref::().is_none(), + "non-channel-message 404 must not be classified as MessageNotFound" + ); +}