Skip to content

Commit d94d792

Browse files
committed
Expose actionable ids for opaque provider failures
Issue #22 was triggered by generic upstream fatal wrappers that only surfaced 'Something went wrong', which left repeated Jobdori-style failures opaque in the CLI. Capture provider request ids on error responses, classify the known generic wrapper as provider_internal, and prefix the user-visible runtime error with the failure class plus session/trace identifiers so operators can correlate the failure quickly. Constraint: Keep the fix small and user-safe without redesigning the broader runtime error taxonomy Constraint: Preserve existing non-generic error text unless the wrapper is the known opaque fatal surface Rejected: Broadly rewriting every runtime error into classified envelopes | unnecessary scope expansion for issue #22 Confidence: high Scope-risk: narrow Reversibility: clean Directive: If more opaque wrappers appear, extend the marker list and classification helper rather than reintroducing raw wrapper text alone Tested: cargo test -p api detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal -- --nocapture; cargo test -p api retries_exhausted_preserves_nested_request_id_and_failure_class -- --nocapture; cargo test -p rusty-claude-cli opaque_provider_wrapper_surfaces_failure_class_session_and_trace -- --nocapture; cargo test -p rusty-claude-cli retry_exhaustion_preserves_internal_failure_class_for_generic_provider_wrapper -- --nocapture; cargo test --workspace Not-tested: Live upstream reproduction of the Jobdori failure against a real provider session
1 parent 2bab408 commit d94d792

4 files changed

Lines changed: 221 additions & 19 deletions

File tree

rust/crates/api/src/error.rs

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ use std::env::VarError;
22
use std::fmt::{Display, Formatter};
33
use std::time::Duration;
44

5+
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
6+
"something went wrong while processing your request",
7+
"please try again, or use /new to start a fresh session",
8+
];
9+
510
#[derive(Debug)]
611
pub enum ApiError {
712
MissingCredentials {
@@ -25,6 +30,7 @@ pub enum ApiError {
2530
status: reqwest::StatusCode,
2631
error_type: Option<String>,
2732
message: Option<String>,
33+
request_id: Option<String>,
2834
body: String,
2935
retryable: bool,
3036
},
@@ -65,6 +71,68 @@ impl ApiError {
6571
| Self::BackoffOverflow { .. } => false,
6672
}
6773
}
74+
75+
#[must_use]
76+
pub fn request_id(&self) -> Option<&str> {
77+
match self {
78+
Self::Api { request_id, .. } => request_id.as_deref(),
79+
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
80+
Self::MissingCredentials { .. }
81+
| Self::ContextWindowExceeded { .. }
82+
| Self::ExpiredOAuthToken
83+
| Self::Auth(_)
84+
| Self::InvalidApiKeyEnv(_)
85+
| Self::Http(_)
86+
| Self::Io(_)
87+
| Self::Json(_)
88+
| Self::InvalidSseFrame(_)
89+
| Self::BackoffOverflow { .. } => None,
90+
}
91+
}
92+
93+
#[must_use]
94+
pub fn safe_failure_class(&self) -> &'static str {
95+
match self {
96+
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
97+
"provider_auth"
98+
}
99+
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
100+
Self::ContextWindowExceeded { .. } => "context_window",
101+
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
102+
Self::Api { .. } | Self::RetriesExhausted { .. } if self.is_generic_fatal_wrapper() => {
103+
"provider_internal"
104+
}
105+
Self::Api { .. } => "provider_error",
106+
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
107+
"provider_transport"
108+
}
109+
Self::RetriesExhausted { .. } => "provider_retry_exhausted",
110+
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
111+
}
112+
}
113+
114+
#[must_use]
115+
pub fn is_generic_fatal_wrapper(&self) -> bool {
116+
match self {
117+
Self::Api { message, body, .. } => {
118+
message
119+
.as_deref()
120+
.is_some_and(looks_like_generic_fatal_wrapper)
121+
|| looks_like_generic_fatal_wrapper(body)
122+
}
123+
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
124+
Self::MissingCredentials { .. }
125+
| Self::ContextWindowExceeded { .. }
126+
| Self::ExpiredOAuthToken
127+
| Self::Auth(_)
128+
| Self::InvalidApiKeyEnv(_)
129+
| Self::Http(_)
130+
| Self::Io(_)
131+
| Self::Json(_)
132+
| Self::InvalidSseFrame(_)
133+
| Self::BackoffOverflow { .. } => false,
134+
}
135+
}
68136
}
69137

70138
impl Display for ApiError {
@@ -102,13 +170,24 @@ impl Display for ApiError {
102170
status,
103171
error_type,
104172
message,
173+
request_id,
105174
body,
106175
..
107176
} => match (error_type, message) {
108177
(Some(error_type), Some(message)) => {
109-
write!(f, "api returned {status} ({error_type}): {message}")
178+
write!(f, "api returned {status} ({error_type})")?;
179+
if let Some(request_id) = request_id {
180+
write!(f, " [trace {request_id}]")?;
181+
}
182+
write!(f, ": {message}")
183+
}
184+
_ => {
185+
write!(f, "api returned {status}")?;
186+
if let Some(request_id) = request_id {
187+
write!(f, " [trace {request_id}]")?;
188+
}
189+
write!(f, ": {body}")
110190
}
111-
_ => write!(f, "api returned {status}: {body}"),
112191
},
113192
Self::RetriesExhausted {
114193
attempts,
@@ -151,3 +230,57 @@ impl From<VarError> for ApiError {
151230
Self::InvalidApiKeyEnv(value)
152231
}
153232
}
233+
234+
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
235+
let lowered = text.to_ascii_lowercase();
236+
GENERIC_FATAL_WRAPPER_MARKERS
237+
.iter()
238+
.any(|marker| lowered.contains(marker))
239+
}
240+
241+
#[cfg(test)]
242+
mod tests {
243+
use super::ApiError;
244+
245+
#[test]
246+
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
247+
let error = ApiError::Api {
248+
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
249+
error_type: Some("api_error".to_string()),
250+
message: Some(
251+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
252+
.to_string(),
253+
),
254+
request_id: Some("req_jobdori_123".to_string()),
255+
body: String::new(),
256+
retryable: true,
257+
};
258+
259+
assert!(error.is_generic_fatal_wrapper());
260+
assert_eq!(error.safe_failure_class(), "provider_internal");
261+
assert_eq!(error.request_id(), Some("req_jobdori_123"));
262+
assert!(error.to_string().contains("[trace req_jobdori_123]"));
263+
}
264+
265+
#[test]
266+
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
267+
let error = ApiError::RetriesExhausted {
268+
attempts: 3,
269+
last_error: Box::new(ApiError::Api {
270+
status: reqwest::StatusCode::BAD_GATEWAY,
271+
error_type: Some("api_error".to_string()),
272+
message: Some(
273+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
274+
.to_string(),
275+
),
276+
request_id: Some("req_nested_456".to_string()),
277+
body: String::new(),
278+
retryable: true,
279+
}),
280+
};
281+
282+
assert!(error.is_generic_fatal_wrapper());
283+
assert_eq!(error.safe_failure_class(), "provider_internal");
284+
assert_eq!(error.request_id(), Some("req_nested_456"));
285+
}
286+
}

rust/crates/api/src/providers/anthropic.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
808808
return Ok(response);
809809
}
810810

811+
let request_id = request_id_from_headers(response.headers());
811812
let body = response.text().await.unwrap_or_else(|_| String::new());
812813
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
813814
let retryable = is_retryable_status(status);
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
820821
message: parsed_error
821822
.as_ref()
822823
.map(|error| error.error.message.clone()),
824+
request_id,
823825
body,
824826
retryable,
825827
})

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
906906
return Ok(response);
907907
}
908908

909+
let request_id = request_id_from_headers(response.headers());
909910
let body = response.text().await.unwrap_or_default();
910911
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
911912
let retryable = is_retryable_status(status);
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
918919
message: parsed_error
919920
.as_ref()
920921
.and_then(|error| error.error.message.clone()),
922+
request_id,
921923
body,
922924
retryable,
923925
})

rust/crates/rusty-claude-cli/src/main.rs

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5239,6 +5239,7 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
52395239
struct AnthropicRuntimeClient {
52405240
runtime: tokio::runtime::Runtime,
52415241
client: AnthropicClient,
5242+
session_id: String,
52425243
model: String,
52435244
enable_tools: bool,
52445245
emit_output: bool,
@@ -5262,6 +5263,7 @@ impl AnthropicRuntimeClient {
52625263
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
52635264
.with_base_url(api::read_base_url())
52645265
.with_prompt_cache(PromptCache::new(session_id)),
5266+
session_id: session_id.to_string(),
52655267
model,
52665268
enable_tools,
52675269
emit_output,
@@ -5301,11 +5303,13 @@ impl ApiClient for AnthropicRuntimeClient {
53015303
};
53025304

53035305
self.runtime.block_on(async {
5304-
let mut stream = self
5305-
.client
5306-
.stream_message(&message_request)
5307-
.await
5308-
.map_err(|error| RuntimeError::new(error.to_string()))?;
5306+
let mut stream =
5307+
self.client
5308+
.stream_message(&message_request)
5309+
.await
5310+
.map_err(|error| {
5311+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5312+
})?;
53095313
let mut stdout = io::stdout();
53105314
let mut sink = io::sink();
53115315
let out: &mut dyn Write = if self.emit_output {
@@ -5319,11 +5323,9 @@ impl ApiClient for AnthropicRuntimeClient {
53195323
let mut pending_tool: Option<(String, String, String)> = None;
53205324
let mut saw_stop = false;
53215325

5322-
while let Some(event) = stream
5323-
.next_event()
5324-
.await
5325-
.map_err(|error| RuntimeError::new(error.to_string()))?
5326-
{
5326+
while let Some(event) = stream.next_event().await.map_err(|error| {
5327+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5328+
})? {
53275329
match event {
53285330
ApiStreamEvent::MessageStart(start) => {
53295331
for block in start.message.content {
@@ -5418,14 +5420,33 @@ impl ApiClient for AnthropicRuntimeClient {
54185420
..message_request.clone()
54195421
})
54205422
.await
5421-
.map_err(|error| RuntimeError::new(error.to_string()))?;
5423+
.map_err(|error| {
5424+
RuntimeError::new(format_user_visible_api_error(&self.session_id, &error))
5425+
})?;
54225426
let mut events = response_to_events(response, out)?;
54235427
push_prompt_cache_record(&self.client, &mut events);
54245428
Ok(events)
54255429
})
54265430
}
54275431
}
54285432

5433+
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
5434+
if error.is_generic_fatal_wrapper() {
5435+
let mut qualifiers = vec![format!("session {session_id}")];
5436+
if let Some(request_id) = error.request_id() {
5437+
qualifiers.push(format!("trace {request_id}"));
5438+
}
5439+
format!(
5440+
"{} ({}): {}",
5441+
error.safe_failure_class(),
5442+
qualifiers.join(", "),
5443+
error
5444+
)
5445+
} else {
5446+
error.to_string()
5447+
}
5448+
}
5449+
54295450
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
54305451
summary
54315452
.assistant_messages
@@ -6424,18 +6445,19 @@ mod tests {
64246445
format_permissions_report, format_permissions_switch_report, format_pr_report,
64256446
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
64266447
format_ultraplan_report, format_unknown_slash_command,
6427-
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
6428-
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
6429-
permission_policy, print_help_to, push_output_block, render_config_report,
6430-
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
6431-
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
6448+
format_unknown_slash_command_message, format_user_visible_api_error,
6449+
normalize_permission_mode, parse_args, parse_git_status_branch,
6450+
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
6451+
print_help_to, push_output_block, render_config_report, render_diff_report,
6452+
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
6453+
resolve_model_alias, resolve_session_reference, response_to_events,
64326454
resume_supported_slash_commands, run_resume_command,
64336455
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
64346456
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
64356457
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
64366458
SlashCommand, StatusUsage, DEFAULT_MODEL,
64376459
};
6438-
use api::{MessageResponse, OutputContentBlock, Usage};
6460+
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
64396461
use plugins::{
64406462
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
64416463
};
@@ -6475,6 +6497,49 @@ mod tests {
64756497
.expect("plugin tool registry should build")
64766498
}
64776499

6500+
#[test]
6501+
fn opaque_provider_wrapper_surfaces_failure_class_session_and_trace() {
6502+
let error = ApiError::Api {
6503+
status: "500".parse().expect("status"),
6504+
error_type: Some("api_error".to_string()),
6505+
message: Some(
6506+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
6507+
.to_string(),
6508+
),
6509+
request_id: Some("req_jobdori_789".to_string()),
6510+
body: String::new(),
6511+
retryable: true,
6512+
};
6513+
6514+
let rendered = format_user_visible_api_error("session-issue-22", &error);
6515+
assert!(rendered.contains("provider_internal"));
6516+
assert!(rendered.contains("session session-issue-22"));
6517+
assert!(rendered.contains("trace req_jobdori_789"));
6518+
}
6519+
6520+
#[test]
6521+
fn retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper() {
6522+
let error = ApiError::RetriesExhausted {
6523+
attempts: 3,
6524+
last_error: Box::new(ApiError::Api {
6525+
status: "502".parse().expect("status"),
6526+
error_type: Some("api_error".to_string()),
6527+
message: Some(
6528+
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
6529+
.to_string(),
6530+
),
6531+
request_id: Some("req_jobdori_790".to_string()),
6532+
body: String::new(),
6533+
retryable: true,
6534+
}),
6535+
};
6536+
6537+
let rendered = format_user_visible_api_error("session-issue-22", &error);
6538+
assert!(rendered.contains("provider_retry_exhausted"), "{rendered}");
6539+
assert!(rendered.contains("session session-issue-22"));
6540+
assert!(rendered.contains("trace req_jobdori_790"));
6541+
}
6542+
64786543
fn temp_dir() -> PathBuf {
64796544
let nanos = SystemTime::now()
64806545
.duration_since(UNIX_EPOCH)

0 commit comments

Comments
 (0)