Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]:"));
Expand Down
114 changes: 113 additions & 1 deletion crates/google-workspace-cli/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{HeaderMap, DATE, RETRY_AFTER};
use serde_json::{json, Map, Value};
use tokio::io::AsyncWriteExt;

Expand Down Expand Up @@ -464,6 +466,13 @@ pub async fn execute_method(
.to_string();

if !status.is_success() {
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, retry_after_reference_time));
let error_body = response.text().await.unwrap_or_default();
tracing::warn!(
api_method = method_id,
Expand All @@ -472,7 +481,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!(
Expand Down Expand Up @@ -750,10 +759,36 @@ pub fn extract_enable_url(message: &str) -> Option<String> {
Some(url.to_string())
}

fn parse_retry_after_seconds(value: &str, now: SystemTime) -> Option<u64> {
let value = value.trim();
if value.is_empty() {
return None;
}

if let Ok(seconds) = value.parse::<u64>() {
return Some(seconds);
}

let retry_at = chrono::DateTime::parse_from_rfc2822(value)
.ok()?
.with_timezone(&chrono::Utc);
let now: chrono::DateTime<chrono::Utc> = now.into();
Some((retry_at - now).num_seconds().max(0) as u64)
}

fn parse_retry_after_reference_time(headers: &HeaderMap) -> Option<SystemTime> {
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<T>(
status: reqwest::StatusCode,
error_body: &str,
auth_method: &AuthMethod,
retry_after_seconds: Option<u64>,
) -> 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 {
Expand Down Expand Up @@ -800,6 +835,7 @@ fn handle_error_response<T>(
message,
reason,
enable_url,
retry_after_seconds,
});
}
}
Expand All @@ -809,6 +845,7 @@ fn handle_error_response<T>(
message: error_body.to_string(),
reason: "httpError".to_string(),
enable_url: None,
retry_after_seconds,
})
}

Expand Down Expand Up @@ -1947,6 +1984,7 @@ mod tests {
reqwest::StatusCode::UNAUTHORIZED,
"Unauthorized",
&AuthMethod::None,
None,
)
.unwrap_err();
match err {
Expand All @@ -1973,6 +2011,7 @@ mod tests {
reqwest::StatusCode::UNAUTHORIZED,
&json_err,
&AuthMethod::OAuth,
None,
)
.unwrap_err();
match err {
Expand Down Expand Up @@ -2008,6 +2047,7 @@ mod tests {
reqwest::StatusCode::BAD_REQUEST,
&json_err,
&AuthMethod::OAuth,
None,
)
.unwrap_err();
match err {
Expand All @@ -2024,6 +2064,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]
Expand Down Expand Up @@ -2156,6 +2229,7 @@ fn test_handle_error_response_non_json() {
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error Text",
&AuthMethod::OAuth,
None,
)
.unwrap_err();
match err {
Expand Down Expand Up @@ -2206,6 +2280,42 @@ 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_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
Expand All @@ -2223,6 +2333,7 @@ fn test_handle_error_response_access_not_configured_with_url() {
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
None,
)
.unwrap_err();

Expand Down Expand Up @@ -2260,6 +2371,7 @@ fn test_handle_error_response_access_not_configured_errors_array() {
reqwest::StatusCode::FORBIDDEN,
&json_err,
&AuthMethod::OAuth,
None,
)
.unwrap_err();

Expand Down
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
3 changes: 3 additions & 0 deletions crates/google-workspace-cli/src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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,
});
}

Expand Down
2 changes: 2 additions & 0 deletions crates/google-workspace-cli/src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -3614,6 +3615,7 @@ mod tests {
message,
reason,
enable_url,
..
} => {
assert_eq!(code, 403);
assert!(message.contains("Test context"));
Expand Down
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/helpers/gmail/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
4 changes: 4 additions & 0 deletions crates/google-workspace-cli/src/helpers/gmail/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -168,6 +170,7 @@ pub(super) async fn handle_watch(
),
reason: "gmailError".to_string(),
enable_url: None,
retry_after_seconds: None,
});
}

Expand Down Expand Up @@ -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,
});
}

Expand Down
3 changes: 3 additions & 0 deletions crates/google-workspace-cli/src/helpers/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ async fn get_json(
message: body,
reason: "workflow_request_failed".to_string(),
enable_url: None,
retry_after_seconds: None,
});
}

Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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,
});
}

Expand Down
1 change: 1 addition & 0 deletions crates/google-workspace-cli/src/timezone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
Loading
Loading