fix(backend_api): suppress Sentry on 401 via typed BackendApiError::Unauthorized (#2781)

This commit is contained in:
CodeGhost21
2026-05-29 00:47:14 +05:30
committed by GitHub
parent 29aeba82d4
commit 1a404ac9c2
2 changed files with 137 additions and 3 deletions
+39
View File
@@ -27,6 +27,19 @@ pub enum BackendApiError {
/// Provider-specific message id from the URL.
message_id: String,
},
/// Backend rejected the bearer JWT with `401 Unauthorized`. This is an
/// expected user-session state (token expired, revoked, rotated
/// server-side) — not a code bug. Callers can route to a re-sign-in
/// flow; the auth domain owns recovery. Targets `OPENHUMAN-TAURI-4K8`
/// (12 events on `/openai/v1/audio/speech` mascot TTS, but the same
/// shape fires on every authed endpoint once the session lapses).
#[error("backend rejected session token on {method} {path}")]
Unauthorized {
/// HTTP method as a static string (`"GET"`, `"POST"`, …).
method: String,
/// Request path the 401 came back from (no query string).
path: String,
},
}
/// Extract `(provider, message_id)` from a backend channel path of the
@@ -526,6 +539,32 @@ impl BackendOAuthClient {
let status_code = status.as_u16();
let status_str = status_code.to_string();
// 401 on any authed backend endpoint is an expected user-session
// state (token expired / revoked / rotated server-side), not a
// code bug — every authed endpoint will see this once the session
// lapses. Surface a typed `BackendApiError::Unauthorized` so the
// auth domain can drive recovery, and skip `report_error` to
// avoid Sentry noise. Targets `OPENHUMAN-TAURI-4K8` (mascot TTS
// surfaced it first on `/openai/v1/audio/speech`, but the same
// shape applies to every `authed_json` path).
if status_code == 401 {
tracing::info!(
domain = "backend_api",
operation = "authed_json",
method = method.as_str(),
path = url.path(),
status = status_code,
failure = "non_2xx",
"[backend_api] 401 on {} {} — session token rejected, surfacing typed error",
method.as_str(),
url.path(),
);
return Err(anyhow::Error::new(BackendApiError::Unauthorized {
method: method.as_str().to_string(),
path: url.path().to_string(),
}));
}
// 404 on `/channels/<provider>/messages/<id>` is an expected
// state (user deleted the message provider-side, or backend
// GC'd the relay row) — not a code bug. Surface a typed
+98 -3
View File
@@ -332,7 +332,10 @@ async fn authed_json_surfaces_message_not_found_on_404() {
let BackendApiError::MessageNotFound {
provider,
message_id,
} = typed;
} = typed
else {
panic!("expected MessageNotFound, got {typed:?}");
};
assert_eq!(provider, "telegram");
assert_eq!(message_id, "1103");
@@ -350,11 +353,100 @@ async fn authed_json_surfaces_message_not_found_on_404() {
let BackendApiError::MessageNotFound {
provider,
message_id,
} = typed;
} = typed
else {
panic!("expected MessageNotFound, got {typed:?}");
};
assert_eq!(provider, "discord");
assert_eq!(message_id, "abc");
}
#[tokio::test]
async fn authed_json_surfaces_unauthorized_on_401() {
// OPENHUMAN-TAURI-4K8: 401 on any authed backend endpoint must surface a
// typed `BackendApiError::Unauthorized` and NOT funnel into `report_error`.
// The mascot TTS path (`/openai/v1/audio/speech`) was the loudest reporter,
// but the same shape fires on every authed endpoint once a session lapses,
// so we cover two different paths/methods to prove the suppression is
// status-driven, not path-keyed.
let app = Router::new()
.route(
"/openai/v1/audio/speech",
post(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }),
)
.route(
"/referral/stats",
get(|| async { (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized") }),
);
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();
// Mascot TTS path — the original reporter.
let err = client
.authed_json(
"mock-jwt",
Method::POST,
"/openai/v1/audio/speech",
Some(json!({ "text": "hello" })),
)
.await
.unwrap_err();
let typed = err.downcast_ref::<BackendApiError>().unwrap();
let BackendApiError::Unauthorized { method, path } = typed else {
panic!("expected Unauthorized, got {typed:?}");
};
assert_eq!(method, "POST");
assert_eq!(path, "/openai/v1/audio/speech");
// Generic GET on a non-TTS path — proves the suppression is per-status,
// not per-path. (Same root cause: expired/revoked backend session.)
let err = client
.authed_json("mock-jwt", Method::GET, "/referral/stats", None)
.await
.unwrap_err();
let typed = err.downcast_ref::<BackendApiError>().unwrap();
let BackendApiError::Unauthorized { method, path } = typed else {
panic!("expected Unauthorized, got {typed:?}");
};
assert_eq!(method, "GET");
assert_eq!(path, "/referral/stats");
}
#[tokio::test]
async fn authed_json_403_is_not_demoted_to_unauthorized() {
// 403 (Forbidden) is a genuine authorization/permission problem — the
// token authenticated but lacked scope. That IS a code/config bug we
// want to keep in Sentry; only 401 (token rejected as a whole) maps
// to the expected-state `Unauthorized` variant.
let app = Router::new().route(
"/openai/v1/audio/speech",
post(|| async { (axum::http::StatusCode::FORBIDDEN, "Forbidden") }),
);
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::POST, "/openai/v1/audio/speech", None)
.await
.unwrap_err();
assert!(
err.downcast_ref::<BackendApiError>().is_none(),
"403 must not be classified as Unauthorized"
);
}
#[tokio::test]
async fn authed_json_404_outside_messages_path_still_reports() {
// 404 on a non-`/channels/<provider>/messages/<id>` path should NOT be
@@ -486,7 +578,10 @@ async fn authed_json_patch_404_with_base_path_prefix_does_not_report() {
let BackendApiError::MessageNotFound {
provider,
message_id,
} = typed;
} = typed
else {
panic!("expected MessageNotFound, got {typed:?}");
};
assert_eq!(provider, "telegram");
assert_eq!(message_id, "9999");
}