feat: add HTTP proxy support via environment variables (#622)

* feat: add HTTP proxy support via environment variables

When http_proxy/https_proxy/all_proxy environment variables are set,
use reqwest (which natively supports proxy) for token refresh instead
of yup-oauth2's hyper-based client (which doesn't support proxy).

This enables gws to work in environments that require HTTP proxy to
access Google APIs (e.g., users in China).

Changes:
- Cargo.toml: Enable reqwest's default features including proxy support
- src/auth.rs: Add proxy-aware token refresh using reqwest as fallback

Fixes #422

* feat: add proxy support for auth login flow

When proxy env vars are set, use a custom OAuth flow with reqwest
for token exchange instead of yup-oauth2's hyper-based client.

Changes to auth_commands.rs:
- Add login_with_proxy_support() for proxy-aware OAuth login
- Add exchange_code_with_reqwest() for token exchange via reqwest
- Detect proxy env vars and choose appropriate flow

* fix: address proxy auth review feedback

* fix: reuse shared reqwest client for auth flows
This commit is contained in:
femto
2026-03-25 17:31:22 +00:00
committed by GitHub
parent 45ca45bfd4
commit a52d297cdf
5 changed files with 524 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
Improve proxy-aware OAuth flows and clean up review feedback for auth login.
+1 -1
View File
@@ -38,7 +38,7 @@ clap = { version = "4", features = ["derive", "string"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots"], default-features = false }
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+118 -2
View File
@@ -21,9 +21,65 @@
use std::path::PathBuf;
use anyhow::Context;
use serde::Deserialize;
use crate::credential_store;
const PROXY_ENV_VARS: &[&str] = &[
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
];
/// Response from Google's token endpoint
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[allow(dead_code)]
expires_in: u64,
#[allow(dead_code)]
token_type: String,
}
/// Refresh an access token using reqwest (supports HTTP proxy via environment variables).
/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues.
async fn refresh_token_with_reqwest(
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> anyhow::Result<String> {
let client = crate::client::shared_client().map_err(anyhow::Error::from)?;
let params = [
("client_id", client_id),
("client_secret", client_secret),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
let response = client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.context("Failed to send token refresh request")?;
if !response.status().is_success() {
let status = response.status();
let body = response_text_or_placeholder(response.text().await);
anyhow::bail!("Token refresh failed with status {}: {}", status, body);
}
let token_response: TokenResponse = response
.json()
.await
.context("Failed to parse token response")?;
Ok(token_response.access_token)
}
/// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header).
///
/// Priority:
@@ -173,14 +229,37 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
get_token_inner(scopes, creds, &token_cache).await
}
/// Check if HTTP proxy environment variables are set
pub(crate) fn has_proxy_env() -> bool {
PROXY_ENV_VARS
.iter()
.any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
}
pub(crate) fn response_text_or_placeholder<E>(result: Result<String, E>) -> String {
result.unwrap_or_else(|_| "(could not read error response body)".to_string())
}
async fn get_token_inner(
scopes: &[&str],
creds: Credential,
token_cache_path: &std::path::Path,
) -> anyhow::Result<String> {
match creds {
Credential::AuthorizedUser(secret) => {
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret)
Credential::AuthorizedUser(ref secret) => {
// If proxy env vars are set, use reqwest directly (it supports proxy)
// This avoids waiting for yup-oauth2's hyper client to timeout
if has_proxy_env() {
return refresh_token_with_reqwest(
&secret.client_id,
&secret.client_secret,
&secret.refresh_token,
)
.await;
}
// No proxy - use yup-oauth2 (faster, has token caching)
let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone())
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
token_cache_path.to_path_buf(),
)))
@@ -398,6 +477,43 @@ mod tests {
}
}
fn clear_proxy_env() -> Vec<EnvVarGuard> {
PROXY_ENV_VARS
.iter()
.map(|key| EnvVarGuard::remove(key))
.collect()
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_false_when_unset() {
let _guards = clear_proxy_env();
assert!(!has_proxy_env());
}
#[test]
#[serial_test::serial]
fn has_proxy_env_returns_true_when_set() {
let mut guards = clear_proxy_env();
guards.push(EnvVarGuard::set(
"HTTPS_PROXY",
"http://proxy.internal:8080",
));
assert!(has_proxy_env());
}
#[test]
fn response_text_or_placeholder_returns_body() {
let body = response_text_or_placeholder(Result::<String, ()>::Ok("error body".to_string()));
assert_eq!(body, "error body");
}
#[test]
fn response_text_or_placeholder_returns_placeholder_on_error() {
let body = response_text_or_placeholder(Result::<String, ()>::Err(()));
assert_eq!(body, "(could not read error response body)");
}
#[tokio::test]
#[serial_test::serial]
async fn test_load_credentials_no_options() {
+363 -148
View File
@@ -13,13 +13,228 @@
// limitations under the License.
use std::collections::HashSet;
use std::path::PathBuf;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use crate::credential_store;
use crate::error::GwsError;
/// Response from Google's token endpoint
#[derive(Debug, Deserialize)]
struct OAuthTokenResponse {
access_token: String,
refresh_token: Option<String>,
#[allow(dead_code)]
expires_in: u64,
#[allow(dead_code)]
token_type: String,
}
/// Exchange authorization code for tokens using reqwest (supports HTTP proxy)
async fn exchange_code_with_reqwest(
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: &str,
) -> Result<OAuthTokenResponse, GwsError> {
let client = crate::client::shared_client()?;
let params = [
("client_id", client_id),
("client_secret", client_secret),
("code", code),
("redirect_uri", redirect_uri),
("grant_type", "authorization_code"),
];
let response = client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.map_err(|e| GwsError::Auth(format!("Failed to send token request: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = crate::auth::response_text_or_placeholder(response.text().await);
return Err(GwsError::Auth(format!(
"Token exchange failed with status {}: {}",
status, body
)));
}
response
.json()
.await
.map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}")))
}
fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String {
let scopes_str = scopes.join(" ");
format!(
"https://accounts.google.com/o/oauth2/auth?\
scope={}&\
access_type=offline&\
redirect_uri={}&\
response_type=code&\
client_id={}&\
prompt=select_account+consent",
urlencoding(&scopes_str),
urlencoding(redirect_uri),
urlencoding(client_id)
)
}
fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
let path = request_line
.split_whitespace()
.nth(1)
.ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?;
path.split('?')
.nth(1)
.and_then(|query| {
query.split('&').find_map(|pair| {
let mut parts = pair.split('=');
if parts.next() == Some("code") {
parts.next().map(|value| value.to_string())
} else {
None
}
})
})
.ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string()))
}
/// Perform OAuth login flow with proxy support using reqwest for token exchange
async fn login_with_proxy_support(
client_id: &str,
client_secret: &str,
scopes: &[String],
) -> Result<(String, String), GwsError> {
// Start local server to receive OAuth callback
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?;
let port = listener
.local_addr()
.map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))?
.port();
let redirect_uri = format!("http://localhost:{}", port);
let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes);
println!("Open this URL in your browser to authenticate:\n");
println!(" {}\n", auth_url);
// Wait for OAuth callback
let (mut stream, _) = listener
.accept()
.map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?;
let mut reader = BufReader::new(&stream);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?;
let code = extract_authorization_code(&request_line)?;
// Send success response to browser
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
<html><body><h1>Success!</h1><p>You may now close this window.</p></body></html>";
let _ = stream.write_all(response.as_bytes());
// Exchange code for tokens using reqwest (proxy-aware)
let token_response =
exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?;
let refresh_token = token_response.refresh_token.ok_or_else(|| {
GwsError::Auth(
"OAuth flow completed but no refresh token was returned. \
Ensure the OAuth consent screen includes 'offline' access."
.to_string(),
)
})?;
Ok((token_response.access_token, refresh_token))
}
fn read_refresh_token_from_cache(temp_path: &Path) -> Result<String, GwsError> {
let token_data = std::fs::read(temp_path)
.ok()
.and_then(|bytes| crate::credential_store::decrypt(&bytes).ok())
.and_then(|decrypted| String::from_utf8(decrypted).ok())
.unwrap_or_default();
extract_refresh_token(&token_data).ok_or_else(|| {
GwsError::Auth(
"OAuth flow completed but no refresh token was returned. \
Ensure the OAuth consent screen includes 'offline' access."
.to_string(),
)
})
}
async fn login_with_yup_oauth(
config_dir: &Path,
client_id: &str,
client_secret: &str,
scopes: &[String],
) -> Result<(String, String), GwsError> {
let secret = yup_oauth2::ApplicationSecret {
client_id: client_id.to_string(),
client_secret: client_secret.to_string(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
redirect_uris: vec!["http://localhost".to_string()],
..Default::default()
};
let temp_path = config_dir.join("credentials.tmp");
let _ = std::fs::remove_file(&temp_path);
let result = async {
let auth = yup_oauth2::InstalledFlowAuthenticator::builder(
secret,
yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect,
)
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
temp_path.clone(),
)))
.force_account_selection(true)
.flow_delegate(Box::new(CliFlowDelegate { login_hint: None }))
.build()
.await
.map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let token = auth
.token(&scope_refs)
.await
.map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?;
let access_token = token
.token()
.ok_or_else(|| GwsError::Auth("No access token returned".to_string()))?
.to_string();
let refresh_token = read_refresh_token_from_cache(&temp_path)?;
Ok((access_token, refresh_token))
}
.await;
let _ = std::fs::remove_file(&temp_path);
result
}
/// Simple URL encoding
fn urlencoding(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
/// Mask a secret string by showing only the first 4 and last 4 characters.
/// Strings with 8 or fewer characters are fully replaced with "***".
///
@@ -383,15 +598,6 @@ async fn handle_login_inner(
// Remove restrictive scopes when broader alternatives are present.
let mut scopes = filter_redundant_restrictive_scopes(scopes);
let secret = yup_oauth2::ApplicationSecret {
client_id: client_id.clone(),
client_secret: client_secret.clone(),
auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
token_uri: "https://oauth2.googleapis.com/token".to_string(),
redirect_uris: vec!["http://localhost".to_string()],
..Default::default()
};
// Ensure openid + email + profile scopes are always present so we can
// identify the user via the userinfo endpoint after login, and so the
// Gmail helpers can fall back to the People API to populate the From
@@ -407,96 +613,50 @@ async fn handle_login_inner(
}
}
// Use a temp file for yup-oauth2's token persistence, then encrypt it
let temp_path = config_dir().join("credentials.tmp");
// Always start fresh — delete any stale temp cache from prior login attempts.
let _ = std::fs::remove_file(&temp_path);
// Ensure config directory exists
if let Some(parent) = temp_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?;
}
let config = config_dir();
std::fs::create_dir_all(&config)
.map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?;
let auth = yup_oauth2::InstalledFlowAuthenticator::builder(
secret,
yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect,
)
.with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
temp_path.clone(),
)))
.force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token
.flow_delegate(Box::new(CliFlowDelegate { login_hint: None }))
.build()
.await
.map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;
// Request a token — this triggers the browser OAuth flow
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let token = auth
.token(&scope_refs)
.await
.map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?;
if token.token().is_some() {
// Read yup-oauth2's token cache to extract the refresh_token.
// EncryptedTokenStorage stores data encrypted, so we must decrypt first.
let token_data = std::fs::read(&temp_path)
.ok()
.and_then(|bytes| crate::credential_store::decrypt(&bytes).ok())
.and_then(|decrypted| String::from_utf8(decrypted).ok())
.unwrap_or_default();
let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| {
GwsError::Auth(
"OAuth flow completed but no refresh token was returned. \
Ensure the OAuth consent screen includes 'offline' access."
.to_string(),
)
})?;
// Build credentials in the standard authorized_user format
let creds_json = json!({
"type": "authorized_user",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
});
let creds_str = serde_json::to_string_pretty(&creds_json)
.map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;
// Fetch the user's email from Google userinfo
let access_token = token.token().unwrap_or_default();
let actual_email = fetch_userinfo_email(access_token).await;
// Save encrypted credentials
let enc_path = credential_store::save_encrypted(&creds_str)
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
let output = json!({
"status": "success",
"message": "Authentication successful. Encrypted credentials saved.",
"account": actual_email.as_deref().unwrap_or("(unknown)"),
"credentials_file": enc_path.display().to_string(),
"encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)",
"scopes": scopes,
});
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
Ok(())
// If proxy env vars are set, use proxy-aware OAuth flow (reqwest)
// Otherwise use yup-oauth2 (faster, but doesn't support proxy)
let (access_token, refresh_token) = if crate::auth::has_proxy_env() {
login_with_proxy_support(&client_id, &client_secret, &scopes).await?
} else {
// Clean up temp file on failure
let _ = std::fs::remove_file(&temp_path);
Err(GwsError::Auth(
"OAuth flow completed but no token was returned.".to_string(),
))
}
login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await?
};
// Build credentials in the standard authorized_user format
let creds_json = json!({
"type": "authorized_user",
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
});
let creds_str = serde_json::to_string_pretty(&creds_json)
.map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;
// Fetch the user's email from Google userinfo
let actual_email = fetch_userinfo_email(&access_token).await;
// Save encrypted credentials
let enc_path = credential_store::save_encrypted(&creds_str)
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
let output = json!({
"status": "success",
"message": "Authentication successful. Encrypted credentials saved.",
"account": actual_email.as_deref().unwrap_or("(unknown)"),
"credentials_file": enc_path.display().to_string(),
"encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)",
"scopes": scopes,
});
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_default()
);
Ok(())
}
/// Fetch the authenticated user's email from Google's userinfo endpoint.
@@ -1203,68 +1363,71 @@ async fn handle_status() -> Result<(), GwsError> {
if let (Some(cid), Some(csec), Some(rt)) = (client_id, client_secret, refresh_token)
{
// Exchange refresh token for access token
let http_client = reqwest::Client::new();
let token_resp = http_client
.post("https://oauth2.googleapis.com/token")
.form(&[
("client_id", cid),
("client_secret", csec),
("refresh_token", rt),
("grant_type", "refresh_token"),
])
.send()
.await;
if let Ok(http_client) = crate::client::shared_client() {
let token_resp = http_client
.post("https://oauth2.googleapis.com/token")
.form(&[
("client_id", cid),
("client_secret", csec),
("refresh_token", rt),
("grant_type", "refresh_token"),
])
.send()
.await;
if let Ok(resp) = token_resp {
if let Ok(token_json) = resp.json::<serde_json::Value>().await {
if let Some(access_token) =
token_json.get("access_token").and_then(|v| v.as_str())
{
output["token_valid"] = json!(true);
// Get user info
if let Ok(user_resp) = http_client
.get("https://www.googleapis.com/oauth2/v1/userinfo")
.bearer_auth(access_token)
.send()
.await
if let Ok(resp) = token_resp {
if let Ok(token_json) = resp.json::<serde_json::Value>().await {
if let Some(access_token) =
token_json.get("access_token").and_then(|v| v.as_str())
{
if let Ok(user_json) =
user_resp.json::<serde_json::Value>().await
output["token_valid"] = json!(true);
// Get user info
if let Ok(user_resp) = http_client
.get("https://www.googleapis.com/oauth2/v1/userinfo")
.bearer_auth(access_token)
.send()
.await
{
if let Some(email) =
user_json.get("email").and_then(|v| v.as_str())
if let Ok(user_json) =
user_resp.json::<serde_json::Value>().await
{
output["user"] = json!(email);
if let Some(email) =
user_json.get("email").and_then(|v| v.as_str())
{
output["user"] = json!(email);
}
}
}
}
// Get granted scopes via tokeninfo
let tokeninfo_url = format!(
"https://oauth2.googleapis.com/tokeninfo?access_token={}",
access_token
);
if let Ok(info_resp) = http_client.get(&tokeninfo_url).send().await
{
if let Ok(info_json) =
info_resp.json::<serde_json::Value>().await
// Get granted scopes via tokeninfo
let tokeninfo_url = format!(
"https://oauth2.googleapis.com/tokeninfo?access_token={}",
access_token
);
if let Ok(info_resp) =
http_client.get(&tokeninfo_url).send().await
{
if let Some(scope_str) =
info_json.get("scope").and_then(|v| v.as_str())
if let Ok(info_json) =
info_resp.json::<serde_json::Value>().await
{
let scopes: Vec<&str> = scope_str.split(' ').collect();
output["scopes"] = json!(scopes);
output["scope_count"] = json!(scopes.len());
if let Some(scope_str) =
info_json.get("scope").and_then(|v| v.as_str())
{
let scopes: Vec<&str> =
scope_str.split(' ').collect();
output["scopes"] = json!(scopes);
output["scope_count"] = json!(scopes.len());
}
}
}
}
} else {
output["token_valid"] = json!(false);
if let Some(err) =
token_json.get("error_description").and_then(|v| v.as_str())
{
output["token_error"] = json!(err);
} else {
output["token_valid"] = json!(false);
if let Some(err) =
token_json.get("error_description").and_then(|v| v.as_str())
{
output["token_error"] = json!(err);
}
}
}
}
@@ -2317,4 +2480,56 @@ mod tests {
let result = extract_scopes_from_doc(&doc, false);
assert!(result.is_empty());
}
#[test]
fn build_proxy_auth_url_encodes_scope_and_redirect_uri() {
let scopes = vec![
"https://www.googleapis.com/auth/drive".to_string(),
"openid".to_string(),
];
let url = build_proxy_auth_url("client id", "http://localhost:8080/callback path", &scopes);
assert!(url.contains("client_id=client%20id"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback%20path"));
assert!(url.contains(&format!(
"scope={}",
urlencoding("https://www.googleapis.com/auth/drive openid")
)));
}
#[test]
fn extract_authorization_code_returns_code() {
let code =
extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1")
.unwrap();
assert_eq!(code, "4/test-code");
}
#[test]
fn extract_authorization_code_rejects_missing_code() {
let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err();
assert!(err.to_string().contains("No authorization code"));
}
#[test]
fn read_refresh_token_from_cache_reads_encrypted_storage() {
let token_data = r#"[{"token":{"refresh_token":"1//refresh-token"}}]"#;
let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap();
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, &encrypted).unwrap();
let refresh_token = read_refresh_token_from_cache(file.path()).unwrap();
assert_eq!(refresh_token, "1//refresh-token");
}
#[test]
fn read_refresh_token_from_cache_requires_refresh_token() {
let token_data = r#"[{"token":{"access_token":"ya29.no-refresh"}}]"#;
let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap();
let mut file = tempfile::NamedTempFile::new().unwrap();
std::io::Write::write_all(&mut file, &encrypted).unwrap();
let err = read_refresh_token_from_cache(file.path()).unwrap_err();
assert!(err.to_string().contains("no refresh token was returned"));
}
}
+37 -4
View File
@@ -14,6 +14,8 @@
//! HTTP client with retry logic for Google API requests.
use std::sync::OnceLock;
use reqwest::header::{HeaderMap, HeaderValue};
const MAX_RETRIES: u32 = 3;
@@ -22,7 +24,7 @@ const MAX_RETRIES: u32 = 3;
const MAX_RETRY_DELAY_SECS: u64 = 60;
const CONNECT_TIMEOUT_SECS: u64 = 10;
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
fn build_client_inner() -> Result<reqwest::Client, String> {
let mut headers = HeaderMap::new();
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
@@ -37,9 +39,26 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
.default_headers(headers)
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
.build()
.map_err(|e| {
crate::error::GwsError::Other(anyhow::anyhow!("Failed to build HTTP client: {e}"))
})
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message)))
}
/// Returns a shared reqwest client clone backed by a single global connection pool.
///
/// `reqwest::Client` is cheap to clone, so callers can take ownership of the
/// returned value while still sharing pooled connections underneath.
pub fn shared_client() -> Result<reqwest::Client, crate::error::GwsError> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
match CLIENT.get_or_init(build_client_inner) {
Ok(client) => Ok(client.clone()),
Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!(
message.clone()
))),
}
}
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
@@ -100,6 +119,20 @@ mod tests {
assert!(build_client().is_ok());
}
#[test]
fn shared_client_succeeds() {
assert!(shared_client().is_ok());
}
#[test]
fn shared_client_can_be_reused() {
let client_a = shared_client().unwrap();
let client_b = shared_client().unwrap();
let request_a = client_a.get("https://example.com").build().unwrap();
let request_b = client_b.get("https://example.com").build().unwrap();
assert_eq!(request_a.url(), request_b.url());
}
#[test]
fn retry_delay_caps_large_header_value() {
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);