Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2978,6 +2978,35 @@ fn dispatch_pending(
dispatched_channels
}

/// Returns `true` when `error` is a non-retryable authentication failure.
///
/// Retrying auth errors is harmful: the token won't self-repair between
/// attempts, so each retry wastes an attempt slot, delays the visible failure,
/// and burns the user's context window. Dead-letter immediately and surface a
/// re-authentication hint instead.
///
/// # Classification rationale
///
/// Auth failures arrive as [`acp::AcpError::AgentError`] with a message
/// surfaced from the upstream CLI. Two narrow patterns reliably identify
/// non-transient auth failures observed in the field:
///
/// - `"Re-authenticate"` — emitted by the Claude CLI when an OAuth token has
/// expired ("OAuth access token has expired. Re-authenticate to continue.").
/// Specific to the auth-expiry flow; does not appear in unrelated errors.
/// - `"API Error: 401"` — present in Claude/Codex HTTP-401 responses; 401 is
/// the standard auth-failure status and does not arise from network blips.
///
/// False positives (misclassifying a transient error as non-retryable) silently
/// drop a user message, which is worse than a false negative (extra retries on
/// an auth error). Both patterns are therefore chosen for high precision.
fn is_auth_error(error: &acp::AcpError) -> bool {
let acp::AcpError::AgentError { message, .. } = error else {
return false;
};
message.contains("Re-authenticate") || message.contains("API Error: 401")
}

/// Spawn a task that posts a user-visible failure notice to the relay.
///
/// Shared by the hard-cap immediate dead-letter path and the retries-exhausted
Expand Down Expand Up @@ -3098,6 +3127,21 @@ fn handle_prompt_result(
} else {
hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)");
}
} else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) {
// Auth errors are non-retryable: the token won't self-repair
// between retries, so requeueing only wastes attempt slots and
// delays the visible failure. Dead-letter immediately and tell
// the user to re-authenticate the CLI.
tracing::warn!(
channel_id = %batch.channel_id,
events = batch.events.len(),
"dead-lettering batch immediately — non-retryable auth error"
);
let content = "⚠️ I couldn't process the last request: authentication failed. \
Please re-authenticate the CLI (e.g. run `claude /login` or `codex login`) \
and then re-send."
.to_string();
spawn_failure_notice(rest_client, &batch, content);
} else if let Some(dead) = queue.requeue(batch) {
let reason = match &result.outcome {
PromptOutcome::Timeout(TimeoutKind::Idle) => "the turn timed out".to_string(),
Expand Down Expand Up @@ -6048,6 +6092,232 @@ mod error_outcome_emission_tests {
let app = AcpError::IdleTimeout(std::time::Duration::from_secs(1));
assert_eq!(turn_errors_emitted_for(PromptOutcome::Error(app)).await, 1);
}

// ── is_auth_error classification ───────────────────────────────────────

#[test]
fn is_auth_error_matches_reauthenticate_message() {
let e = acp::AcpError::AgentError {
code: -32000,
message: "API Error: OAuth access token has expired. Re-authenticate to continue."
.to_string(),
};
assert!(
is_auth_error(&e),
"Re-authenticate variant must be classified as auth error"
);
}

#[test]
fn is_auth_error_matches_401_message() {
let e = acp::AcpError::AgentError {
code: -32000,
message: "Internal error: API Error: 401 OAuth access token has expired.".to_string(),
};
assert!(
is_auth_error(&e),
"API Error: 401 variant must be classified as auth error"
);
}

#[test]
fn is_auth_error_rejects_other_agent_error_message() {
let e = acp::AcpError::AgentError {
code: -32601,
message: "Usage credits required for 1M context — turn on usage credits".to_string(),
};
assert!(
!is_auth_error(&e),
"usage-credit error must NOT be classified as auth error"
);
}

#[test]
fn is_auth_error_rejects_transport_errors() {
let io = acp::AcpError::Io(std::io::Error::other("pipe broke"));
assert!(
!is_auth_error(&io),
"I/O error must not be classified as auth error"
);
let timeout = acp::AcpError::WriteTimeout(std::time::Duration::from_secs(5));
assert!(
!is_auth_error(&timeout),
"WriteTimeout must not be classified as auth error"
);
}

// ── auth error dead-letter behavior ────────────────────────────────────

/// An auth-class `PromptOutcome::Error` must dead-letter immediately
/// (the batch is never requeued) so the user sees a re-auth hint at once
/// rather than after 10 futile retries.
#[tokio::test]
async fn auth_error_dead_letters_immediately_without_requeueing() {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
.sign_with_keys(&keys)
.unwrap();
let channel_id = uuid::Uuid::new_v4();
let batch = FlushBatch {
channel_id,
events: vec![BatchEvent {
event,
prompt_tag: "test".into(),
received_at: std::time::Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

let auth_error = acp::AcpError::AgentError {
code: -32000,
message: "API Error: 401 OAuth access token has expired. Re-authenticate to continue."
.to_string(),
};

let agent = dummy_agent(0).await;
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
pool.task_map_mut().insert(
task_id,
crate::pool::TaskMeta {
agent_index: 0,
channel_id: None,
turn_id: "test-turn-id".to_string(),
recoverable_batch: None,
control_tx: None,
steer_tx: None,
},
);
let mut queue = EventQueue::new(config::DedupMode::Queue);
let config = test_config();
let mut heartbeat_in_flight = false;
let removed_channels = std::collections::HashSet::new();
let mut crash_history = vec![SlotCircuit {
crash_times: Vec::new(),
open_until: None,
respawn_in_flight: false,
}];
let (respawn_tx, _respawn_rx) = mpsc::channel(8);
let mut respawn_tasks = tokio::task::JoinSet::new();
let result = PromptResult {
agent,
source: PromptSource::Channel(channel_id),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::Error(auth_error),
batch: Some(batch),
};
handle_prompt_result(
&mut pool,
&mut queue,
&config,
result,
&mut heartbeat_in_flight,
&removed_channels,
&mut crash_history,
&respawn_tx,
&mut respawn_tasks,
None,
None,
);

// The batch must not be requeued: pending_channels returns 0.
assert_eq!(
queue.pending_channels(),
0,
"auth error must dead-letter immediately — batch must not be requeued"
);
assert_eq!(
queue.queued_event_count(&channel_id),
0,
"auth error must dead-letter immediately — no events should be pending"
);
}

/// A non-auth application error (e.g. usage credits) must still follow the
/// standard requeue path so today's behavior is unchanged.
#[tokio::test]
async fn non_auth_application_error_is_requeued() {
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
.sign_with_keys(&keys)
.unwrap();
let channel_id = uuid::Uuid::new_v4();
let batch = FlushBatch {
channel_id,
events: vec![BatchEvent {
event,
prompt_tag: "test".into(),
received_at: std::time::Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

// Usage-credits error — AgentError but NOT an auth error.
let usage_error = acp::AcpError::AgentError {
code: -32000,
message: "Usage credits required for 1M context".to_string(),
};

let agent = dummy_agent(0).await;
let mut pool = AgentPool::from_slots(vec![None]);
let task_id = pool.join_set.spawn(async {}).id();
pool.task_map_mut().insert(
task_id,
crate::pool::TaskMeta {
agent_index: 0,
channel_id: None,
turn_id: "test-turn-id".to_string(),
recoverable_batch: None,
control_tx: None,
steer_tx: None,
},
);
let mut queue = EventQueue::new(config::DedupMode::Queue);
let config = test_config();
let mut heartbeat_in_flight = false;
let removed_channels = std::collections::HashSet::new();
let mut crash_history = vec![SlotCircuit {
crash_times: Vec::new(),
open_until: None,
respawn_in_flight: false,
}];
let (respawn_tx, _respawn_rx) = mpsc::channel(8);
let mut respawn_tasks = tokio::task::JoinSet::new();
let result = PromptResult {
agent,
source: PromptSource::Channel(channel_id),
turn_id: "test-turn-id".to_string(),
outcome: PromptOutcome::Error(usage_error),
batch: Some(batch),
};
handle_prompt_result(
&mut pool,
&mut queue,
&config,
result,
&mut heartbeat_in_flight,
&removed_channels,
&mut crash_history,
&respawn_tx,
&mut respawn_tasks,
None,
None,
);

// Non-auth application error: batch IS requeued (first attempt, retry budget > 0).
assert_eq!(
queue.pending_channels(),
1,
"non-auth application error must requeue the batch for retry"
);
assert_eq!(
queue.queued_event_count(&channel_id),
1,
"non-auth application error must preserve the event for retry"
);
}
}

#[cfg(test)]
Expand Down
Loading