fix(providers): drop budget-exhausted 400s from Sentry (#3M, #12, #13) (#1633)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
oxoxDev
2026-05-14 04:18:30 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 6386854d6c
commit bf9404a42f
10 changed files with 329 additions and 78 deletions
+13
View File
@@ -1314,6 +1314,19 @@ pub fn run() {
);
return None;
}
if openhuman_core::core::observability::is_budget_event(&event) {
// Log only structured tag metadata — `event.message` can carry
// upstream provider error text including tokens / pasted-through
// secrets, and per `CLAUDE.md` "never log secrets or full PII".
// The (domain, status) pair is sufficient diagnostic since
// those are the tags `is_budget_event` gates on.
log::debug!(
"[sentry-budget-filter] dropping budget-exhausted event (domain={:?}, status={:?})",
event.tags.get("domain"),
event.tags.get("status")
);
return None;
}
// Defense-in-depth: drop max-tool-iterations cap events that
// slipped past the call-site filters in the core (see
// `openhuman_core::core::observability::is_max_iterations_event`
+14 -1
View File
@@ -476,7 +476,20 @@ impl BackendOAuthClient {
// implement retry/disable logic, so skip Sentry to avoid noise.
let is_transient_infra =
crate::core::observability::is_transient_http_status_code(status_code);
if is_transient_infra {
let is_budget_exhausted = status_code == 400
&& crate::openhuman::providers::is_budget_exhausted_message(&text);
if is_budget_exhausted {
tracing::info!(
method = method.as_str(),
path = url.path(),
status = status_code,
failure = "non_2xx",
kind = "budget",
"[backend_api] budget-exhausted 400 on {} {} — not reporting to Sentry",
method.as_str(),
url.path(),
);
} else if is_transient_infra {
tracing::warn!(
domain = "backend_api",
operation = "authed_json",
+107 -2
View File
@@ -1,6 +1,6 @@
//! Centralised error reporting for the core, plus a Sentry
//! `before_send` filter that drops per-attempt transient-upstream
//! provider failures.
//! `before_send` filters that drop deterministic provider noise:
//! per-attempt transient-upstream failures and budget-exhausted user-state.
//!
//! Wraps `tracing::error!` (which the global subscriber forwards to Sentry via
//! `sentry-tracing`) inside a `sentry::with_scope` so each captured event
@@ -61,6 +61,7 @@ pub enum ExpectedErrorKind {
LocalAiBinaryMissing,
BackendUserError,
LocalAiCapabilityUnavailable,
BudgetExhausted,
}
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
@@ -86,6 +87,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
if is_local_ai_capability_unavailable_message(&lower) {
return Some(ExpectedErrorKind::LocalAiCapabilityUnavailable);
}
if crate::openhuman::providers::is_budget_exhausted_message(message) {
return Some(ExpectedErrorKind::BudgetExhausted);
}
None
}
@@ -321,6 +325,22 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
"[observability] {domain}.{operation} skipped expected local-ai capability-unavailable error: {message}"
);
}
ExpectedErrorKind::BudgetExhausted => {
// User-state condition: the backend reports the user is out of
// budget / credits / balance (HTTP 400 from the OpenHuman backend,
// surfaced by `providers::is_budget_exhausted_message`). The UI
// already surfaces this as an actionable toast — Sentry would
// turn each affected turn into noise (OPENHUMAN-TAURI-3M / -12 /
// -13). Demote to info so it still appears in breadcrumbs but
// never spawns a Sentry error event.
tracing::info!(
domain = domain,
operation = operation,
kind = "budget",
error = %message,
"[observability] {domain}.{operation} skipped expected budget-exhausted error: {message}"
);
}
}
}
@@ -533,6 +553,47 @@ pub fn is_transient_message_failure(msg: &str) -> bool {
|| contains_transient_transport_phrase(&lower)
}
/// Returns true when a Sentry event is a budget-exhausted 400 that should be
/// dropped from `before_send`.
///
/// Match criteria (all required):
/// - tag `failure == "non_2xx"`
/// - tag `status == "400"`
/// - the event message or any exception value contains one of the tight
/// budget-exhaustion phrases
///
/// Note: `domain` is intentionally not gated here as defense-in-depth over
/// the emit-site classifier — any non_2xx/400 event that carries the
/// budget-exhausted phrasing is dropped regardless of which domain produced
/// it, so a future re-emitter under a different tag still gets filtered.
pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
let tags = &event.tags;
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
return false;
}
if tags.get("status").map(String::as_str) != Some("400") {
return false;
}
event_contains_budget_exhausted_message(event)
}
fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool {
if event
.message
.as_deref()
.is_some_and(crate::openhuman::providers::is_budget_exhausted_message)
{
return true;
}
event.exception.values.iter().any(|exception| {
exception
.value
.as_deref()
.is_some_and(crate::openhuman::providers::is_budget_exhausted_message)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1153,6 +1214,50 @@ mod tests {
}
}
#[test]
fn budget_filter_drops_budget_message_on_tagged_400() {
let event = event_with_tags_and_message(
&[("failure", "non_2xx"), ("status", "400")],
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#,
);
assert!(is_budget_event(&event));
}
#[test]
fn budget_filter_drops_budget_exception_on_tagged_400() {
let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]);
event.exception.values.push(sentry::protocol::Exception {
value: Some("Budget exceeded — add credits to continue".to_string()),
..Default::default()
});
assert!(is_budget_event(&event));
}
#[test]
fn budget_filter_keeps_non_budget_400() {
let event = event_with_tags_and_message(
&[("failure", "non_2xx"), ("status", "400")],
"Bad request: missing field",
);
assert!(!is_budget_event(&event));
}
#[test]
fn budget_filter_requires_non_2xx_failure_and_400_status() {
let message = "Budget exceeded — add credits to continue";
for tags in [
vec![("failure", "transport"), ("status", "400")],
vec![("failure", "non_2xx"), ("status", "500")],
vec![("failure", "non_2xx")],
] {
let event = event_with_tags_and_message(&tags, message);
assert!(!is_budget_event(&event));
}
}
#[test]
fn report_error_or_expected_does_not_panic() {
report_error_or_expected(
+7
View File
@@ -59,6 +59,13 @@ fn main() {
if openhuman_core::core::observability::is_transient_provider_http_failure(&event) {
return None;
}
// Defense-in-depth for budget-exhausted 400s. Emit sites demote the
// known backend responses before they hit Sentry; this catches any
// future non_2xx/status=400 event that carries the same tight body
// phrases.
if openhuman_core::core::observability::is_budget_event(&event) {
return None;
}
// Defense-in-depth: drop max-tool-iterations cap events that
// slipped past the call-site filters in
// `agent::harness::session::runtime::run_single`,
@@ -510,10 +510,11 @@ impl Agent {
// `log::info!` (OPENHUMAN-TAURI-99 / -98).
//
// Other agent errors go through `report_error_or_expected`
// so OPENHUMAN-TAURI-5Z and friends — upstream transient
// HTTP that bubbles up under `domain=agent` and escapes
// the `domain=llm_provider` filter — get demoted to a
// warn-level breadcrumb without losing genuine bugs.
// so OPENHUMAN-TAURI-5Z and the budget-noise cluster —
// upstream transient HTTP and backend budget-exhausted 400s
// that bubble up under `domain=agent` and escape the
// `domain=llm_provider` filter — get demoted to a
// warn/info-level breadcrumb without losing genuine bugs.
// `Err` propagation, the `AgentError` domain event, and
// downstream `recoverable=false` semantics are preserved.
let is_max_iter = matches!(
+60
View File
@@ -0,0 +1,60 @@
/// Returns true if a 400 response body indicates the user is out of
/// budget / has insufficient balance / over their plan. These are
/// deterministic user-state errors — already surfaced in the UI as a
/// toast — and must not flow to Sentry as errors.
///
/// Match is case-insensitive against any of the known phrases. Keep the
/// list deliberately tight: false positives demote real backend bugs.
pub fn is_budget_exhausted_message(body: &str) -> bool {
const PHRASES: &[&str] = &[
"insufficient budget",
"budget exceeded",
"add credits",
"insufficient balance",
];
let lower = body.to_ascii_lowercase();
PHRASES.iter().any(|phrase| lower.contains(phrase))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_known_budget_exhaustion_phrases() {
for body in [
"Insufficient budget",
"Budget exceeded",
"Insufficient balance",
"Add credits to continue",
] {
assert!(
is_budget_exhausted_message(body),
"{body:?} must be classified as budget-exhausted user-state"
);
}
}
#[test]
fn detection_is_case_insensitive() {
assert!(is_budget_exhausted_message("INSUFFICIENT BUDGET"));
assert!(is_budget_exhausted_message("budget EXCEEDED — ADD credits"));
assert!(is_budget_exhausted_message("Insufficient BALANCE"));
}
#[test]
fn ignores_non_budget_messages() {
for body in [
"Bad request: missing field",
"Invalid request: model not found",
"HTTP 400 Bad Request",
"",
] {
assert!(
!is_budget_exhausted_message(body),
"{body:?} must not be classified as budget-exhausted"
);
}
}
}
+40 -5
View File
@@ -400,7 +400,14 @@ impl OpenAiCompatibleProvider {
let error = response.text().await?;
let sanitized = super::sanitize_api_error(&error);
let message = format!("{} Responses API error: {sanitized}", self.name);
if super::should_report_provider_http_failure(status) {
if super::is_budget_exhausted_http_400(status, &error) {
super::log_budget_exhausted_http_400(
"responses_api",
self.name.as_str(),
Some(model),
status,
);
} else if super::should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
"llm_provider",
@@ -736,7 +743,14 @@ impl OpenAiCompatibleProvider {
"{} streaming API error ({}): {}",
self.name, status, sanitized
);
if super::should_report_provider_http_failure(status) {
if super::is_budget_exhausted_http_400(status, &body) {
super::log_budget_exhausted_http_400(
"streaming_chat",
self.name.as_str(),
Some(native_request.model.as_str()),
status,
);
} else if super::should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
"llm_provider",
@@ -1190,7 +1204,14 @@ impl Provider for OpenAiCompatibleProvider {
let status_str = status.as_u16().to_string();
let message = format!("{} API error ({status}): {sanitized}", self.name);
if super::should_report_provider_http_failure(status) {
if super::is_budget_exhausted_http_400(status, &error) {
super::log_budget_exhausted_http_400(
"chat_completions",
self.name.as_str(),
Some(model),
status,
);
} else if super::should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
"llm_provider",
@@ -1574,7 +1595,14 @@ impl Provider for OpenAiCompatibleProvider {
let status_str = status.as_u16().to_string();
let message = format!("{} API error ({status}): {sanitized}", self.name);
if super::should_report_provider_http_failure(status) {
if super::is_budget_exhausted_http_400(status, &error) {
super::log_budget_exhausted_http_400(
"native_chat",
self.name.as_str(),
Some(model),
status,
);
} else if super::should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
"llm_provider",
@@ -1701,7 +1729,14 @@ impl Provider for OpenAiCompatibleProvider {
};
let sanitized_error = super::sanitize_api_error(&raw_error);
let message = format!("{}: {}", status, sanitized_error);
if super::should_report_provider_http_failure(status) {
if super::is_budget_exhausted_http_400(status, &raw_error) {
super::log_budget_exhausted_http_400(
"stream_chat",
provider_name.as_str(),
Some(model_owned.as_str()),
status,
);
} else if super::should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
"llm_provider",
+2
View File
@@ -1,3 +1,4 @@
pub mod billing_error;
pub mod compatible;
pub mod openhuman_backend;
pub mod ops;
@@ -12,4 +13,5 @@ pub use traits::{
ProviderDelta, ToolCall, ToolResultMessage, UsageInfo,
};
pub use billing_error::is_budget_exhausted_message;
pub use ops::*;
+41 -64
View File
@@ -128,29 +128,28 @@ pub fn should_report_provider_http_failure(status: reqwest::StatusCode) -> bool
!crate::core::observability::TRANSIENT_PROVIDER_HTTP_STATUSES.contains(&status.as_u16())
}
/// Whether a "Budget exceeded" error from `provider` at `status` should be
/// suppressed from Sentry.
///
/// Suppression is scoped to `backend + 400` so that:
/// - Other providers (OpenAI, Anthropic, …) whose 400 bodies happen to mention
/// "Budget exceeded" still report.
/// - Backend 5xx errors that mention "Budget exceeded" still report (server bug,
/// not user-state).
/// - Only the exact user-actionable signal from the OpenHuman backend — which
/// the UI surfaces directly — is silenced.
pub(super) fn is_budget_exceeded_suppressed(
/// Whether a provider non-2xx response is a deterministic budget-exhausted
/// user-state error that should be demoted from Sentry to an info log.
pub(super) fn is_budget_exhausted_http_400(status: reqwest::StatusCode, body: &str) -> bool {
status == reqwest::StatusCode::BAD_REQUEST && super::is_budget_exhausted_message(body)
}
pub(super) fn log_budget_exhausted_http_400(
operation: &str,
provider: &str,
model: Option<&str>,
status: reqwest::StatusCode,
sanitized_body: &str,
) -> bool {
provider == openhuman_backend::PROVIDER_LABEL
&& matches!(
status,
reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::PAYMENT_REQUIRED
)
&& sanitized_body
.to_ascii_lowercase()
.contains("budget exceeded")
) {
tracing::info!(
domain = "llm_provider",
operation = operation,
provider = provider,
model = model.unwrap_or(""),
status = status.as_u16(),
failure = "non_2xx",
kind = "budget",
"[llm_provider] {operation} budget-exhausted 400 — not reporting to Sentry"
);
}
/// Build a sanitized provider error from a failed HTTP response.
@@ -183,7 +182,7 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
let is_auth_failure = matches!(status.as_u16(), 401 | 403);
let is_backend = provider == openhuman_backend::PROVIDER_LABEL;
let is_budget_exceeded_user_state = is_budget_exceeded_suppressed(provider, status, &sanitized);
let is_budget_exhausted_user_state = is_budget_exhausted_http_400(status, &body);
if is_auth_failure && is_backend {
tracing::warn!(
@@ -204,14 +203,8 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
reason: sanitize_api_error(&message),
},
);
} else if is_budget_exceeded_user_state {
tracing::info!(
domain = "llm_provider",
operation = "api_error",
provider = provider,
status = status_str.as_str(),
"[llm_provider] budget-exceeded response suppressed from Sentry (user-actionable, not a bug)"
);
} else if is_budget_exhausted_user_state {
log_budget_exhausted_http_400("api_error", provider, None, status);
} else if should_report_provider_http_failure(status) {
crate::core::observability::report_error(
message.as_str(),
@@ -547,76 +540,60 @@ mod tests {
}
}
// Confirm the Budget-exceeded suppression predicate is scoped correctly.
// Confirm the budget-exhausted suppression predicate is scoped correctly.
// These tests exercise the real production function, not a duplicate.
mod budget_exceeded_suppression {
mod budget_exhausted_suppression {
use super::*;
const BACKEND: &str = openhuman_backend::PROVIDER_LABEL; // "OpenHuman"
const OTHER: &str = "OpenAI";
const BUDGET_BODY: &str = "Budget exceeded: you have no credits remaining";
const BUDGET_BODY: &str = "Insufficient budget";
const UNRELATED_BODY: &str = "Invalid request: model not found";
#[test]
fn backend_400_budget_exceeded_is_suppressed() {
assert!(is_budget_exceeded_suppressed(
BACKEND,
fn budget_exhausted_400_is_suppressed() {
assert!(is_budget_exhausted_http_400(
reqwest::StatusCode::BAD_REQUEST,
BUDGET_BODY,
));
}
#[test]
fn other_provider_400_budget_exceeded_is_not_suppressed() {
// A third-party provider whose body happens to say "Budget exceeded"
// should still be reported to Sentry — only the backend gets special
// treatment.
assert!(!is_budget_exceeded_suppressed(
OTHER,
fn budget_exhausted_400_is_case_insensitive() {
assert!(is_budget_exhausted_http_400(
reqwest::StatusCode::BAD_REQUEST,
BUDGET_BODY,
"budget exceeded — ADD credits to continue",
));
}
#[test]
fn backend_500_budget_exceeded_is_not_suppressed() {
fn budget_exhausted_500_is_not_suppressed() {
// A 500 is a server bug, not expected user-state — keep reporting.
assert!(!is_budget_exceeded_suppressed(
BACKEND,
assert!(!is_budget_exhausted_http_400(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
BUDGET_BODY,
));
}
#[test]
fn backend_400_unrelated_body_is_not_suppressed() {
assert!(!is_budget_exceeded_suppressed(
BACKEND,
fn budget_exhausted_400_unrelated_body_is_not_suppressed() {
assert!(!is_budget_exhausted_http_400(
reqwest::StatusCode::BAD_REQUEST,
UNRELATED_BODY,
));
}
#[test]
fn backend_402_budget_exceeded_is_suppressed() {
// 402 Payment Required is a valid alternative status the backend may
// return for the same user-state (no credits); it should be suppressed
// just like 400.
assert!(is_budget_exceeded_suppressed(
BACKEND,
fn budget_exhausted_402_is_not_suppressed() {
assert!(!is_budget_exhausted_http_400(
reqwest::StatusCode::PAYMENT_REQUIRED,
BUDGET_BODY,
));
}
#[test]
fn backend_402_budget_exceeded_case_insensitive() {
// Body casing should not affect suppression — e.g. "budget exceeded"
// (all-lowercase) from a future backend change.
assert!(is_budget_exceeded_suppressed(
BACKEND,
reqwest::StatusCode::PAYMENT_REQUIRED,
"budget exceeded: no credits",
fn budget_exhausted_empty_body_is_not_suppressed() {
assert!(!is_budget_exhausted_http_400(
reqwest::StatusCode::BAD_REQUEST,
"",
));
}
}
+40 -2
View File
@@ -1,5 +1,6 @@
//! Runtime smoke for the Sentry `before_send` filters that drop per-attempt
//! transient-upstream provider, backend_api, and integrations failures.
//! transient-upstream provider, backend_api, and integrations failures plus
//! budget-exhausted user-state 400s (OPENHUMAN-TAURI-3M / 12 / 13).
//!
//! Unit tests in `src/core/observability.rs` exercise the pure filter
//! function. This integration test wires the actual `sentry::init` →
@@ -8,7 +9,7 @@
//! and aggregate `all_exhausted` events still surface.
use openhuman_core::core::observability::{
is_transient_backend_api_failure, is_transient_integrations_failure,
is_budget_event, is_transient_backend_api_failure, is_transient_integrations_failure,
is_transient_provider_http_failure,
};
use sentry::protocol::Event;
@@ -58,6 +59,7 @@ fn count_captured(events: Vec<Event<'static>>) -> usize {
if is_transient_provider_http_failure(&event)
|| is_transient_backend_api_failure(&event)
|| is_transient_integrations_failure(&event)
|| is_budget_event(&event)
{
None
} else {
@@ -112,6 +114,42 @@ fn drops_integrations_transient_transport_timeout() {
);
}
#[test]
fn drops_budget_exhausted_400() {
let event = event_with_tags_and_message(
&[
("domain", "llm_provider"),
("failure", "non_2xx"),
("status", "400"),
],
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#,
);
assert_eq!(
count_captured(vec![event]),
0,
"budget-exhausted 400s must be filtered in before_send"
);
}
#[test]
fn keeps_non_budget_400() {
let event = event_with_tags_and_message(
&[
("domain", "llm_provider"),
("failure", "non_2xx"),
("status", "400"),
],
"Bad request: missing field",
);
assert_eq!(
count_captured(vec![event]),
1,
"non-budget 400s must still reach Sentry"
);
}
#[test]
fn drops_per_attempt_429_503_504_408_502() {
// Each of these matches the tag shape `ops::api_error` sets when a