Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/client/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;

///
/// Configuration for an authenticated Open Payments client.
Expand Down Expand Up @@ -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<Duration>,

/// 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<Duration>,
}

impl Default for ClientConfig {
Expand Down Expand Up @@ -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)),
}
}
}
13 changes: 12 additions & 1 deletion src/client/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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}"))
Expand Down
58 changes: 58 additions & 0 deletions tests/client_request_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<WalletAddress, _> = 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)
);
}