From 499e0b39ae5ed1872a0ad26e85229be4a4d36616 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 10:20:30 +0800 Subject: [PATCH 1/3] feat(error): surface retry-after header --- crates/google-workspace-cli/src/error.rs | 1 + crates/google-workspace-cli/src/executor.rs | 87 ++++++++++++++++++- .../src/helpers/calendar.rs | 1 + .../src/helpers/events/subscribe.rs | 3 + .../src/helpers/gmail/mod.rs | 2 + .../src/helpers/gmail/triage.rs | 1 + .../src/helpers/gmail/watch.rs | 4 + .../src/helpers/workflows.rs | 3 + crates/google-workspace-cli/src/timezone.rs | 1 + crates/google-workspace/src/error.rs | 24 +++++ 10 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace-cli/src/error.rs b/crates/google-workspace-cli/src/error.rs index b5b389bd..ae0ae0d0 100644 --- a/crates/google-workspace-cli/src/error.rs +++ b/crates/google-workspace-cli/src/error.rs @@ -120,6 +120,7 @@ mod tests { message: "bad".to_string(), reason: "r".to_string(), enable_url: None, + retry_after_seconds: None, }; let label = error_label(&api_err); assert!(label.contains("error[api]:")); diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 46f31ac4..6d2fa371 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -20,10 +20,12 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::time::SystemTime; use anyhow::Context; use futures_util::stream::TryStreamExt; use futures_util::StreamExt; +use reqwest::header::RETRY_AFTER; use serde_json::{json, Map, Value}; use tokio::io::AsyncWriteExt; @@ -464,6 +466,11 @@ pub async fn execute_method( .to_string(); if !status.is_success() { + let retry_after_seconds = response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| parse_retry_after_seconds(value, SystemTime::now())); let error_body = response.text().await.unwrap_or_default(); tracing::warn!( api_method = method_id, @@ -472,7 +479,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_seconds); } tracing::debug!( @@ -750,10 +757,28 @@ pub fn extract_enable_url(message: &str) -> Option { Some(url.to_string()) } +fn parse_retry_after_seconds(value: &str, now: SystemTime) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Ok(seconds) = value.parse::() { + return Some(seconds); + } + + let retry_at = chrono::DateTime::parse_from_rfc2822(value) + .ok()? + .with_timezone(&chrono::Utc); + let now: chrono::DateTime = now.into(); + Some((retry_at - now).num_seconds().max(0) as u64) +} + fn handle_error_response( status: reqwest::StatusCode, error_body: &str, auth_method: &AuthMethod, + retry_after_seconds: Option, ) -> Result { // 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 +825,7 @@ fn handle_error_response( message, reason, enable_url, + retry_after_seconds, }); } } @@ -809,6 +835,7 @@ fn handle_error_response( message: error_body.to_string(), reason: "httpError".to_string(), enable_url: None, + retry_after_seconds, }) } @@ -1947,6 +1974,7 @@ mod tests { reqwest::StatusCode::UNAUTHORIZED, "Unauthorized", &AuthMethod::None, + None, ) .unwrap_err(); match err { @@ -1973,6 +2001,7 @@ mod tests { reqwest::StatusCode::UNAUTHORIZED, &json_err, &AuthMethod::OAuth, + None, ) .unwrap_err(); match err { @@ -2008,6 +2037,7 @@ mod tests { reqwest::StatusCode::BAD_REQUEST, &json_err, &AuthMethod::OAuth, + None, ) .unwrap_err(); match err { @@ -2024,6 +2054,39 @@ mod tests { _ => panic!("Expected Api error"), } } + + #[test] + fn test_handle_error_response_includes_retry_after() { + let json_err = json!({ + "error": { + "code": 429, + "message": "Rate limit exceeded", + "errors": [{ "reason": "rateLimitExceeded" }] + } + }) + .to_string(); + + let err = handle_error_response::<()>( + reqwest::StatusCode::TOO_MANY_REQUESTS, + &json_err, + &AuthMethod::OAuth, + Some(17), + ) + .unwrap_err(); + match err { + GwsError::Api { + code, + reason, + retry_after_seconds, + .. + } => { + assert_eq!(code, 429); + assert_eq!(reason, "rateLimitExceeded"); + assert_eq!(retry_after_seconds, Some(17)); + } + _ => panic!("Expected Api error"), + } + } } #[tokio::test] @@ -2156,6 +2219,7 @@ fn test_handle_error_response_non_json() { reqwest::StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error Text", &AuthMethod::OAuth, + None, ) .unwrap_err(); match err { @@ -2206,6 +2270,25 @@ fn test_extract_enable_url_trims_trailing_punctuation() { ); } +#[test] +fn test_parse_retry_after_seconds_delta() { + assert_eq!( + parse_retry_after_seconds("17", std::time::UNIX_EPOCH), + Some(17) + ); +} + +#[test] +fn test_parse_retry_after_seconds_http_date() { + assert_eq!( + parse_retry_after_seconds( + "Wed, 21 Oct 2015 07:28:00 GMT", + std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_445_412_400), + ), + Some(80) + ); +} + #[test] fn test_handle_error_response_access_not_configured_with_url() { // Matches the top-level "reason" field format Google actually returns for this error @@ -2223,6 +2306,7 @@ fn test_handle_error_response_access_not_configured_with_url() { reqwest::StatusCode::FORBIDDEN, &json_err, &AuthMethod::OAuth, + None, ) .unwrap_err(); @@ -2260,6 +2344,7 @@ fn test_handle_error_response_access_not_configured_errors_array() { reqwest::StatusCode::FORBIDDEN, &json_err, &AuthMethod::OAuth, + None, ) .unwrap_err(); diff --git a/crates/google-workspace-cli/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs index cf28b249..63d0ceb1 100644 --- a/crates/google-workspace-cli/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -274,6 +274,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { message: err, reason: "calendarList_failed".to_string(), enable_url: None, + retry_after_seconds: None, }); } diff --git a/crates/google-workspace-cli/src/helpers/events/subscribe.rs b/crates/google-workspace-cli/src/helpers/events/subscribe.rs index 764aeaac..5beaaff2 100644 --- a/crates/google-workspace-cli/src/helpers/events/subscribe.rs +++ b/crates/google-workspace-cli/src/helpers/events/subscribe.rs @@ -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_seconds: None, }); } @@ -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_seconds: 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_seconds: None, }); } diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index caeb8b6b..98790b2e 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -438,6 +438,7 @@ pub(super) fn build_api_error(status: u16, body: &str, context: &str) -> GwsErro message: format!("{context}: {message}"), reason, enable_url, + retry_after_seconds: None, } } @@ -3614,6 +3615,7 @@ mod tests { message, reason, enable_url, + .. } => { assert_eq!(code, 403); assert!(message.contains("Test context")); diff --git a/crates/google-workspace-cli/src/helpers/gmail/triage.rs b/crates/google-workspace-cli/src/helpers/gmail/triage.rs index 3c275e1f..aea2bacf 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/triage.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/triage.rs @@ -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_seconds: None, }); } diff --git a/crates/google-workspace-cli/src/helpers/gmail/watch.rs b/crates/google-workspace-cli/src/helpers/gmail/watch.rs index 027446fd..b628381a 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/watch.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/watch.rs @@ -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_seconds: None, }); } @@ -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_seconds: None, }); } @@ -168,6 +170,7 @@ pub(super) async fn handle_watch( ), reason: "gmailError".to_string(), enable_url: None, + retry_after_seconds: 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_seconds: None, }); } diff --git a/crates/google-workspace-cli/src/helpers/workflows.rs b/crates/google-workspace-cli/src/helpers/workflows.rs index b921d864..e9fd6978 100644 --- a/crates/google-workspace-cli/src/helpers/workflows.rs +++ b/crates/google-workspace-cli/src/helpers/workflows.rs @@ -251,6 +251,7 @@ async fn get_json( message: body, reason: "workflow_request_failed".to_string(), enable_url: None, + retry_after_seconds: None, }); } @@ -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_seconds: 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_seconds: None, }); } diff --git a/crates/google-workspace-cli/src/timezone.rs b/crates/google-workspace-cli/src/timezone.rs index b7cd6577..251cbc46 100644 --- a/crates/google-workspace-cli/src/timezone.rs +++ b/crates/google-workspace-cli/src/timezone.rs @@ -92,6 +92,7 @@ async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result message: body, reason: "timezone_fetch_failed".to_string(), enable_url: None, + retry_after_seconds: None, }); } diff --git a/crates/google-workspace/src/error.rs b/crates/google-workspace/src/error.rs index 71ae0390..ece2c093 100644 --- a/crates/google-workspace/src/error.rs +++ b/crates/google-workspace/src/error.rs @@ -26,6 +26,8 @@ pub enum GwsError { reason: String, /// For `accessNotConfigured` errors: the GCP console URL to enable the API. enable_url: Option, + /// Seconds to wait before retrying, when the API returned a Retry-After header. + retry_after_seconds: Option, }, #[error("{0}")] @@ -71,6 +73,7 @@ impl GwsError { message, reason, enable_url, + retry_after_seconds, } => { let mut error_obj = json!({ "code": code, @@ -80,6 +83,9 @@ impl GwsError { if let Some(url) = enable_url { error_obj["enable_url"] = json!(url); } + if let Some(seconds) = retry_after_seconds { + error_obj["retry_after_seconds"] = json!(seconds); + } json!({ "error": error_obj }) } GwsError::Validation(msg) => json!({ @@ -125,6 +131,7 @@ mod tests { message: "Not Found".to_string(), reason: "notFound".to_string(), enable_url: None, + retry_after_seconds: None, }; assert_eq!(err.exit_code(), GwsError::EXIT_CODE_API); } @@ -185,6 +192,7 @@ mod tests { message: "Not Found".to_string(), reason: "notFound".to_string(), enable_url: None, + retry_after_seconds: None, }; let json = err.to_json(); assert_eq!(json["error"]["code"], 404); @@ -236,6 +244,7 @@ mod tests { message: "Gmail API has not been used in project 549352339482 before or it is disabled.".to_string(), reason: "accessNotConfigured".to_string(), enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()), + retry_after_seconds: None, }; let json = err.to_json(); assert_eq!(json["error"]["code"], 403); @@ -253,10 +262,25 @@ mod tests { message: "API not enabled.".to_string(), reason: "accessNotConfigured".to_string(), enable_url: None, + retry_after_seconds: None, }; let json = err.to_json(); assert_eq!(json["error"]["code"], 403); assert_eq!(json["error"]["reason"], "accessNotConfigured"); assert!(json["error"]["enable_url"].is_null()); } + + #[test] + fn test_error_to_json_retry_after_seconds() { + let err = GwsError::Api { + code: 429, + message: "Rate limit exceeded.".to_string(), + reason: "rateLimitExceeded".to_string(), + enable_url: None, + retry_after_seconds: Some(17), + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 429); + assert_eq!(json["error"]["retry_after_seconds"], 17); + } } From 18434ee26baa2871aab124dabbbb23548a04475c Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 10:42:43 +0800 Subject: [PATCH 2/3] fix(error): use response date for retry-after --- crates/google-workspace-cli/src/executor.rs | 35 ++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/google-workspace-cli/src/executor.rs b/crates/google-workspace-cli/src/executor.rs index 6d2fa371..4d09ac44 100644 --- a/crates/google-workspace-cli/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -25,7 +25,7 @@ use std::time::SystemTime; use anyhow::Context; use futures_util::stream::TryStreamExt; use futures_util::StreamExt; -use reqwest::header::RETRY_AFTER; +use reqwest::header::{HeaderMap, DATE, RETRY_AFTER}; use serde_json::{json, Map, Value}; use tokio::io::AsyncWriteExt; @@ -466,11 +466,13 @@ pub async fn execute_method( .to_string(); if !status.is_success() { - let retry_after_seconds = response - .headers() + let headers = response.headers(); + let retry_after_reference_time = + parse_retry_after_reference_time(headers).unwrap_or_else(SystemTime::now); + let retry_after_seconds = headers .get(RETRY_AFTER) .and_then(|value| value.to_str().ok()) - .and_then(|value| parse_retry_after_seconds(value, SystemTime::now())); + .and_then(|value| parse_retry_after_seconds(value, retry_after_reference_time)); let error_body = response.text().await.unwrap_or_default(); tracing::warn!( api_method = method_id, @@ -774,6 +776,14 @@ fn parse_retry_after_seconds(value: &str, now: SystemTime) -> Option { Some((retry_at - now).num_seconds().max(0) as u64) } +fn parse_retry_after_reference_time(headers: &HeaderMap) -> Option { + let value = headers.get(DATE)?.to_str().ok()?; + let date = chrono::DateTime::parse_from_rfc2822(value) + .ok()? + .with_timezone(&chrono::Utc); + Some(date.into()) +} + fn handle_error_response( status: reqwest::StatusCode, error_body: &str, @@ -2289,6 +2299,23 @@ fn test_parse_retry_after_seconds_http_date() { ); } +#[test] +fn test_parse_retry_after_reference_time_uses_response_date() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::DATE, + reqwest::header::HeaderValue::from_static("Wed, 21 Oct 2015 07:26:40 GMT"), + ); + + assert_eq!( + parse_retry_after_seconds( + "Wed, 21 Oct 2015 07:28:00 GMT", + parse_retry_after_reference_time(&headers).unwrap(), + ), + Some(80) + ); +} + #[test] fn test_handle_error_response_access_not_configured_with_url() { // Matches the top-level "reason" field format Google actually returns for this error From 542eddb2f104fdd064c657939acdfb783e4ed7f5 Mon Sep 17 00:00:00 2001 From: Lubrsy706 Date: Fri, 15 May 2026 10:50:38 +0800 Subject: [PATCH 3/3] feat(error): show retry-after in display --- crates/google-workspace/src/error.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/google-workspace/src/error.rs b/crates/google-workspace/src/error.rs index ece2c093..4c40679a 100644 --- a/crates/google-workspace/src/error.rs +++ b/crates/google-workspace/src/error.rs @@ -19,7 +19,7 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum GwsError { - #[error("{message}")] + #[error("{message}{}", retry_after_display(*retry_after_seconds))] Api { code: u16, message: String, @@ -43,6 +43,12 @@ pub enum GwsError { Other(#[from] anyhow::Error), } +fn retry_after_display(retry_after_seconds: Option) -> String { + retry_after_seconds + .map(|seconds| format!(" (retry after {seconds}s)")) + .unwrap_or_default() +} + impl GwsError { /// Exit code for [`GwsError::Api`] variants. pub const EXIT_CODE_API: i32 = 1; @@ -283,4 +289,17 @@ mod tests { assert_eq!(json["error"]["code"], 429); assert_eq!(json["error"]["retry_after_seconds"], 17); } + + #[test] + fn test_api_display_includes_retry_after_seconds() { + let err = GwsError::Api { + code: 429, + message: "Rate limit exceeded.".to_string(), + reason: "rateLimitExceeded".to_string(), + enable_url: None, + retry_after_seconds: Some(17), + }; + + assert_eq!(err.to_string(), "Rate limit exceeded. (retry after 17s)"); + } }