fix(client): add 10s connect timeout to prevent initial hangs (#608)

* fix(client): add 10s connect timeout to prevent initial hangs

* fix(client): retry on transient connect/timeout errors

* chore: regenerate skills [skip ci]

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-24 12:49:23 -06:00
committed by GitHub
parent 75a7121713
commit b8fd3d966f
3 changed files with 43 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---
fix(client): add 10s connect timeout to prevent hangs on initial connection
+4 -4
View File
@@ -70,19 +70,19 @@ gws drive <resource> <method> [flags]
- `create` — Creates a shared drive. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
- `get` — Gets a shared drive's metadata by ID. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
- `hide` — Hides a shared drive from the default view. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
- `list` — Lists the user's shared drives. This method accepts the `q` parameter, which is a search query combining one or more search terms. For more information, see the [Search for shared drives](/workspace/drive/api/guides/search-shareddrives) guide.
- `list` — Lists the user's shared drives. This method accepts the `q` parameter, which is a search query combining one or more search terms. For more information, see the [Search for shared drives](https://developers.google.com/workspace/drive/api/guides/search-shareddrives) guide.
- `unhide` — Restores a shared drive to the default view. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
- `update` — Updates the metadata for a shared drive. For more information, see [Manage shared drives](https://developers.google.com/workspace/drive/api/guides/manage-shareddrives).
### files
- `copy` — Creates a copy of a file and applies any requested updates with patch semantics. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file).
- `create` — Creates a file. For more information, see [Create and manage files](/workspace/drive/api/guides/create-file). This method supports an */upload* URI and accepts uploaded media with the following characteristics: - *Maximum file size:* 5,120 GB - *Accepted Media MIME types:* `*/*` (Specify a valid MIME type, rather than the literal `*/*` value. The literal `*/*` is only used to indicate that any valid MIME type can be uploaded.
- `create` — Creates a file. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file). This method supports an */upload* URI and accepts uploaded media with the following characteristics: - *Maximum file size:* 5,120 GB - *Accepted Media MIME types:* `*/*` (Specify a valid MIME type, rather than the literal `*/*` value. The literal `*/*` is only used to indicate that any valid MIME type can be uploaded.
- `download` — Downloads the content of a file. For more information, see [Download and export files](https://developers.google.com/workspace/drive/api/guides/manage-downloads). Operations are valid for 24 hours from the time of creation.
- `export` — Exports a Google Workspace document to the requested MIME type and returns exported byte content. For more information, see [Download and export files](https://developers.google.com/workspace/drive/api/guides/manage-downloads). Note that the exported content is limited to 10 MB.
- `generateIds` — Generates a set of file IDs which can be provided in create or copy requests. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file).
- `get` — Gets a file's metadata or content by ID. For more information, see [Search for files and folders](/workspace/drive/api/guides/search-files). If you provide the URL parameter `alt=media`, then the response includes the file contents in the response body. Downloading content with `alt=media` only works if the file is stored in Drive. To download Google Docs, Sheets, and Slides use [`files.export`](/workspace/drive/api/reference/rest/v3/files/export) instead.
- `list` — Lists the user's files. For more information, see [Search for files and folders](/workspace/drive/api/guides/search-files). This method accepts the `q` parameter, which is a search query combining one or more search terms. This method returns *all* files by default, including trashed files. If you don't want trashed files to appear in the list, use the `trashed=false` query parameter to remove trashed files from the results.
- `get` — Gets a file's metadata or content by ID. For more information, see [Search for files and folders](https://developers.google.com/workspace/drive/api/guides/search-files). If you provide the URL parameter `alt=media`, then the response includes the file contents in the response body. Downloading content with `alt=media` only works if the file is stored in Drive.
- `list` — Lists the user's files. For more information, see [Search for files and folders](https://developers.google.com/workspace/drive/api/guides/search-files). This method accepts the `q` parameter, which is a search query combining one or more search terms. This method returns *all* files by default, including trashed files. If you don't want trashed files to appear in the list, use the `trashed=false` query parameter to remove trashed files from the results.
- `listLabels` — Lists the labels on a file. For more information, see [List labels on a file](https://developers.google.com/workspace/drive/api/guides/list-labels).
- `modifyLabels` — Modifies the set of labels applied to a file. For more information, see [Set a label field on a file](https://developers.google.com/workspace/drive/api/guides/set-label). Returns a list of the labels that were added or modified.
- `update` — Updates a file's metadata, content, or both. When calling this method, only populate fields in the request that you want to modify. When updating fields, some fields might be changed automatically, such as `modifiedDate`. This method supports patch semantics. This method supports an */upload* URI and accepts uploaded media with the following characteristics: - *Maximum file size:* 5,120 GB - *Accepted Media MIME types:* `*/*` (Specify a valid MIME type, rather than the literal `*/*` value.
+34 -18
View File
@@ -1,5 +1,11 @@
use reqwest::header::{HeaderMap, HeaderValue};
const MAX_RETRIES: u32 = 3;
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
/// or misconfigured server from hanging the process indefinitely.
const MAX_RETRY_DELAY_SECS: u64 = 60;
const CONNECT_TIMEOUT_SECS: u64 = 10;
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
let mut headers = HeaderMap::new();
let name = env!("CARGO_PKG_NAME");
@@ -13,40 +19,50 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
reqwest::Client::builder()
.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}"))
})
}
const MAX_RETRIES: u32 = 3;
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
/// or misconfigured server from hanging the process indefinitely.
const MAX_RETRY_DELAY_SECS: u64 = 60;
/// Send an HTTP request with automatic retry on 429 (rate limit) responses.
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
/// and transient connection/timeout errors.
/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s).
pub async fn send_with_retry(
build_request: impl Fn() -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
let mut last_err: Option<reqwest::Error> = None;
for attempt in 0..MAX_RETRIES {
let resp = build_request().send().await?;
match build_request().send().await {
Ok(resp) => {
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return Ok(resp);
}
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return Ok(resp);
let header_value = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok());
let retry_after = compute_retry_delay(header_value, attempt);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
}
Err(e) if e.is_connect() || e.is_timeout() => {
// Transient network error — retry with exponential backoff
let delay = compute_retry_delay(None, attempt);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
last_err = Some(e);
}
Err(e) => return Err(e),
}
let header_value = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok());
let retry_after = compute_retry_delay(header_value, attempt);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
}
// Final attempt — return whatever we get
build_request().send().await
match build_request().send().await {
Ok(resp) => Ok(resp),
Err(e) => Err(last_err.unwrap_or(e)),
}
}
/// Compute the retry delay from a Retry-After header value and attempt number.