diff --git a/Cargo.lock b/Cargo.lock index 78bafd2955..69d71f73d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,7 +1046,7 @@ dependencies = [ name = "cap-api" version = "0.0.0" dependencies = [ - "reqwest 0.12.23", + "reqwest 0.12.24", ] [[package]] @@ -1221,7 +1221,7 @@ dependencies = [ "posthog-rs", "rand 0.8.5", "relative-path", - "reqwest 0.12.23", + "reqwest 0.12.24", "rodio", "scap", "scap-direct3d", @@ -3944,12 +3944,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - [[package]] name = "heck" version = "0.4.1" @@ -4204,7 +4198,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.5.10", "system-configuration 0.6.1", "tokio", "tower-service", @@ -4460,7 +4454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "serde", "serde_core", ] @@ -4900,7 +4894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-targets 0.48.5", ] [[package]] @@ -6203,7 +6197,7 @@ dependencies = [ "bytes", "http 1.3.1", "opentelemetry", - "reqwest 0.12.23", + "reqwest 0.12.24", ] [[package]] @@ -6218,7 +6212,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.12.23", + "reqwest 0.12.24", "thiserror 2.0.16", "tracing", ] @@ -6993,7 +6987,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.31", - "socket2 0.6.0", + "socket2 0.5.10", "thiserror 2.0.16", "tokio", "tracing", @@ -7030,7 +7024,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.0", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -7448,9 +7442,8 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +version = "0.12.24" +source = "git+https://github.com/CapSoftware/reqwest?rev=9b5ecbd5210a9510fde766015cabb724c1e70d2e#9b5ecbd5210a9510fde766015cabb724c1e70d2e" dependencies = [ "base64 0.22.1", "bytes", @@ -8076,7 +8069,7 @@ checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030" dependencies = [ "httpdate", "native-tls", - "reqwest 0.12.23", + "reqwest 0.12.24", "sentry-actix", "sentry-anyhow", "sentry-backtrace", @@ -9142,7 +9135,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.12.23", + "reqwest 0.12.24", "serde", "serde_json", "serde_repr", @@ -9363,7 +9356,7 @@ dependencies = [ "data-url", "http 1.3.1", "regex", - "reqwest 0.12.23", + "reqwest 0.12.24", "schemars 0.8.22", "serde", "serde_json", @@ -9559,7 +9552,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.12.23", + "reqwest 0.12.24", "semver", "serde", "serde_json", @@ -11276,7 +11269,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3f3b7f77e7..d351d3b289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,3 +86,6 @@ cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8" } # https://github.com/CapSoftware/posthog-rs/commit/c7e9712be2f9a9122b1df685d5a067afa5415288 posthog-rs = { git = "https://github.com/CapSoftware/posthog-rs", rev = "c7e9712be2f9a9122b1df685d5a067afa5415288" } + +# https://github.com/CapSoftware/reqwest/commit/9b5ecbd5210a9510fde766015cabb724c1e70d2e +reqwest = { git = "https://github.com/CapSoftware/reqwest", rev = "9b5ecbd5210a9510fde766015cabb724c1e70d2e" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 2903d10e98..8dbaa58034 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -65,7 +65,7 @@ rodio = "0.19.0" png = "0.17.13" device_query = "4.0.1" base64 = "0.22.1" -reqwest = { version = "0.12.7", features = ["json", "stream", "multipart"] } +reqwest = { version = "0.12.24", features = ["json", "stream", "multipart"] } dotenvy_macro = "0.15.7" global-hotkey = "0.7.0" rand = "0.8.5" diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index 88e4ec457c..05678af591 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -5,7 +5,6 @@ use ffmpeg::{ format::{self as avformat}, software::resampling, }; -use reqwest::Client; use serde::{Deserialize, Serialize}; use specta::Type; use std::fs::File; @@ -22,6 +21,8 @@ use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextPar // Re-export caption types from cap_project pub use cap_project::{CaptionSegment, CaptionSettings}; +use crate::http_client; + // Convert the project type's float precision from f32 to f64 for compatibility #[derive(Debug, Serialize, Deserialize, Type, Clone)] pub struct CaptionData { @@ -1051,6 +1052,7 @@ impl DownloadProgress { #[specta::specta] #[instrument(skip(window))] pub async fn download_whisper_model( + app: AppHandle, window: Window, model_name: String, output_path: String, @@ -1067,8 +1069,8 @@ pub async fn download_whisper_model( }; // Create the client and download the model - let client = Client::new(); - let response = client + let response = app + .state::() .get(model_url) .send() .await diff --git a/apps/desktop/src-tauri/src/http_client.rs b/apps/desktop/src-tauri/src/http_client.rs new file mode 100644 index 0000000000..47774eead0 --- /dev/null +++ b/apps/desktop/src-tauri/src/http_client.rs @@ -0,0 +1,59 @@ +//! We reuse clients so we get connection pooling and also so the retry policy can handle backing off across requests. + +use std::ops::Deref; + +use reqwest::StatusCode; + +pub struct HttpClient(reqwest::Client); + +impl Default for HttpClient { + fn default() -> Self { + Self(reqwest::Client::new()) + } +} + +impl Deref for HttpClient { + type Target = reqwest::Client; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct RetryableHttpClient(reqwest::Result); + +impl Default for RetryableHttpClient { + fn default() -> Self { + Self( + reqwest::Client::builder() + .retry( + reqwest::retry::always() + .classify_fn(|req_rep| { + match req_rep.status() { + // Server errors + Some(s) + if s.is_server_error() + || s == StatusCode::TOO_MANY_REQUESTS => + { + req_rep.retryable() + } + // Network errors + None => req_rep.retryable(), + _ => req_rep.success(), + } + }) + .max_retries_per_request(5) + .max_extra_load(5.0), + ) + .build(), + ) + } +} + +impl Deref for RetryableHttpClient { + type Target = reqwest::Result; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ea9baef021..1880247974 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod flags; mod frame_ws; mod general_settings; mod hotkeys; +mod http_client; mod logging; mod notifications; mod permissions; @@ -2193,6 +2194,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app.manage(EditorWindowIds::default()); #[cfg(target_os = "macos")] app.manage(crate::platform::ScreenCapturePrewarmer::default()); + app.manage(http_client::HttpClient::default()); + app.manage(http_client::RetryableHttpClient::default()); tokio::spawn({ let camera_feed = camera_feed.clone(); diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index d27d1246bc..cc8a8c0d4d 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -3,11 +3,11 @@ use crate::{ UploadProgress, VideoUploadInfo, api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, + http_client::RetryableHttpClient, posthog::{PostHogEvent, async_capture_event}, web_api::{AuthedApiError, ManagerExt}, }; use async_stream::{stream, try_stream}; -use axum::http::Uri; use bytes::Bytes; use cap_project::{RecordingMeta, S3UploadMeta, UploadMeta}; use cap_utils::spawn_actor; @@ -24,11 +24,10 @@ use std::{ io, path::{Path, PathBuf}, pin::pin, - str::FromStr, sync::{Arc, Mutex, PoisonError}, time::Duration, }; -use tauri::{AppHandle, ipc::Channel}; +use tauri::{AppHandle, Manager, ipc::Channel}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; use tokio::{ @@ -596,25 +595,6 @@ pub fn from_pending_file_to_chunks( .instrument(Span::current()) } -fn retryable_client(host: String) -> reqwest::ClientBuilder { - reqwest::Client::builder().retry( - reqwest::retry::for_host(host) - .classify_fn(|req_rep| { - match req_rep.status() { - // Server errors - Some(s) if s.is_server_error() || s == StatusCode::TOO_MANY_REQUESTS => { - req_rep.retryable() - } - // Network errors - None => req_rep.retryable(), - _ => req_rep.success(), - } - }) - .max_retries_per_request(5) - .max_extra_load(5.0), - ) -} - /// Takes an incoming stream of bytes and individually uploads them to S3. /// /// Note: It's on the caller to ensure the chunks are sized correctly within S3 limits. @@ -726,19 +706,16 @@ fn multipart_uploader( } let size = chunk.len(); - let url = Uri::from_str(&presigned_url).map_err(|err| { - format!("uploader/part/{part_number}/invalid_url: {err:?}") - })?; - let mut req = - retryable_client(url.host().unwrap_or("").to_string()) - .build() - .map_err(|err| { - format!("uploader/part/{part_number}/client: {err:?}") - })? - .put(&presigned_url) - .header("Content-Length", chunk.len()) - .timeout(Duration::from_secs(5 * 60)) - .body(chunk); + let mut req = app + .state::() + .as_ref() + .map_err(|err| { + format!("uploader/part/{part_number}/client: {err:?}") + })? + .put(&presigned_url) + .header("Content-Length", chunk.len()) + .timeout(Duration::from_secs(5 * 60)) + .body(chunk); if let Some(md5_sum) = &md5_sum { req = req.header("Content-MD5", md5_sum); @@ -811,10 +788,9 @@ pub async fn singlepart_uploader( ) -> Result<(), AuthedApiError> { let presigned_url = api::upload_signed(&app, request).await?; - let url = Uri::from_str(&presigned_url) - .map_err(|err| format!("singlepart_uploader/invalid_url: {err:?}"))?; - let resp = retryable_client(url.host().unwrap_or("").to_string()) - .build() + let resp = app + .state::() + .as_ref() .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? .put(&presigned_url) .header("Content-Length", total_size) diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index bdc6b405b4..a8fdf1fd6f 100644 --- a/apps/desktop/src-tauri/src/web_api.rs +++ b/apps/desktop/src-tauri/src/web_api.rs @@ -6,6 +6,7 @@ use tracing::{error, warn}; use crate::{ ArcLock, auth::{AuthSecret, AuthStore}, + http_client, }; #[derive(Error, Debug)] @@ -58,12 +59,11 @@ fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { } async fn do_authed_request( + client: &reqwest::Client, auth: &AuthStore, - build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + build: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder, url: String, ) -> Result { - let client = reqwest::Client::new(); - let req = build(client, url).header( "Authorization", format!( @@ -82,13 +82,13 @@ pub trait ManagerExt: Manager { async fn authed_api_request( &self, path: impl Into, - build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + build: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder, ) -> Result; async fn api_request( &self, path: impl Into, - build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + build: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder, ) -> Result; async fn make_app_url(&self, pathname: impl AsRef) -> String; @@ -100,7 +100,7 @@ impl + Emitter, R: Runtime> ManagerExt for T { async fn authed_api_request( &self, path: impl Into, - build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + build: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder, ) -> Result { let Some(auth) = AuthStore::get(self.app_handle()).map_err(AuthedApiError::AuthStore)? else { @@ -109,7 +109,8 @@ impl + Emitter, R: Runtime> ManagerExt for T { }; let url = self.make_app_url(path.into()).await; - let response = do_authed_request(&auth, build, url).await?; + let response = + do_authed_request(&self.state::(), &auth, build, url).await?; if response.status() == StatusCode::UNAUTHORIZED { error!("Authentication expired. Please log in again."); @@ -122,12 +123,13 @@ impl + Emitter, R: Runtime> ManagerExt for T { async fn api_request( &self, path: impl Into, - build: impl FnOnce(reqwest::Client, String) -> reqwest::RequestBuilder, + build: impl FnOnce(&reqwest::Client, String) -> reqwest::RequestBuilder, ) -> Result { let url = self.make_app_url(path.into()).await; - let client = reqwest::Client::new(); - apply_env_headers(build(client, url)).send().await + apply_env_headers(build(&self.state::(), url)) + .send() + .await } async fn make_app_url(&self, pathname: impl AsRef) -> String { diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 531e155bea..6545b383b8 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [dependencies] -reqwest = "0.12.23" +reqwest = "0.12.24" [lints] workspace = true