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

@@ -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:?}"),
}
}