mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6386854d6c
commit
bf9404a42f
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user