-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(errors): include retry-after in api errors #802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
a8adf59
ba21290
fefb4e7
228d250
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -462,6 +462,11 @@ pub async fn execute_method( | |
| .and_then(|v| v.to_str().ok()) | ||
| .unwrap_or("") | ||
| .to_string(); | ||
| let retry_after = response | ||
| .headers() | ||
| .get(reqwest::header::RETRY_AFTER) | ||
| .and_then(|v| v.to_str().ok()) | ||
| .map(str::to_string); | ||
|
|
||
| if !status.is_success() { | ||
| let error_body = response.text().await.unwrap_or_default(); | ||
|
|
@@ -472,7 +477,7 @@ pub async fn execute_method( | |
| latency_ms = latency_ms, | ||
| "API error" | ||
| ); | ||
| return handle_error_response(status, &error_body, &auth_method); | ||
| return handle_error_response(status, &error_body, &auth_method, retry_after); | ||
| } | ||
|
|
||
| tracing::debug!( | ||
|
|
@@ -754,6 +759,7 @@ fn handle_error_response<T>( | |
| status: reqwest::StatusCode, | ||
| error_body: &str, | ||
| auth_method: &AuthMethod, | ||
| retry_after: Option<String>, | ||
| ) -> Result<T, GwsError> { | ||
| // If 401/403 and no auth was provided, give a helpful message | ||
| if (status.as_u16() == 401 || status.as_u16() == 403) && *auth_method == AuthMethod::None { | ||
|
|
@@ -800,6 +806,7 @@ fn handle_error_response<T>( | |
| message, | ||
| reason, | ||
| enable_url, | ||
| retry_after, | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -809,6 +816,7 @@ fn handle_error_response<T>( | |
| message: error_body.to_string(), | ||
| reason: "httpError".to_string(), | ||
| enable_url: None, | ||
| retry_after, | ||
| }) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
|
|
@@ -1947,6 +1955,7 @@ mod tests { | |
| reqwest::StatusCode::UNAUTHORIZED, | ||
| "Unauthorized", | ||
| &AuthMethod::None, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
| match err { | ||
|
|
@@ -1973,6 +1982,7 @@ mod tests { | |
| reqwest::StatusCode::UNAUTHORIZED, | ||
| &json_err, | ||
| &AuthMethod::OAuth, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
| match err { | ||
|
|
@@ -2008,6 +2018,7 @@ mod tests { | |
| reqwest::StatusCode::BAD_REQUEST, | ||
| &json_err, | ||
| &AuthMethod::OAuth, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
| match err { | ||
|
|
@@ -2024,6 +2035,31 @@ mod tests { | |
| _ => panic!("Expected Api error"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_handle_error_response_preserves_retry_after_header() { | ||
| let json_err = json!({ | ||
| "error": { | ||
| "code": 429, | ||
| "message": "Quota exceeded", | ||
| "errors": [{ "reason": "rateLimitExceeded" }] | ||
| } | ||
| }) | ||
| .to_string(); | ||
|
|
||
| let err = handle_error_response::<()>( | ||
| reqwest::StatusCode::TOO_MANY_REQUESTS, | ||
| &json_err, | ||
| &AuthMethod::OAuth, | ||
| Some("120".to_string()), | ||
| ) | ||
| .unwrap_err(); | ||
|
|
||
| match err { | ||
| GwsError::Api { retry_after, .. } => assert_eq!(retry_after.as_deref(), Some("120")), | ||
| _ => panic!("Expected Api error"), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
|
|
@@ -2156,6 +2192,7 @@ fn test_handle_error_response_non_json() { | |
| reqwest::StatusCode::INTERNAL_SERVER_ERROR, | ||
| "Internal Server Error Text", | ||
| &AuthMethod::OAuth, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
| match err { | ||
|
|
@@ -2223,6 +2260,7 @@ fn test_handle_error_response_access_not_configured_with_url() { | |
| reqwest::StatusCode::FORBIDDEN, | ||
| &json_err, | ||
| &AuthMethod::OAuth, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
|
|
||
|
|
@@ -2260,6 +2298,7 @@ fn test_handle_error_response_access_not_configured_errors_array() { | |
| reqwest::StatusCode::FORBIDDEN, | ||
| &json_err, | ||
| &AuthMethod::OAuth, | ||
| None, | ||
| ) | ||
| .unwrap_err(); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -274,6 +274,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { | |
| message: err, | ||
| reason: "calendarList_failed".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The References
|
||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -221,6 +221,7 @@ pub(super) async fn handle_subscribe( | |
| message: format!("Failed to create Pub/Sub topic: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }); | ||
| } | ||
|
|
||
|
|
@@ -246,6 +247,7 @@ pub(super) async fn handle_subscribe( | |
| message: format!("Failed to create Pub/Sub subscription: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -421,6 +423,7 @@ async fn pull_loop( | |
| message: format!("Pub/Sub pull failed: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -385,6 +385,7 @@ pub(super) async fn fetch_message_metadata( | |
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status().as_u16(); | ||
| let retry_after = retry_after_header(&resp); | ||
| let body = resp | ||
| .text() | ||
| .await | ||
|
|
@@ -393,6 +394,7 @@ pub(super) async fn fetch_message_metadata( | |
| status, | ||
| &body, | ||
| &format!("Failed to fetch message {message_id}"), | ||
| retry_after, | ||
| )); | ||
| } | ||
|
|
||
|
|
@@ -407,7 +409,12 @@ pub(super) async fn fetch_message_metadata( | |
| /// Build a `GwsError::Api` from an HTTP error response body, parsing the | ||
| /// Google JSON error format when possible. Modeled after the executor's | ||
| /// `handle_error_response`, extracting message, reason, and enable URL. | ||
| pub(super) fn build_api_error(status: u16, body: &str, context: &str) -> GwsError { | ||
| pub(super) fn build_api_error( | ||
| status: u16, | ||
| body: &str, | ||
| context: &str, | ||
| retry_after: Option<String>, | ||
| ) -> GwsError { | ||
| let err_json: Option<Value> = serde_json::from_str(body).ok(); | ||
| let err_obj = err_json.as_ref().and_then(|v| v.get("error")); | ||
| let message = err_obj | ||
|
|
@@ -438,9 +445,17 @@ pub(super) fn build_api_error(status: u16, body: &str, context: &str) -> GwsErro | |
| message: format!("{context}: {message}"), | ||
| reason, | ||
| enable_url, | ||
| retry_after, | ||
| } | ||
| } | ||
|
|
||
| pub(super) fn retry_after_header(resp: &reqwest::Response) -> Option<String> { | ||
| resp.headers() | ||
| .get(reqwest::header::RETRY_AFTER) | ||
| .and_then(|value| value.to_str().ok()) | ||
| .map(str::to_string) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| #[derive(Debug)] | ||
| struct SendAsIdentity { | ||
| mailbox: Mailbox, | ||
|
|
@@ -462,6 +477,7 @@ async fn fetch_send_as_identities( | |
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status().as_u16(); | ||
| let retry_after = retry_after_header(&resp); | ||
| let body = resp | ||
| .text() | ||
| .await | ||
|
|
@@ -470,6 +486,7 @@ async fn fetch_send_as_identities( | |
| status, | ||
| &body, | ||
| "Failed to fetch sendAs settings", | ||
| retry_after, | ||
| )); | ||
| } | ||
|
|
||
|
|
@@ -656,11 +673,17 @@ async fn fetch_profile_display_name( | |
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status().as_u16(); | ||
| let retry_after = retry_after_header(&resp); | ||
| let body = resp | ||
| .text() | ||
| .await | ||
| .unwrap_or_else(|_| "(error body unreadable)".to_string()); | ||
| return Err(build_api_error(status, &body, "People API request failed")); | ||
| return Err(build_api_error( | ||
| status, | ||
| &body, | ||
| "People API request failed", | ||
| retry_after, | ||
| )); | ||
| } | ||
|
|
||
| let body: Value = resp.json().await.map_err(|e| { | ||
|
|
@@ -703,6 +726,7 @@ async fn fetch_attachment_data( | |
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status().as_u16(); | ||
| let retry_after = retry_after_header(&resp); | ||
| let err = resp | ||
| .text() | ||
| .await | ||
|
|
@@ -711,6 +735,7 @@ async fn fetch_attachment_data( | |
| status, | ||
| &err, | ||
| &format!("Failed to fetch attachment {attachment_id} from message {message_id}"), | ||
| retry_after, | ||
| )); | ||
| } | ||
|
|
||
|
|
@@ -3607,13 +3632,14 @@ mod tests { | |
| #[test] | ||
| fn test_build_api_error_parses_google_json_format() { | ||
| let body = r#"{"error":{"code":403,"message":"Insufficient Permission","errors":[{"reason":"insufficientPermissions","domain":"global","message":"Insufficient Permission"}]}}"#; | ||
| let err = build_api_error(403, body, "Test context"); | ||
| let err = build_api_error(403, body, "Test context", None); | ||
| match err { | ||
| GwsError::Api { | ||
| code, | ||
| message, | ||
| reason, | ||
| enable_url, | ||
| .. | ||
| } => { | ||
| assert_eq!(code, 403); | ||
| assert!(message.contains("Test context")); | ||
|
|
@@ -3627,7 +3653,7 @@ mod tests { | |
|
|
||
| #[test] | ||
| fn test_build_api_error_falls_back_to_raw_body() { | ||
| let err = build_api_error(500, "Internal Server Error", "Test context"); | ||
| let err = build_api_error(500, "Internal Server Error", "Test context", None); | ||
| match err { | ||
| GwsError::Api { | ||
| code, | ||
|
|
@@ -3643,10 +3669,20 @@ mod tests { | |
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_build_api_error_preserves_retry_after() { | ||
| let body = r#"{"error":{"code":429,"message":"Quota exceeded","errors":[{"reason":"rateLimitExceeded"}]}}"#; | ||
| let err = build_api_error(429, body, "ctx", Some("60".to_string())); | ||
| match err { | ||
| GwsError::Api { retry_after, .. } => assert_eq!(retry_after.as_deref(), Some("60")), | ||
| _ => panic!("Expected GwsError::Api"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_build_api_error_extracts_top_level_reason() { | ||
| let body = r#"{"error":{"code":404,"message":"Not Found","reason":"notFound"}}"#; | ||
| let err = build_api_error(404, body, "ctx"); | ||
| let err = build_api_error(404, body, "ctx", None); | ||
| match err { | ||
| GwsError::Api { reason, .. } => assert_eq!(reason, "notFound"), | ||
| _ => panic!("Expected GwsError::Api"), | ||
|
|
@@ -3656,7 +3692,7 @@ mod tests { | |
| #[test] | ||
| fn test_build_api_error_access_not_configured_extracts_url() { | ||
| let body = r#"{"error":{"code":403,"message":"People API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=123 then retry.","errors":[{"reason":"accessNotConfigured"}]}}"#; | ||
| let err = build_api_error(403, body, "ctx"); | ||
| let err = build_api_error(403, body, "ctx", None); | ||
| match err { | ||
| GwsError::Api { | ||
| reason, enable_url, .. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,7 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { | |
| message: err, | ||
| reason: "list_failed".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,7 @@ pub(super) async fn handle_watch( | |
| message: format!("Failed to create Pub/Sub topic: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }); | ||
| } | ||
|
|
||
|
|
@@ -132,6 +133,7 @@ pub(super) async fn handle_watch( | |
| message: format!("Failed to create Pub/Sub subscription: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -168,6 +170,7 @@ pub(super) async fn handle_watch( | |
| ), | ||
| reason: "gmailError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -301,6 +304,7 @@ async fn watch_pull_loop( | |
| message: format!("Pub/Sub pull failed: {body}"), | ||
| reason: "pubsubError".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -251,6 +251,7 @@ async fn get_json( | |
| message: body, | ||
| reason: "workflow_request_failed".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }); | ||
| } | ||
|
|
||
|
|
@@ -517,6 +518,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { | |
| message: body, | ||
| reason: "task_create_failed".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -676,6 +678,7 @@ async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { | |
| message: body, | ||
| reason: "chat_send_failed".to_string(), | ||
| enable_url: None, | ||
| retry_after: None, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for extracting the
Retry-Afterheader is duplicated here and incrates/google-workspace-cli/src/helpers/gmail/mod.rs. It should be refactored into a shared helper function to ensure consistency and reduce duplication.