Skip to content

Commit bf9404a

Browse files
oxoxDevclaude
andauthored
fix(providers): drop budget-exhausted 400s from Sentry (#3M, tinyhumansai#12, tinyhumansai#13) (tinyhumansai#1633)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6386854 commit bf9404a

10 files changed

Lines changed: 329 additions & 78 deletions

File tree

app/src-tauri/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,19 @@ pub fn run() {
13141314
);
13151315
return None;
13161316
}
1317+
if openhuman_core::core::observability::is_budget_event(&event) {
1318+
// Log only structured tag metadata — `event.message` can carry
1319+
// upstream provider error text including tokens / pasted-through
1320+
// secrets, and per `CLAUDE.md` "never log secrets or full PII".
1321+
// The (domain, status) pair is sufficient diagnostic since
1322+
// those are the tags `is_budget_event` gates on.
1323+
log::debug!(
1324+
"[sentry-budget-filter] dropping budget-exhausted event (domain={:?}, status={:?})",
1325+
event.tags.get("domain"),
1326+
event.tags.get("status")
1327+
);
1328+
return None;
1329+
}
13171330
// Defense-in-depth: drop max-tool-iterations cap events that
13181331
// slipped past the call-site filters in the core (see
13191332
// `openhuman_core::core::observability::is_max_iterations_event`

src/api/rest.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,20 @@ impl BackendOAuthClient {
476476
// implement retry/disable logic, so skip Sentry to avoid noise.
477477
let is_transient_infra =
478478
crate::core::observability::is_transient_http_status_code(status_code);
479-
if is_transient_infra {
479+
let is_budget_exhausted = status_code == 400
480+
&& crate::openhuman::providers::is_budget_exhausted_message(&text);
481+
if is_budget_exhausted {
482+
tracing::info!(
483+
method = method.as_str(),
484+
path = url.path(),
485+
status = status_code,
486+
failure = "non_2xx",
487+
kind = "budget",
488+
"[backend_api] budget-exhausted 400 on {} {} — not reporting to Sentry",
489+
method.as_str(),
490+
url.path(),
491+
);
492+
} else if is_transient_infra {
480493
tracing::warn!(
481494
domain = "backend_api",
482495
operation = "authed_json",

src/core/observability.rs

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Centralised error reporting for the core, plus a Sentry
2-
//! `before_send` filter that drops per-attempt transient-upstream
3-
//! provider failures.
2+
//! `before_send` filters that drop deterministic provider noise:
3+
//! per-attempt transient-upstream failures and budget-exhausted user-state.
44
//!
55
//! Wraps `tracing::error!` (which the global subscriber forwards to Sentry via
66
//! `sentry-tracing`) inside a `sentry::with_scope` so each captured event
@@ -61,6 +61,7 @@ pub enum ExpectedErrorKind {
6161
LocalAiBinaryMissing,
6262
BackendUserError,
6363
LocalAiCapabilityUnavailable,
64+
BudgetExhausted,
6465
}
6566

6667
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
@@ -86,6 +87,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
8687
if is_local_ai_capability_unavailable_message(&lower) {
8788
return Some(ExpectedErrorKind::LocalAiCapabilityUnavailable);
8889
}
90+
if crate::openhuman::providers::is_budget_exhausted_message(message) {
91+
return Some(ExpectedErrorKind::BudgetExhausted);
92+
}
8993
None
9094
}
9195

@@ -321,6 +325,22 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
321325
"[observability] {domain}.{operation} skipped expected local-ai capability-unavailable error: {message}"
322326
);
323327
}
328+
ExpectedErrorKind::BudgetExhausted => {
329+
// User-state condition: the backend reports the user is out of
330+
// budget / credits / balance (HTTP 400 from the OpenHuman backend,
331+
// surfaced by `providers::is_budget_exhausted_message`). The UI
332+
// already surfaces this as an actionable toast — Sentry would
333+
// turn each affected turn into noise (OPENHUMAN-TAURI-3M / -12 /
334+
// -13). Demote to info so it still appears in breadcrumbs but
335+
// never spawns a Sentry error event.
336+
tracing::info!(
337+
domain = domain,
338+
operation = operation,
339+
kind = "budget",
340+
error = %message,
341+
"[observability] {domain}.{operation} skipped expected budget-exhausted error: {message}"
342+
);
343+
}
324344
}
325345
}
326346

@@ -533,6 +553,47 @@ pub fn is_transient_message_failure(msg: &str) -> bool {
533553
|| contains_transient_transport_phrase(&lower)
534554
}
535555

556+
/// Returns true when a Sentry event is a budget-exhausted 400 that should be
557+
/// dropped from `before_send`.
558+
///
559+
/// Match criteria (all required):
560+
/// - tag `failure == "non_2xx"`
561+
/// - tag `status == "400"`
562+
/// - the event message or any exception value contains one of the tight
563+
/// budget-exhaustion phrases
564+
///
565+
/// Note: `domain` is intentionally not gated here as defense-in-depth over
566+
/// the emit-site classifier — any non_2xx/400 event that carries the
567+
/// budget-exhausted phrasing is dropped regardless of which domain produced
568+
/// it, so a future re-emitter under a different tag still gets filtered.
569+
pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
570+
let tags = &event.tags;
571+
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
572+
return false;
573+
}
574+
if tags.get("status").map(String::as_str) != Some("400") {
575+
return false;
576+
}
577+
event_contains_budget_exhausted_message(event)
578+
}
579+
580+
fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool {
581+
if event
582+
.message
583+
.as_deref()
584+
.is_some_and(crate::openhuman::providers::is_budget_exhausted_message)
585+
{
586+
return true;
587+
}
588+
589+
event.exception.values.iter().any(|exception| {
590+
exception
591+
.value
592+
.as_deref()
593+
.is_some_and(crate::openhuman::providers::is_budget_exhausted_message)
594+
})
595+
}
596+
536597
#[cfg(test)]
537598
mod tests {
538599
use super::*;
@@ -1153,6 +1214,50 @@ mod tests {
11531214
}
11541215
}
11551216

1217+
#[test]
1218+
fn budget_filter_drops_budget_message_on_tagged_400() {
1219+
let event = event_with_tags_and_message(
1220+
&[("failure", "non_2xx"), ("status", "400")],
1221+
r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"#,
1222+
);
1223+
1224+
assert!(is_budget_event(&event));
1225+
}
1226+
1227+
#[test]
1228+
fn budget_filter_drops_budget_exception_on_tagged_400() {
1229+
let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]);
1230+
event.exception.values.push(sentry::protocol::Exception {
1231+
value: Some("Budget exceeded — add credits to continue".to_string()),
1232+
..Default::default()
1233+
});
1234+
1235+
assert!(is_budget_event(&event));
1236+
}
1237+
1238+
#[test]
1239+
fn budget_filter_keeps_non_budget_400() {
1240+
let event = event_with_tags_and_message(
1241+
&[("failure", "non_2xx"), ("status", "400")],
1242+
"Bad request: missing field",
1243+
);
1244+
1245+
assert!(!is_budget_event(&event));
1246+
}
1247+
1248+
#[test]
1249+
fn budget_filter_requires_non_2xx_failure_and_400_status() {
1250+
let message = "Budget exceeded — add credits to continue";
1251+
for tags in [
1252+
vec![("failure", "transport"), ("status", "400")],
1253+
vec![("failure", "non_2xx"), ("status", "500")],
1254+
vec![("failure", "non_2xx")],
1255+
] {
1256+
let event = event_with_tags_and_message(&tags, message);
1257+
assert!(!is_budget_event(&event));
1258+
}
1259+
}
1260+
11561261
#[test]
11571262
fn report_error_or_expected_does_not_panic() {
11581263
report_error_or_expected(

src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ fn main() {
5959
if openhuman_core::core::observability::is_transient_provider_http_failure(&event) {
6060
return None;
6161
}
62+
// Defense-in-depth for budget-exhausted 400s. Emit sites demote the
63+
// known backend responses before they hit Sentry; this catches any
64+
// future non_2xx/status=400 event that carries the same tight body
65+
// phrases.
66+
if openhuman_core::core::observability::is_budget_event(&event) {
67+
return None;
68+
}
6269
// Defense-in-depth: drop max-tool-iterations cap events that
6370
// slipped past the call-site filters in
6471
// `agent::harness::session::runtime::run_single`,

src/openhuman/agent/harness/session/runtime.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -510,10 +510,11 @@ impl Agent {
510510
// `log::info!` (OPENHUMAN-TAURI-99 / -98).
511511
//
512512
// Other agent errors go through `report_error_or_expected`
513-
// so OPENHUMAN-TAURI-5Z and friends — upstream transient
514-
// HTTP that bubbles up under `domain=agent` and escapes
515-
// the `domain=llm_provider` filter — get demoted to a
516-
// warn-level breadcrumb without losing genuine bugs.
513+
// so OPENHUMAN-TAURI-5Z and the budget-noise cluster —
514+
// upstream transient HTTP and backend budget-exhausted 400s
515+
// that bubble up under `domain=agent` and escape the
516+
// `domain=llm_provider` filter — get demoted to a
517+
// warn/info-level breadcrumb without losing genuine bugs.
517518
// `Err` propagation, the `AgentError` domain event, and
518519
// downstream `recoverable=false` semantics are preserved.
519520
let is_max_iter = matches!(
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/// Returns true if a 400 response body indicates the user is out of
2+
/// budget / has insufficient balance / over their plan. These are
3+
/// deterministic user-state errors — already surfaced in the UI as a
4+
/// toast — and must not flow to Sentry as errors.
5+
///
6+
/// Match is case-insensitive against any of the known phrases. Keep the
7+
/// list deliberately tight: false positives demote real backend bugs.
8+
pub fn is_budget_exhausted_message(body: &str) -> bool {
9+
const PHRASES: &[&str] = &[
10+
"insufficient budget",
11+
"budget exceeded",
12+
"add credits",
13+
"insufficient balance",
14+
];
15+
16+
let lower = body.to_ascii_lowercase();
17+
PHRASES.iter().any(|phrase| lower.contains(phrase))
18+
}
19+
20+
#[cfg(test)]
21+
mod tests {
22+
use super::*;
23+
24+
#[test]
25+
fn detects_known_budget_exhaustion_phrases() {
26+
for body in [
27+
"Insufficient budget",
28+
"Budget exceeded",
29+
"Insufficient balance",
30+
"Add credits to continue",
31+
] {
32+
assert!(
33+
is_budget_exhausted_message(body),
34+
"{body:?} must be classified as budget-exhausted user-state"
35+
);
36+
}
37+
}
38+
39+
#[test]
40+
fn detection_is_case_insensitive() {
41+
assert!(is_budget_exhausted_message("INSUFFICIENT BUDGET"));
42+
assert!(is_budget_exhausted_message("budget EXCEEDED — ADD credits"));
43+
assert!(is_budget_exhausted_message("Insufficient BALANCE"));
44+
}
45+
46+
#[test]
47+
fn ignores_non_budget_messages() {
48+
for body in [
49+
"Bad request: missing field",
50+
"Invalid request: model not found",
51+
"HTTP 400 Bad Request",
52+
"",
53+
] {
54+
assert!(
55+
!is_budget_exhausted_message(body),
56+
"{body:?} must not be classified as budget-exhausted"
57+
);
58+
}
59+
}
60+
}

src/openhuman/providers/compatible.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,14 @@ impl OpenAiCompatibleProvider {
400400
let error = response.text().await?;
401401
let sanitized = super::sanitize_api_error(&error);
402402
let message = format!("{} Responses API error: {sanitized}", self.name);
403-
if super::should_report_provider_http_failure(status) {
403+
if super::is_budget_exhausted_http_400(status, &error) {
404+
super::log_budget_exhausted_http_400(
405+
"responses_api",
406+
self.name.as_str(),
407+
Some(model),
408+
status,
409+
);
410+
} else if super::should_report_provider_http_failure(status) {
404411
crate::core::observability::report_error(
405412
message.as_str(),
406413
"llm_provider",
@@ -736,7 +743,14 @@ impl OpenAiCompatibleProvider {
736743
"{} streaming API error ({}): {}",
737744
self.name, status, sanitized
738745
);
739-
if super::should_report_provider_http_failure(status) {
746+
if super::is_budget_exhausted_http_400(status, &body) {
747+
super::log_budget_exhausted_http_400(
748+
"streaming_chat",
749+
self.name.as_str(),
750+
Some(native_request.model.as_str()),
751+
status,
752+
);
753+
} else if super::should_report_provider_http_failure(status) {
740754
crate::core::observability::report_error(
741755
message.as_str(),
742756
"llm_provider",
@@ -1190,7 +1204,14 @@ impl Provider for OpenAiCompatibleProvider {
11901204

11911205
let status_str = status.as_u16().to_string();
11921206
let message = format!("{} API error ({status}): {sanitized}", self.name);
1193-
if super::should_report_provider_http_failure(status) {
1207+
if super::is_budget_exhausted_http_400(status, &error) {
1208+
super::log_budget_exhausted_http_400(
1209+
"chat_completions",
1210+
self.name.as_str(),
1211+
Some(model),
1212+
status,
1213+
);
1214+
} else if super::should_report_provider_http_failure(status) {
11941215
crate::core::observability::report_error(
11951216
message.as_str(),
11961217
"llm_provider",
@@ -1574,7 +1595,14 @@ impl Provider for OpenAiCompatibleProvider {
15741595

15751596
let status_str = status.as_u16().to_string();
15761597
let message = format!("{} API error ({status}): {sanitized}", self.name);
1577-
if super::should_report_provider_http_failure(status) {
1598+
if super::is_budget_exhausted_http_400(status, &error) {
1599+
super::log_budget_exhausted_http_400(
1600+
"native_chat",
1601+
self.name.as_str(),
1602+
Some(model),
1603+
status,
1604+
);
1605+
} else if super::should_report_provider_http_failure(status) {
15781606
crate::core::observability::report_error(
15791607
message.as_str(),
15801608
"llm_provider",
@@ -1701,7 +1729,14 @@ impl Provider for OpenAiCompatibleProvider {
17011729
};
17021730
let sanitized_error = super::sanitize_api_error(&raw_error);
17031731
let message = format!("{}: {}", status, sanitized_error);
1704-
if super::should_report_provider_http_failure(status) {
1732+
if super::is_budget_exhausted_http_400(status, &raw_error) {
1733+
super::log_budget_exhausted_http_400(
1734+
"stream_chat",
1735+
provider_name.as_str(),
1736+
Some(model_owned.as_str()),
1737+
status,
1738+
);
1739+
} else if super::should_report_provider_http_failure(status) {
17051740
crate::core::observability::report_error(
17061741
message.as_str(),
17071742
"llm_provider",

src/openhuman/providers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod billing_error;
12
pub mod compatible;
23
pub mod openhuman_backend;
34
pub mod ops;
@@ -12,4 +13,5 @@ pub use traits::{
1213
ProviderDelta, ToolCall, ToolResultMessage, UsageInfo,
1314
};
1415

16+
pub use billing_error::is_budget_exhausted_message;
1517
pub use ops::*;

0 commit comments

Comments
 (0)