fix(cron): stop cron billing-state Sentry floods — 402 credits + 400 budget (TAURI-RUST-514 / -BMW) (#3913)

This commit is contained in:
oxoxDev
2026-06-22 10:14:19 -07:00
committed by GitHub
parent 500822300c
commit bc49ef3e60
4 changed files with 359 additions and 15 deletions
+19
View File
@@ -2223,6 +2223,25 @@ pub fn run() {
);
return None;
}
// Drop provider insufficient-credits 402s — the user's own BYO
// account (e.g. OpenRouter) is out of balance, a billing state
// OpenHuman has no lever over once the request already caps
// max_tokens. The core binary's main.rs before_send already
// filters these; since #1061 the core runs in-process inside this
// shell, so the cron `agent_job` retries-exhausted report (and any
// other compatible-provider path) lands in THIS Sentry client and
// must be filtered identically. Closes the #3617 drift that wired
// the filter only into the standalone-CLI chain (TAURI-RUST-514 /
// -C62).
if openhuman_core::core::observability::is_insufficient_credits_event(&event) {
// Metadata-only log shape — `event.message` carries the raw
// provider 402 body which CLAUDE.md forbids from local logs.
log::debug!(
"[sentry-insufficient-credits-filter] dropping insufficient-credits 402 event_id={:?}",
event.event_id
);
return None;
}
// Strip server_name (hostname) to avoid leaking machine identity.
event.server_name = None;
// Attach the cached account uid so Sentry can count unique users
+96 -14
View File
@@ -2233,6 +2233,43 @@ pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
event_contains_budget_exhausted_message(event)
}
/// Whether a raw error / message string is a provider **insufficient-credits
/// 402** — the BYO account (e.g. OpenRouter) genuinely lacks the balance to
/// satisfy the request. Anchored on BOTH a 402-status shape AND a credit
/// phrase, so a bare `402` (or a non-402 error whose body merely contains the
/// digits `402`) is not swallowed and keeps reaching Sentry.
///
/// Single source of truth shared by the message-level cron halt
/// (`openhuman::cron::scheduler`'s `is_insufficient_credits_failure`, which
/// stops retrying a permanent 402 and skips its `report_error`) and the
/// event-level `before_send` filter [`is_insufficient_credits_event`] — the
/// same split as [`is_session_expired_message`] ↔ [`is_session_expired_event`].
/// TAURI-RUST-514 / -C62.
pub fn is_insufficient_credits_message(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
// Anchor the 402 to a status shape — the emit sites format the message
// as "<provider> API error (402 Payment Required): <body>". Matching a
// bare "402" would false-positive on body digits (e.g. a 400 error
// whose body says "can only afford 402 tokens"), which is NOT this
// user-state and must keep reaching Sentry.
let is_402_status = lower.contains("(402") || lower.contains("402 payment required");
if !is_402_status {
return false;
}
// Check the credit/balance signal against the BODY only. The status prefix
// "(402 Payment Required)" itself contains the phrase "payment required",
// which `body_indicates_insufficient_credits` matches — so feeding it the
// whole string would classify ANY 402 (even one whose body is an unrelated
// condition) as insufficient-credits and suppress it (codex P2 on #3913).
// Slice off everything up to and including the formatted "): " status
// separator first; fall back to the whole text when the separator is
// absent (non-standard shape) so a credit phrase there still matches.
let body = lower
.split_once("): ")
.map_or(lower.as_str(), |(_, body)| body);
crate::openhuman::inference::provider::body_indicates_insufficient_credits(body)
}
/// Defense-in-depth `before_send` filter for **insufficient-credits 402**
/// provider events (TAURI-RUST-C62): the user's own BYO provider account
/// (e.g. OpenRouter) is out of balance — a billing state OpenHuman has no
@@ -2252,22 +2289,10 @@ pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
/// - that same text carries an insufficient-credits phrase
/// (`provider::body_indicates_insufficient_credits`).
pub fn is_insufficient_credits_event(event: &sentry::protocol::Event<'_>) -> bool {
fn text_is_insufficient_credits_402(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
// Anchor the 402 to a status shape — the emit sites format the message
// as "<provider> API error (402 Payment Required): <body>". Matching a
// bare "402" would false-positive on body digits (e.g. a 400 error
// whose body says "can only afford 402 tokens"), which is NOT this
// user-state and must keep reaching Sentry.
let is_402_status = lower.contains("(402") || lower.contains("402 payment required");
is_402_status
&& crate::openhuman::inference::provider::body_indicates_insufficient_credits(text)
}
if event
.message
.as_deref()
.is_some_and(text_is_insufficient_credits_402)
.is_some_and(is_insufficient_credits_message)
{
return true;
}
@@ -2275,7 +2300,7 @@ pub fn is_insufficient_credits_event(event: &sentry::protocol::Event<'_>) -> boo
exception
.value
.as_deref()
.is_some_and(text_is_insufficient_credits_402)
.is_some_and(is_insufficient_credits_message)
})
}
@@ -5345,6 +5370,63 @@ mod tests {
)));
}
#[test]
fn is_insufficient_credits_message_matches_verbatim_cron_402() {
// Verbatim TAURI-RUST-514 body as it reaches the cron `report_error`
// call site (`domain=cron`, `operation=agent_job`): the message-level
// matcher must catch it so the cron halt skips the leaking report.
assert!(is_insufficient_credits_message(
"openrouter API error (402 Payment Required): {\"error\":{\"message\":\"This \
request requires more credits, or fewer max_tokens. You requested up to 65536 \
tokens, but can only afford 5081.\"}}",
));
assert!(is_insufficient_credits_message(
"custom_openai API error (402 Payment Required): insufficient balance",
));
}
#[test]
fn is_insufficient_credits_message_requires_402_and_credit_phrase() {
// A 402 without a credit phrase, and a credit phrase without a 402
// status, must both stay reportable (could be a real defect).
assert!(!is_insufficient_credits_message(
"provider API error (402): some unrelated condition"
));
assert!(!is_insufficient_credits_message(
"provider API error (500): internal error, insufficient memory"
));
// The status must be the 402, not a digit in the body.
assert!(!is_insufficient_credits_message(
"provider API error (400): can only afford 402 tokens"
));
// codex P2: the status prefix "(402 Payment Required)" itself contains
// the phrase "payment required". An unrelated body behind a real 402
// must NOT be classified as insufficient-credits (else the cron halt
// would suppress a genuine 402 defect). The credit signal must live in
// the BODY, not the formatted status prefix.
assert!(!is_insufficient_credits_message(
"provider API error (402 Payment Required): some unrelated condition"
));
// But a body that literally carries the credit signal still matches,
// even when the status prefix also says "Payment Required".
assert!(is_insufficient_credits_message(
"provider API error (402 Payment Required): your account has insufficient balance"
));
}
#[test]
fn is_insufficient_credits_event_delegates_to_message_matcher() {
// Parity: the event-level filter is now a thin wrapper over the
// message-level matcher across both the message and exception paths.
let body = "myopenrouter API error (402 Payment Required): This request requires \
more credits, or fewer max_tokens.";
assert!(is_insufficient_credits_message(body));
assert!(is_insufficient_credits_event(&event_with_message(body)));
assert!(is_insufficient_credits_event(&event_with_exception_value(
body
)));
}
#[test]
fn session_expired_before_send_matches_core_401_events() {
let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work";
+120 -1
View File
@@ -337,6 +337,61 @@ fn is_session_expired_failure(
crate::core::observability::is_session_expired_message(signal)
}
/// Did this failed agent-job attempt hit a provider **insufficient-credits
/// 402** state (BYO account out of balance, e.g. OpenRouter)?
///
/// Same shape as [`is_session_expired_failure`], for the same reason: the
/// condition is a deterministic, permanent user-state error with no local
/// lever — retrying it across the backoff loop cannot recover, it only burns
/// cycles and (pre-this-guard) multiplied the per-attempt
/// `report_error` events that flooded Sentry (TAURI-RUST-514: the residual
/// after #3617 capped the extraction path, surfacing here via the cron
/// `agent_job` `report_error` which the desktop `before_send` chain did not
/// yet filter). So we halt after the first occurrence and skip the report,
/// matching the source demotion already applied at the provider emit site
/// (`is_provider_insufficient_credits_402`).
///
/// Routes on `last_agent_error` first (the raw anyhow chain carrying the
/// provider's 402 wire body), falling back to `last_output`, identical to
/// [`is_session_expired_failure`]. Restricted to `JobType::Agent`.
fn is_insufficient_credits_failure(
job_type: &JobType,
last_agent_error: Option<&str>,
last_output: &str,
) -> bool {
if !matches!(job_type, JobType::Agent) {
return false;
}
let signal = last_agent_error.unwrap_or(last_output);
crate::core::observability::is_insufficient_credits_message(signal)
}
/// Did this failed agent-job attempt hit a managed-backend **budget-exhausted
/// 400** state (`USER_INSUFFICIENT_CREDITS` — the OpenHuman account is out of
/// its spend budget)?
///
/// The sibling of [`is_insufficient_credits_failure`] for the managed-backend
/// billing 400 instead of the BYO provider 402. Same rationale: a permanent
/// user-state error with no local lever, so retrying across the backoff loop
/// cannot recover and the per-attempt `report_error` floods Sentry
/// (TAURI-RUST-BMW). The existing `before_send` filter
/// [`crate::core::observability::is_budget_event`] is **tag-gated**
/// (`failure=non_2xx` + `status=400`), tags the cron `agent_job` re-report
/// does not carry — so the residual leaks here. Halt on the first occurrence
/// and skip the report, reusing the same body classifier as that filter
/// (`provider::is_budget_exhausted_message`). Restricted to `JobType::Agent`.
fn is_budget_exhausted_failure(
job_type: &JobType,
last_agent_error: Option<&str>,
last_output: &str,
) -> bool {
if !matches!(job_type, JobType::Agent) {
return false;
}
let signal = last_agent_error.unwrap_or(last_output);
crate::openhuman::inference::provider::is_budget_exhausted_message(signal)
}
async fn execute_job_with_retry(
config: &Config,
security: &SecurityPolicy,
@@ -347,6 +402,8 @@ async fn execute_job_with_retry(
let retries = config.reliability.scheduler_retries;
let mut backoff_ms = config.reliability.provider_backoff_ms.max(200);
let mut session_expired = false;
let mut credits_exhausted = false;
let mut budget_exhausted = false;
for attempt in 0..=retries {
let (success, output, agent_error) = match job.job_type {
@@ -384,6 +441,49 @@ async fn execute_job_with_retry(
break;
}
if is_insufficient_credits_failure(
&job.job_type,
last_agent_error.as_deref(),
last_output.as_str(),
) {
// Halt on the first occurrence — a BYO provider 402 (out of
// balance) is permanent across the backoff loop, and the
// provider emit site already demoted it from Sentry. Skipping
// the retries-exhausted `report_error` below keeps the residual
// off Sentry at source, independent of the `before_send` chain
// (TAURI-RUST-514). See `is_insufficient_credits_failure`.
// Metadata-only log (no raw provider body — see CLAUDE.md).
log::debug!(
"[cron] action=halt_on_insufficient_credits_402 job_id={} attempt={} retries={}",
job.id.as_str(),
attempt,
retries
);
credits_exhausted = true;
break;
}
if is_budget_exhausted_failure(
&job.job_type,
last_agent_error.as_deref(),
last_output.as_str(),
) {
// Halt on the first occurrence — a managed-backend budget 400
// (USER_INSUFFICIENT_CREDITS) is permanent across the backoff
// loop. The tag-gated `is_budget_event` before_send filter never
// matches this cron re-report, so suppressing the report here
// keeps it off Sentry at source (TAURI-RUST-BMW). See
// `is_budget_exhausted_failure`. Metadata-only log (no raw body).
log::debug!(
"[cron] action=halt_on_budget_exhausted_400 job_id={} attempt={} retries={}",
job.id.as_str(),
attempt,
retries
);
budget_exhausted = true;
break;
}
if attempt < retries {
let jitter_ms = u64::from(Utc::now().timestamp_subsec_millis() % 250);
time::sleep(Duration::from_millis(backoff_ms + jitter_ms)).await;
@@ -391,7 +491,12 @@ async fn execute_job_with_retry(
}
}
if matches!(job.job_type, JobType::Agent) && !session_expired {
// Permanent billing user-states (BYO 402 out-of-credit / managed-backend
// 400 out-of-budget) are demoted at source: halt the loop and skip the
// retries-exhausted report, independent of the tag-gated before_send
// filters that the cron re-report does not match (TAURI-RUST-514 / -BMW).
let billing_halt = credits_exhausted || budget_exhausted;
if matches!(job.job_type, JobType::Agent) && !session_expired && !billing_halt {
let report_message = last_agent_error
.as_deref()
.unwrap_or_else(|| last_output.as_str());
@@ -409,6 +514,20 @@ async fn execute_job_with_retry(
("failure", "retries_exhausted"),
],
);
} else if matches!(job.job_type, JobType::Agent) && billing_halt {
// Suppressed the retries-exhausted Sentry report for a permanent
// billing user-state. Metadata-only breadcrumb so the suppression is
// diagnosable in production without the raw provider body.
let reason = if credits_exhausted {
"insufficient_credits_402"
} else {
"budget_exhausted_400"
};
log::debug!(
"[cron] action=suppress_retries_exhausted_report reason={reason} job_id={} retries={}",
job.id.as_str(),
retries
);
}
(false, last_output)
+124
View File
@@ -383,6 +383,130 @@ fn is_session_expired_failure_does_not_halt_shell_jobs() {
);
}
// TAURI-RUST-514 — a BYO provider insufficient-credits 402 ("requires more
// credits") leaks from a cron-fired agent job through `last_agent_error`.
// `is_insufficient_credits_failure` must consult the message classifier so the
// retry loop halts on the first occurrence (a permanent billing state) instead
// of retrying N times and reporting `failure=retries_exhausted` to Sentry.
#[test]
fn is_insufficient_credits_failure_matches_verbatim_402_in_agent_error() {
let wire = r#"openrouter API error (402 Payment Required): {"error":{"message":"This request requires more credits, or fewer max_tokens. You requested up to 65536 tokens, but can only afford 5081."}}"#;
assert!(
is_insufficient_credits_failure(
&JobType::Agent,
Some(wire),
AGENT_JOB_USER_FAILURE_MESSAGE
),
"raw agent error carrying the 402 credit body must trip the halt"
);
}
// Defense-in-depth: classify even if a future path surfaces the raw 402 in
// `last_output` rather than `last_agent_error`.
#[test]
fn is_insufficient_credits_failure_matches_when_only_output_carries_signal() {
let wire = r#"openrouter API error (402 Payment Required): insufficient balance — add credits"#;
assert!(is_insufficient_credits_failure(&JobType::Agent, None, wire));
}
// Negative guard: the canned user-facing message carries no 402 signal, and an
// ordinary provider error (500, or a 400 whose body merely names a token
// count) must NOT halt — those are exactly what the retry loop +
// `failure=retries_exhausted` capture exist for.
#[test]
fn is_insufficient_credits_failure_does_not_match_non_credit_errors() {
assert!(!is_insufficient_credits_failure(
&JobType::Agent,
Some(AGENT_JOB_USER_FAILURE_MESSAGE),
AGENT_JOB_USER_FAILURE_MESSAGE,
));
let server_err =
r#"OpenHuman API error (500 Internal Server Error): {"error":"Internal server error"}"#;
assert!(!is_insufficient_credits_failure(
&JobType::Agent,
Some(server_err),
""
));
let digit_in_body = r#"provider API error (400): can only afford 402 tokens"#;
assert!(
!is_insufficient_credits_failure(&JobType::Agent, Some(digit_in_body), ""),
"the 402 must be the status, not an arbitrary token count in a 400 body"
);
}
// Scope guard: shell jobs that echo a 402-shaped string keep their retry
// semantics — only agent jobs route through the inference layer.
#[test]
fn is_insufficient_credits_failure_does_not_halt_shell_jobs() {
let wire = r#"openrouter API error (402 Payment Required): requires more credits"#;
assert!(!is_insufficient_credits_failure(
&JobType::Shell,
None,
wire
));
assert!(!is_insufficient_credits_failure(
&JobType::Shell,
Some(wire),
wire
));
}
// TAURI-RUST-BMW — a managed-backend 400 "Insufficient budget"
// (USER_INSUFFICIENT_CREDITS) leaks from a cron-fired agent job through
// `last_agent_error`. `is_budget_exhausted_failure` must consult the budget
// classifier so the retry loop halts on the first occurrence (a permanent
// billing state) instead of retrying N times and reporting
// `failure=retries_exhausted` to Sentry — the tag-gated `is_budget_event`
// `before_send` filter never matched this cron re-report.
#[test]
fn is_budget_exhausted_failure_matches_verbatim_400_in_agent_error() {
let wire = r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget","errorCode":"USER_INSUFFICIENT_CREDITS"}"#;
assert!(
is_budget_exhausted_failure(&JobType::Agent, Some(wire), AGENT_JOB_USER_FAILURE_MESSAGE),
"raw agent error carrying the 400 budget body must trip the halt"
);
}
// Defense-in-depth: classify even if a future path surfaces the raw 400 in
// `last_output` rather than `last_agent_error`.
#[test]
fn is_budget_exhausted_failure_matches_when_only_output_carries_signal() {
let wire = r#"OpenHuman API error (400 Bad Request): budget exceeded — add credits"#;
assert!(is_budget_exhausted_failure(&JobType::Agent, None, wire));
}
// Negative guard: the canned user-facing message and an ordinary provider
// error must NOT halt — those are what the retry loop +
// `failure=retries_exhausted` capture exist for.
#[test]
fn is_budget_exhausted_failure_does_not_match_non_budget_errors() {
assert!(!is_budget_exhausted_failure(
&JobType::Agent,
Some(AGENT_JOB_USER_FAILURE_MESSAGE),
AGENT_JOB_USER_FAILURE_MESSAGE,
));
let server_err =
r#"OpenHuman API error (500 Internal Server Error): {"error":"Internal server error"}"#;
assert!(!is_budget_exhausted_failure(
&JobType::Agent,
Some(server_err),
""
));
}
// Scope guard: shell jobs that echo a budget-shaped string keep their retry
// semantics — only agent jobs route through the inference layer.
#[test]
fn is_budget_exhausted_failure_does_not_halt_shell_jobs() {
let wire = r#"OpenHuman API error (400 Bad Request): {"error":"Insufficient budget"}"#;
assert!(!is_budget_exhausted_failure(&JobType::Shell, None, wire));
assert!(!is_budget_exhausted_failure(
&JobType::Shell,
Some(wire),
wire
));
}
#[tokio::test]
async fn run_agent_job_returns_error_without_provider_key() {
let tmp = TempDir::new().unwrap();