diff --git a/src/client/config.rs b/src/client/config.rs index d0dcb74..2e7217e 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use std::time::Duration; /// /// Configuration for an authenticated Open Payments client. @@ -49,6 +50,23 @@ pub struct ClientConfig { /// /// This is the URL of the wallet address that will be used to send and receive payments. pub wallet_address_url: String, + + /// Optional request timeout duration. + /// + /// If set, all HTTP requests made by this client will be subject to + /// this timeout. Defaults to 30 seconds if not specified. + /// + /// Payment APIs are latency-sensitive — a missing timeout can cause + /// requests to hang indefinitely if the server stops responding. + #[serde(skip)] + pub request_timeout: Option, + + /// Optional connection timeout duration. + /// + /// Limits how long the client will wait to establish a TCP connection. + /// Defaults to 10 seconds if not specified. + #[serde(skip)] + pub connect_timeout: Option, } impl Default for ClientConfig { @@ -76,6 +94,8 @@ impl Default for ClientConfig { private_key_path: PathBuf::from("private.key"), jwks_path: None, wallet_address_url: "".into(), + request_timeout: Some(Duration::from_secs(30)), + connect_timeout: Some(Duration::from_secs(10)), } } } diff --git a/src/client/core.rs b/src/client/core.rs index 1f0f99d..23efe55 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -80,7 +80,18 @@ impl AuthenticatedOpenPaymentsClient { /// - `validation_errors`: List of validation errors (if applicable) /// - `details`: Additional error details (if applicable) pub fn new(config: ClientConfig) -> Result { - let http_client = ReqwestClient::new(); + let mut builder = ReqwestClient::builder(); + + if let Some(timeout) = config.request_timeout { + builder = builder.timeout(timeout); + } + if let Some(connect_timeout) = config.connect_timeout { + builder = builder.connect_timeout(connect_timeout); + } + + let http_client = builder.build().map_err(|e| { + OpClientError::other(format!("Failed to build HTTP client: {e}")) + })?; let signing_key = load_or_generate_key(&config.private_key_path).map_err(|e| { OpClientError::signature(format!("Failed to load or generate signing key: {e}")) diff --git a/tests/client_request_tests.rs b/tests/client_request_tests.rs index 27356dd..bb448b1 100644 --- a/tests/client_request_tests.rs +++ b/tests/client_request_tests.rs @@ -17,6 +17,8 @@ fn dummy_config(base: &str) -> ClientConfig { private_key_path: std::path::PathBuf::from("tests/private.key"), jwks_path: None, wallet_address_url: format!("{base}/alice"), + request_timeout: None, + connect_timeout: None, } } @@ -337,3 +339,59 @@ async fn cancel_grant_204_no_content_succeeds() { .await; assert!(res.is_ok()); } + +#[tokio::test] +async fn client_with_timeouts_builds_successfully() { + use std::time::Duration; + + let server = MockServer::start().await; + let base = Url::parse(&server.uri()).unwrap(); + + Mock::given(method("GET")) + .and(path(base.join("alice").unwrap().path())) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": format!("{}/alice", server.uri()), + "publicName": "Alice", + "assetCode": "USD", + "assetScale": 2, + "authServer": format!("{}/auth", server.uri()), + "resourceServer": server.uri() + }))) + .mount(&server) + .await; + + let tmp = tempdir().unwrap(); + let config = ClientConfig { + key_id: "test-key".into(), + private_key_path: tmp.path().join("private.key"), + jwks_path: None, + wallet_address_url: format!("{}/alice", server.uri()), + request_timeout: Some(Duration::from_secs(5)), + connect_timeout: Some(Duration::from_secs(2)), + }; + + // Verify client builds with timeouts + let client = AuthenticatedClient::new(config).unwrap(); + + // Verify it can still make requests + let res: Result = client + .wallet_address() + .get(base.join("alice").unwrap().as_ref()) + .await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn client_default_config_has_timeouts() { + let config = ClientConfig::default(); + assert!(config.request_timeout.is_some()); + assert!(config.connect_timeout.is_some()); + assert_eq!( + config.request_timeout.unwrap(), + std::time::Duration::from_secs(30) + ); + assert_eq!( + config.connect_timeout.unwrap(), + std::time::Duration::from_secs(10) + ); +}