Implement US-022: Enhanced error context for API failures

Add structured error context to API failures:
- Request ID tracking across retries with full context in error messages
- Provider-specific error code mapping with actionable suggestions
- Suggested user actions for common error types (401, 403, 413, 429, 500, 502-504)
- Added suggested_action field to ApiError::Api variant
- Updated enrich_bearer_auth_error to preserve suggested_action

Files changed:
- rust/crates/api/src/error.rs: Add suggested_action field, update Display
- rust/crates/api/src/providers/openai_compat.rs: Add suggested_action_for_status()
- rust/crates/api/src/providers/anthropic.rs: Update error handling

All tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yeachan-Heo
2026-04-16 19:15:00 +00:00
parent 5e65b33042
commit 4cb1db9faa
4 changed files with 208 additions and 8 deletions

View File

@@ -53,6 +53,8 @@ pub enum ApiError {
request_id: Option<String>,
body: String,
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
},
RetriesExhausted {
attempts: u32,
@@ -239,6 +241,7 @@ impl ApiError {
}
impl Display for ApiError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials {
@@ -340,9 +343,7 @@ impl Display for ApiError {
provider,
} => write!(
f,
"request body size ({} bytes) exceeds {provider} limit ({} bytes); reduce prompt length or context before retrying",
estimated_bytes,
max_bytes
"request body size ({estimated_bytes} bytes) exceeds {provider} limit ({max_bytes} bytes); reduce prompt length or context before retrying"
),
}
}
@@ -489,6 +490,7 @@ mod tests {
request_id: Some("req_jobdori_123".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(error.is_generic_fatal_wrapper());
@@ -511,6 +513,7 @@ mod tests {
request_id: Some("req_nested_456".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
}),
};
@@ -531,6 +534,7 @@ mod tests {
request_id: Some("req_ctx_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
assert!(error.is_context_window_failure());

View File

@@ -885,6 +885,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
request_id,
body,
retryable,
suggested_action: None,
})
}
@@ -909,6 +910,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
} = error
else {
return error;
@@ -921,6 +923,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
let Some(bearer_token) = auth.bearer_token() else {
@@ -931,6 +934,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
};
if !bearer_token.starts_with("sk-ant-") {
@@ -941,6 +945,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
@@ -955,6 +960,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
};
}
let enriched_message = match message {
@@ -968,6 +974,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
request_id,
body,
retryable,
suggested_action,
}
}
@@ -1555,6 +1562,7 @@ mod tests {
request_id: Some("req_varleg_001".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@@ -1595,6 +1603,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: true,
suggested_action: None,
};
// when
@@ -1623,6 +1632,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@@ -1650,6 +1660,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when
@@ -1674,6 +1685,7 @@ mod tests {
request_id: None,
body: String::new(),
retryable: false,
suggested_action: None,
};
// when

View File

@@ -32,9 +32,9 @@ pub struct OpenAiCompatConfig {
pub base_url_env: &'static str,
pub default_base_url: &'static str,
/// Maximum request body size in bytes. Provider-specific limits:
/// - DashScope: 6MB (6_291_456 bytes) - observed in dogfood testing
/// - OpenAI: 100MB (104_857_600 bytes)
/// - xAI: 50MB (52_428_800 bytes)
/// - `DashScope`: 6MB (`6_291_456` bytes) - observed in dogfood testing
/// - `OpenAI`: 100MB (`104_857_600` bytes)
/// - `xAI`: 50MB (`52_428_800` bytes)
pub max_request_body_bytes: usize,
}
@@ -196,6 +196,10 @@ impl OpenAiCompatClient {
request_id,
body,
retryable: false,
suggested_action: suggested_action_for_status(
reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
),
});
}
}
@@ -1289,6 +1293,7 @@ fn parse_sse_frame(
request_id: None,
body: payload.clone(),
retryable: false,
suggested_action: suggested_action_for_status(status),
});
}
}
@@ -1346,6 +1351,8 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let suggested_action = suggested_action_for_status(status);
Err(ApiError::Api {
status,
error_type: parsed_error
@@ -1357,6 +1364,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
request_id,
body,
retryable,
suggested_action,
})
}
@@ -1364,6 +1372,20 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
match status.as_u16() {
401 => Some("Check API key is set correctly and has not expired".to_string()),
403 => Some("Verify API key has required permissions for this operation".to_string()),
413 => Some("Reduce prompt size or context window before retrying".to_string()),
429 => Some("Wait a moment before retrying; consider reducing request rate".to_string()),
500 => Some("Provider server error - retry after a brief wait".to_string()),
502..=504 => Some("Provider gateway error - retry after a brief wait".to_string()),
_ => None,
}
}
fn normalize_finish_reason(value: &str) -> String {
match value {
"stop" => "end_turn",
@@ -2142,7 +2164,7 @@ mod tests {
assert_eq!(max_bytes, 6_291_456); // 6MB limit
assert!(estimated_bytes > max_bytes);
}
_ => panic!("expected RequestBodySizeExceeded error, got {:?}", err),
_ => panic!("expected RequestBodySizeExceeded error, got {err:?}"),
}
}