diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index 88e4ec457c..e191ee07cd 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -19,6 +19,8 @@ use tokio::sync::Mutex; use tracing::instrument; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; +use crate::shared_client::get_shared_client; + // Re-export caption types from cap_project pub use cap_project::{CaptionSegment, CaptionSettings}; @@ -1067,7 +1069,7 @@ pub async fn download_whisper_model( }; // Create the client and download the model - let client = Client::new(); + let client = get_shared_client(); let response = client .get(model_url) .send() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2a0eb78225..a881ba5f50 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ mod posthog; mod presets; mod recording; mod recording_settings; +mod shared_client; mod target_select_overlay; mod thumbnails; mod tray; diff --git a/apps/desktop/src-tauri/src/shared_client.rs b/apps/desktop/src-tauri/src/shared_client.rs new file mode 100644 index 0000000000..ea5bfa5a93 --- /dev/null +++ b/apps/desktop/src-tauri/src/shared_client.rs @@ -0,0 +1,45 @@ +use reqwest::Client; +use std::sync::OnceLock; + +/// Global shared HTTP client instance with retry policies +static SHARED_CLIENT: OnceLock = OnceLock::new(); + +/// Get the shared HTTP client instance +/// +/// This client is configured with retry policies and is shared across the entire application. +/// This allows for global tracking of requests to each domain for DOS protection. +pub fn get_shared_client() -> &'static Client { + SHARED_CLIENT.get_or_init(|| { + Client::builder() + .retry( + reqwest::retry::for_all() + .classify_fn(|req_rep| { + match req_rep.status() { + // Server errors and rate limiting + Some(s) + if s.is_server_error() + || s == reqwest::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), + ) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create shared HTTP client") + }) +} + +/// Get a retryable client for specific hosts +/// +/// This function returns the shared client which has global retry tracking. +/// All requests use the same client instance for consistent DOS protection. +pub fn get_retryable_client(_host: String) -> Result<&'static Client, reqwest::Error> { + Ok(get_shared_client()) +} diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index d27d1246bc..19c09fadf6 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -4,6 +4,7 @@ use crate::{ UploadProgress, VideoUploadInfo, api::{self, PresignedS3PutRequest, PresignedS3PutRequestMethod, S3VideoMeta, UploadedPart}, posthog::{PostHogEvent, async_capture_event}, + shared_client::get_retryable_client, web_api::{AuthedApiError, ManagerExt}, }; use async_stream::{stream, try_stream}; @@ -596,25 +597,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. @@ -729,16 +711,18 @@ fn multipart_uploader( 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 host = url + .host() + .ok_or_else(|| format!("uploader/part/{part_number}/missing_host"))? + .to_string(); + let client = get_retryable_client(host).map_err(|err| { + format!("uploader/part/{part_number}/client: {err:?}") + })?; + let mut req = client + .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); @@ -813,9 +797,13 @@ pub async fn singlepart_uploader( 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() - .map_err(|err| format!("singlepart_uploader/client: {err:?}"))? + let host = url + .host() + .ok_or_else(|| "singlepart_uploader/missing_host".to_string())? + .to_string(); + let client = + get_retryable_client(host).map_err(|err| format!("singlepart_uploader/client: {err:?}"))?; + let resp = client .put(&presigned_url) .header("Content-Length", total_size) .body(reqwest::Body::wrap_stream(stream)) diff --git a/apps/desktop/src-tauri/src/web_api.rs b/apps/desktop/src-tauri/src/web_api.rs index bdc6b405b4..c7099f4ca0 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}, + shared_client::get_shared_client, }; #[derive(Error, Debug)] @@ -59,10 +60,10 @@ fn apply_env_headers(req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { async fn do_authed_request( 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 client = get_shared_client(); let req = build(client, url).header( "Authorization", @@ -82,13 +83,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 +101,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 { @@ -122,10 +123,10 @@ 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(); + let client = get_shared_client(); apply_env_headers(build(client, url)).send().await } diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index db21f744e1..eca65fb570 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -22,7 +22,7 @@ declare global { const IconCapCorners: typeof import('~icons/cap/corners.jsx')['default'] const IconCapCrop: typeof import('~icons/cap/crop.jsx')['default'] const IconCapCursor: typeof import('~icons/cap/cursor.jsx')['default'] - const IconCapEditor: typeof import("~icons/cap/editor.jsx")["default"] + const IconCapEditor: typeof import('~icons/cap/editor.jsx')['default'] const IconCapEnlarge: typeof import('~icons/cap/enlarge.jsx')['default'] const IconCapFile: typeof import('~icons/cap/file.jsx')['default'] const IconCapFilmCut: typeof import('~icons/cap/film-cut.jsx')['default'] @@ -57,7 +57,7 @@ declare global { const IconCapStopCircle: typeof import('~icons/cap/stop-circle.jsx')['default'] const IconCapTrash: typeof import('~icons/cap/trash.jsx')['default'] const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default'] - const IconCapUpload: typeof import("~icons/cap/upload.jsx")["default"] + const IconCapUpload: typeof import('~icons/cap/upload.jsx')['default'] const IconCapX: typeof import('~icons/cap/x.jsx')['default'] const IconCapZoomIn: typeof import('~icons/cap/zoom-in.jsx')['default'] const IconCapZoomOut: typeof import('~icons/cap/zoom-out.jsx')['default'] @@ -69,7 +69,7 @@ declare global { const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] - const IconLucideEye: typeof import("~icons/lucide/eye.jsx")["default"] + const IconLucideEye: typeof import('~icons/lucide/eye.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] const IconLucideGift: typeof import('~icons/lucide/gift.jsx')['default'] @@ -88,7 +88,7 @@ declare global { const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] const IconLucideVideo: typeof import('~icons/lucide/video.jsx')['default'] const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default'] - const IconLucideVolumeX: typeof import("~icons/lucide/volume-x.jsx")["default"] + const IconLucideVolumeX: typeof import('~icons/lucide/volume-x.jsx')['default'] const IconMaterialSymbolsScreenshotFrame2Rounded: typeof import('~icons/material-symbols/screenshot-frame2-rounded.jsx')['default'] const IconMdiLoading: typeof import("~icons/mdi/loading.jsx")["default"] const IconMdiMonitor: typeof import('~icons/mdi/monitor.jsx')['default']