mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(backend_api): suppress Sentry on 401 via typed BackendApiError::Unauthorized (#2781)
This commit is contained in:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user