fix(inference): publish SessionExpired for backend 401 on chat_completions (Sentry TAURI-RUST-N) (#2814)

This commit is contained in:
CodeGhost21
2026-05-29 00:30:32 +05:30
committed by GitHub
parent c1869c68f7
commit 38238d63d5
2 changed files with 246 additions and 19 deletions
+14 -1
View File
@@ -1479,7 +1479,20 @@ impl Provider for OpenAiCompatibleProvider {
format!("{} API error ({status}): {sanitized}", self.name),
status,
);
if super::is_budget_exhausted_http_400(status, &error) {
if super::is_backend_auth_failure(self.name.as_str(), status) {
// Backend rejected the app session JWT (401/403): expected
// session-expiry (token expired/revoked/rotated), not a code
// bug. Publish SessionExpired so the credentials subscriber
// drives reauth and the scheduler-gate halts downstream LLM
// work, and skip the Sentry report (TAURI-RUST-N). Mirrors the
// `is_backend_auth_failure` arm in `super::api_error`.
super::publish_backend_session_expired(
"chat_completions",
self.name.as_str(),
status,
&message,
);
} else if super::is_budget_exhausted_http_400(status, &error) {
super::log_budget_exhausted_http_400(
"chat_completions",
self.name.as_str(),
+232 -18
View File
@@ -701,6 +701,50 @@ pub(super) fn log_context_window_exceeded(
);
}
/// Whether a provider non-2xx response is the OpenHuman **backend** rejecting
/// the app session JWT (`401`/`403`). This is expected user-session state
/// (token expired / revoked / rotated server-side), not a product bug — the
/// auth domain owns recovery. `401`/`403` from **other** providers (OpenAI,
/// Anthropic, …) mean a misconfigured BYO API key and stay Sentry-actionable,
/// so the predicate is provider-scoped to [`openhuman_backend::PROVIDER_LABEL`].
pub(super) fn is_backend_auth_failure(provider: &str, status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 401 | 403) && provider == openhuman_backend::PROVIDER_LABEL
}
/// Handle a backend session-expiry auth failure: publish a
/// [`crate::core::event_bus::DomainEvent::SessionExpired`] so the credentials
/// subscriber clears the session and flips the scheduler-gate signed-out
/// override (halting downstream LLM work — see OPENHUMAN-TAURI-1T), and skip
/// the Sentry report. Mirrors the `is_auth_failure && is_backend` arm in
/// [`api_error`], factored out for the hand-rolled provider HTTP-error chains
/// in [`super::compatible::OpenAiCompatibleProvider`] which consume the
/// response body inline and so can't delegate to `api_error`. The
/// `chat_completions` chain lacked this branch and reported the backend
/// `401 Invalid token` to Sentry — that drift was TAURI-RUST-N.
///
/// `message` is the already-formatted `"{provider} API error ({status}): …"`
/// string; it embeds the sanitized body, but the prefix and caller-controlled
/// provider name aren't scrubbed, so re-run [`sanitize_api_error`] on the final
/// string before it reaches the SessionExpired subscriber's logs.
pub(super) fn publish_backend_session_expired(
operation: &str,
provider: &str,
status: reqwest::StatusCode,
message: &str,
) {
tracing::warn!(
domain = "llm_provider",
operation = operation,
provider = provider,
status = status.as_u16(),
"[llm_provider] backend auth failure ({status}) — publishing SessionExpired"
);
crate::core::event_bus::publish_global(crate::core::event_bus::DomainEvent::SessionExpired {
source: "llm_provider.openhuman_backend".to_string(),
reason: sanitize_api_error(message),
});
}
/// Build a sanitized provider error from a failed HTTP response.
///
/// Reports the failure to Sentry with `provider` and `status` tags so
@@ -748,24 +792,10 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
let is_context_window_exceeded = is_context_window_exceeded_message(&body);
if is_auth_failure && is_backend {
tracing::warn!(
domain = "llm_provider",
operation = "api_error",
provider = provider,
status = status_str.as_str(),
"[llm_provider] backend auth failure ({status}) — publishing SessionExpired"
);
// `message` already embeds the sanitized body via
// `sanitize_api_error(&body)`, but the leading `{provider} API
// error ({status})` prefix and any caller-controlled provider
// name aren't scrubbed — re-run sanitize on the final string so
// the SessionExpired subscriber's logs never persist secrets.
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::SessionExpired {
source: "llm_provider.openhuman_backend".to_string(),
reason: sanitize_api_error(&message),
},
);
// Single source of truth for backend session-expiry handling (warn +
// SessionExpired publish + final-string sanitize) — shared with the
// hand-rolled `chat_completions` chain in `compatible.rs`.
publish_backend_session_expired("api_error", provider, status, &message);
} else if is_budget_exhausted_user_state {
log_budget_exhausted_http_400("api_error", provider, None, status);
} else if is_custom_openai_upstream_bad_request {
@@ -1819,4 +1849,188 @@ mod tests {
);
}
}
/// `is_backend_auth_failure` is the polarity guard that decides whether a
/// 401/403 is the OpenHuman backend's expired session (silence + drive
/// reauth) or a third-party BYO-key rejection (actionable, must reach
/// Sentry). Getting this wrong in either direction is a regression:
/// over-matching silences real misconfig; under-matching is TAURI-RUST-N.
#[test]
fn is_backend_auth_failure_only_matches_openhuman_backend_401_403() {
use reqwest::StatusCode;
let backend = crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL;
assert!(is_backend_auth_failure(backend, StatusCode::UNAUTHORIZED));
assert!(is_backend_auth_failure(backend, StatusCode::FORBIDDEN));
// Non-auth backend statuses stay reportable (real server bugs / transient).
for s in [
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::BAD_REQUEST,
StatusCode::NOT_FOUND,
] {
assert!(
!is_backend_auth_failure(backend, s),
"backend {s} must not be treated as session-expiry"
);
}
// Third-party BYO-key 401/403 (user's own key revoked) must NOT be
// silenced — that is actionable misconfiguration for Sentry.
for provider in ["custom_openai", "OpenAI", "Anthropic", "openrouter"] {
assert!(
!is_backend_auth_failure(provider, StatusCode::UNAUTHORIZED),
"{provider} 401 must reach Sentry as actionable BYO-key error"
);
assert!(
!is_backend_auth_failure(provider, StatusCode::FORBIDDEN),
"{provider} 403 must reach Sentry as actionable BYO-key error"
);
}
}
/// `publish_backend_session_expired` must emit a `SessionExpired` event on
/// the `auth` domain with the canonical source and a sanitized reason, so
/// the credentials subscriber can drive reauth.
#[tokio::test]
async fn publish_backend_session_expired_emits_sanitized_session_expired() {
use crate::core::event_bus::{global, init_global, DomainEvent};
init_global(1024);
let mut rx = global().expect("event bus initialized").raw_receiver();
// `TEST_MARKER_A` makes this event distinguishable from the sibling
// `chat_completions_backend_401_*` test's event on the shared global
// bus (both run in parallel against the same singleton). The `sk-`
// token probes that `sanitize_api_error` actually scrubs secrets out
// of the SessionExpired reason rather than just emitting the event.
let secret = "sk-LIVEA0123456789abcdefSECRET";
let msg = format!(
r#"OpenHuman API error (401 Unauthorized): {{"success":false,"error":"TEST_MARKER_A Invalid token {secret}"}}"#
);
publish_backend_session_expired(
"chat_completions",
crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL,
reqwest::StatusCode::UNAUTHORIZED,
&msg,
);
let mut reason_seen: Option<String> = None;
loop {
match rx.try_recv() {
Ok(DomainEvent::SessionExpired { source, reason }) => {
if source == "llm_provider.openhuman_backend"
&& reason.contains("TEST_MARKER_A")
{
reason_seen = Some(reason);
break;
}
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
let reason = reason_seen.expect(
"publish_backend_session_expired must emit SessionExpired(source=llm_provider.openhuman_backend) carrying TEST_MARKER_A",
);
assert!(
reason.contains("[REDACTED]"),
"sanitize_api_error must redact the sk- token in the reason: {reason}"
);
assert!(
!reason.contains(secret),
"raw secret must not survive into the SessionExpired reason: {reason}"
);
}
/// End-to-end regression for TAURI-RUST-N: a backend `401 Invalid token`
/// on the hand-rolled `chat_completions` path must publish `SessionExpired`
/// (driving reauth) and surface the typed error — NOT spam Sentry. The
/// provider is labelled exactly like the OpenHuman backend provider, which
/// is what gates the backend-auth-failure branch.
#[tokio::test]
async fn chat_completions_backend_401_publishes_session_expired() {
use crate::core::event_bus::{global, init_global, DomainEvent};
use axum::routing::post;
init_global(1024);
let mut rx = global().expect("event bus initialized").raw_receiver();
async fn unauthorized_handler() -> Response {
// `TEST_MARKER_B` distinguishes this event from the sibling
// `publish_backend_session_expired_*` test on the shared global
// bus; the `sk-` token probes end-to-end redaction through
// `api_error` → `publish_backend_session_expired`.
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"success": false,
"error": "TEST_MARKER_B Invalid token sk-LIVEB9876543210fedcbaSECRET"
})),
)
.into_response()
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let app = Router::new().route("/chat/completions", post(unauthorized_handler));
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
let provider =
crate::openhuman::inference::provider::compatible::OpenAiCompatibleProvider::new_no_responses_fallback(
crate::openhuman::inference::provider::openhuman_backend::PROVIDER_LABEL,
&format!("http://{addr}"),
Some("expired-jwt"),
crate::openhuman::inference::provider::compatible::AuthStyle::Bearer,
);
let err = crate::openhuman::inference::provider::traits::Provider::chat_with_system(
&provider,
None,
"hi",
"reasoning-quick-v1",
0.0,
)
.await
.expect_err("backend 401 must surface as an error");
let msg = err.to_string();
assert!(
msg.contains("OpenHuman API error (401") && msg.contains("Invalid token"),
"error must carry the backend 401 envelope: {msg}"
);
let mut reason_seen: Option<String> = None;
loop {
match rx.try_recv() {
Ok(DomainEvent::SessionExpired { source, reason }) => {
if source == "llm_provider.openhuman_backend"
&& reason.contains("TEST_MARKER_B")
{
reason_seen = Some(reason);
break;
}
}
Ok(_) => continue,
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
let reason = reason_seen.expect(
"backend 401 on chat_completions must publish SessionExpired carrying TEST_MARKER_B, not report to Sentry",
);
assert!(
reason.contains("[REDACTED]"),
"sanitize_api_error must redact the sk- token end-to-end: {reason}"
);
assert!(
!reason.contains("sk-LIVEB9876543210fedcbaSECRET"),
"raw secret must not survive into the SessionExpired reason: {reason}"
);
}
}