Skip to content

Commit 31fc132

Browse files
byshingclaude
andcommitted
feat: curl-style request timing via --timing flag
Add --timing global flag that prints per-request phase breakdown to stderr after each HTTP response (success or error): namelookup DNS resolution time (custom tokio resolver) connect (TCP+TLS) full connection time (connector_layer timing) starttransfer time to first byte (TTFB) transfer body transfer time total end-to-end Uses reqwest 0.13's dns_resolver() and connector_layer() hooks to instrument each phase without modifying the request path. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 01b5168 commit 31fc132

8 files changed

Lines changed: 337 additions & 84 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ reedline = "0.38"
3939
nu-ansi-term = "0.50"
4040
rmcp = { version = "1.1", default-features = false, features = ["server", "transport-io", "transport-streamable-http-server"] }
4141
axum = "0.8"
42+
tower = { version = "0.5", features = ["util"] }
43+
pin-project-lite = "0.2"
4244

4345
[dev-dependencies]
4446
assert_cmd = "2"

src/cli/shell.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pub(crate) async fn run(ctx: &AppContext) -> Result<()> {
8989
mcp_mode: false,
9090
profile: cli.profile.or_else(|| ctx.profile.clone()),
9191
no_keychain: cli.no_keychain || ctx.no_keychain,
92+
timing: cli.timing || ctx.timing,
9293
};
9394
if let Some(command) = cli.command {
9495
if let Err(e) = Box::pin(dispatch(&shell_ctx, command)).await {

src/exchange/client.rs

Lines changed: 100 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
/// Rate limiting is server-authoritative: the client surfaces `x-ratelimit-*`
88
/// headers in error responses so agents can adapt their behaviour.
99
use std::env;
10-
use std::time::Duration;
10+
use std::sync::Arc;
11+
use std::time::{Duration, Instant};
1112

1213
use reqwest::header::{HeaderMap, HeaderValue};
1314
use serde_json::Value;
@@ -64,14 +65,28 @@ pub struct BitmexClient {
6465
pub(crate) base_url: String,
6566
pub(crate) testnet: bool,
6667
verbose: bool,
68+
/// When Some, timing phases are collected and printed for every request.
69+
timing: Option<super::timing::TimingHandle>,
6770
}
6871

69-
fn build_http_client() -> Result<reqwest::Client> {
72+
fn build_http_client(
73+
timing: Option<&super::timing::TimingHandle>,
74+
) -> Result<reqwest::Client> {
7075
let mut builder = reqwest::Client::builder()
7176
.use_rustls_tls()
7277
.timeout(Duration::from_secs(30))
7378
.user_agent(concat!("bitmex-cli/", env!("CARGO_PKG_VERSION")));
7479

80+
if let Some(handle) = timing {
81+
builder = builder
82+
.dns_resolver(Arc::new(
83+
super::timing::TimingDnsResolver::new(handle.clone())
84+
))
85+
.connector_layer(
86+
super::timing::TimingConnectorLayer::new(handle.clone())
87+
);
88+
}
89+
7590
if let Ok(val) = env::var("BITMEX_DANGER_ACCEPT_INVALID_CERTS") {
7691
if matches!(val.as_str(), "1" | "true" | "yes") {
7792
eprintln!(
@@ -89,45 +104,44 @@ fn build_http_client() -> Result<reqwest::Client> {
89104

90105
impl BitmexClient {
91106
/// Create a new client targeting mainnet or testnet.
92-
pub fn new(testnet: bool, verbose: bool) -> Result<Self> {
107+
pub fn new(testnet: bool, verbose: bool, timing: bool) -> Result<Self> {
93108
let base_url = if testnet {
94109
TESTNET_URL.to_string()
95110
} else {
96111
MAINNET_URL.to_string()
97112
};
113+
let timing_handle = timing.then(super::timing::new_handle);
98114
Ok(Self {
99-
http: build_http_client()?,
115+
http: build_http_client(timing_handle.as_ref())?,
100116
base_url,
101117
testnet,
102118
verbose,
119+
timing: timing_handle,
103120
})
104121
}
105122

106123
/// Create a new client with an explicit base URL override.
107-
pub fn new_with_url(base_url: String, testnet: bool, verbose: bool) -> Result<Self> {
124+
pub fn new_with_url(base_url: String, testnet: bool, verbose: bool, timing: bool) -> Result<Self> {
125+
let timing_handle = timing.then(super::timing::new_handle);
108126
Ok(Self {
109-
http: build_http_client()?,
127+
http: build_http_client(timing_handle.as_ref())?,
110128
base_url,
111129
testnet,
112130
verbose,
131+
timing: timing_handle,
113132
})
114133
}
115134

116135
/// Public GET — no authentication headers.
117136
pub async fn get(&self, path: &str, query: &str) -> Result<Value> {
118137
let url = self.url(path, query);
119-
if self.verbose {
120-
crate::cli::output::verbose(&format!("GET {url}"));
121-
}
138+
if self.verbose { crate::cli::output::verbose(&format!("GET {url}")); }
139+
self.begin_request();
122140
let resp = super::middleware::execute_with_retry(self.verbose, || async {
123-
self.http
124-
.get(&url)
125-
.send()
126-
.await
141+
self.http.get(&url).send().await
127142
.map_err(|e| BitmexError::Network { message: e.to_string() })
128-
})
129-
.await?;
130-
self.parse_response(resp).await
143+
}).await?;
144+
self.parse_response(resp, "GET", &url).await
131145
}
132146

133147
/// Authenticated GET.
@@ -138,97 +152,73 @@ impl BitmexClient {
138152
format!("/api/v1{path}?{query}")
139153
};
140154
let url = self.url(path, query);
141-
if self.verbose {
142-
crate::cli::output::verbose(&format!("GET {url} (auth)"));
143-
}
155+
if self.verbose { crate::cli::output::verbose(&format!("GET {url} (auth)")); }
156+
self.begin_request();
144157
let resp = super::middleware::execute_with_retry(self.verbose, || async {
145158
let expires = auth::generate_expires()?;
146159
let sig = auth::sign("GET", &full_path, expires, "", &creds.api_secret)?;
147160
let headers = self.auth_headers(&creds.api_key, expires, &sig)?;
148-
self.http
149-
.get(&url)
150-
.headers(headers)
151-
.send()
152-
.await
161+
self.http.get(&url).headers(headers).send().await
153162
.map_err(|e| BitmexError::Network { message: e.to_string() })
154-
})
155-
.await?;
156-
self.parse_response(resp).await
163+
}).await?;
164+
self.parse_response(resp, "GET", &url).await
157165
}
158166

159167
/// Authenticated POST with JSON body.
160168
pub async fn post(&self, path: &str, body: &Value, creds: &Credentials) -> Result<Value> {
161169
let full_path = format!("/api/v1{path}");
162170
let body_str = serde_json::to_string(body)?;
163171
let url = self.url(path, "");
164-
if self.verbose {
165-
crate::cli::output::verbose(&format!("POST {url}"));
166-
}
172+
if self.verbose { crate::cli::output::verbose(&format!("POST {url}")); }
173+
self.begin_request();
167174
let resp = super::middleware::execute_with_retry(self.verbose, || async {
168175
let expires = auth::generate_expires()?;
169176
let sig = auth::sign("POST", &full_path, expires, &body_str, &creds.api_secret)?;
170177
let headers = self.auth_headers(&creds.api_key, expires, &sig)?;
171-
self.http
172-
.post(&url)
173-
.headers(headers)
178+
self.http.post(&url).headers(headers)
174179
.header("Content-Type", "application/json")
175-
.body(body_str.clone())
176-
.send()
177-
.await
180+
.body(body_str.clone()).send().await
178181
.map_err(|e| BitmexError::Network { message: e.to_string() })
179-
})
180-
.await?;
181-
self.parse_response(resp).await
182+
}).await?;
183+
self.parse_response(resp, "POST", &url).await
182184
}
183185

184186
/// Authenticated PUT with JSON body.
185187
pub async fn put(&self, path: &str, body: &Value, creds: &Credentials) -> Result<Value> {
186188
let full_path = format!("/api/v1{path}");
187189
let body_str = serde_json::to_string(body)?;
188190
let url = self.url(path, "");
189-
if self.verbose {
190-
crate::cli::output::verbose(&format!("PUT {url}"));
191-
}
191+
if self.verbose { crate::cli::output::verbose(&format!("PUT {url}")); }
192+
self.begin_request();
192193
let resp = super::middleware::execute_with_retry(self.verbose, || async {
193194
let expires = auth::generate_expires()?;
194195
let sig = auth::sign("PUT", &full_path, expires, &body_str, &creds.api_secret)?;
195196
let headers = self.auth_headers(&creds.api_key, expires, &sig)?;
196-
self.http
197-
.put(&url)
198-
.headers(headers)
197+
self.http.put(&url).headers(headers)
199198
.header("Content-Type", "application/json")
200-
.body(body_str.clone())
201-
.send()
202-
.await
199+
.body(body_str.clone()).send().await
203200
.map_err(|e| BitmexError::Network { message: e.to_string() })
204-
})
205-
.await?;
206-
self.parse_response(resp).await
201+
}).await?;
202+
self.parse_response(resp, "PUT", &url).await
207203
}
208204

209205
/// Authenticated PATCH with JSON body.
210206
pub async fn patch(&self, path: &str, body: &Value, creds: &Credentials) -> Result<Value> {
211207
let full_path = format!("/api/v1{path}");
212208
let body_str = serde_json::to_string(body)?;
213209
let url = self.url(path, "");
214-
if self.verbose {
215-
crate::cli::output::verbose(&format!("PATCH {url}"));
216-
}
210+
if self.verbose { crate::cli::output::verbose(&format!("PATCH {url}")); }
211+
self.begin_request();
217212
let resp = super::middleware::execute_with_retry(self.verbose, || async {
218213
let expires = auth::generate_expires()?;
219214
let sig = auth::sign("PATCH", &full_path, expires, &body_str, &creds.api_secret)?;
220215
let headers = self.auth_headers(&creds.api_key, expires, &sig)?;
221-
self.http
222-
.patch(&url)
223-
.headers(headers)
216+
self.http.patch(&url).headers(headers)
224217
.header("Content-Type", "application/json")
225-
.body(body_str.clone())
226-
.send()
227-
.await
218+
.body(body_str.clone()).send().await
228219
.map_err(|e| BitmexError::Network { message: e.to_string() })
229-
})
230-
.await?;
231-
self.parse_response(resp).await
220+
}).await?;
221+
self.parse_response(resp, "PATCH", &url).await
232222
}
233223

234224
/// Authenticated DELETE, optional JSON body.
@@ -244,31 +234,21 @@ impl BitmexClient {
244234
} else {
245235
format!("/api/v1{path}?{query}")
246236
};
247-
let body_str = body
248-
.map(|b| serde_json::to_string(b))
249-
.transpose()?
250-
.unwrap_or_default();
237+
let body_str = body.map(|b| serde_json::to_string(b)).transpose()?.unwrap_or_default();
251238
let url = self.url(path, query);
252-
if self.verbose {
253-
crate::cli::output::verbose(&format!("DELETE {url}"));
254-
}
239+
if self.verbose { crate::cli::output::verbose(&format!("DELETE {url}")); }
240+
self.begin_request();
255241
let resp = super::middleware::execute_with_retry(self.verbose, || async {
256242
let expires = auth::generate_expires()?;
257-
let sig =
258-
auth::sign("DELETE", &full_path, expires, &body_str, &creds.api_secret)?;
243+
let sig = auth::sign("DELETE", &full_path, expires, &body_str, &creds.api_secret)?;
259244
let headers = self.auth_headers(&creds.api_key, expires, &sig)?;
260245
let mut req = self.http.delete(&url).headers(headers);
261246
if !body_str.is_empty() {
262-
req = req
263-
.header("Content-Type", "application/json")
264-
.body(body_str.clone());
247+
req = req.header("Content-Type", "application/json").body(body_str.clone());
265248
}
266-
req.send()
267-
.await
268-
.map_err(|e| BitmexError::Network { message: e.to_string() })
269-
})
270-
.await?;
271-
self.parse_response(resp).await
249+
req.send().await.map_err(|e| BitmexError::Network { message: e.to_string() })
250+
}).await?;
251+
self.parse_response(resp, "DELETE", &url).await
272252
}
273253

274254
fn url(&self, path: &str, query: &str) -> String {
@@ -299,7 +279,28 @@ impl BitmexClient {
299279
Ok(headers)
300280
}
301281

302-
async fn parse_response(&self, resp: reqwest::Response) -> Result<Value> {
282+
/// Reset timing state and record request_start. Call before each request.
283+
fn begin_request(&self) {
284+
if let Some(ref handle) = self.timing {
285+
if let Ok(mut phases) = handle.lock() {
286+
*phases = super::timing::Phases {
287+
request_start: Some(Instant::now()),
288+
..Default::default()
289+
};
290+
}
291+
}
292+
}
293+
294+
/// Print timing summary to stderr. Call after body is fully read.
295+
fn end_request(&self, method: &str, url: &str) {
296+
if let Some(ref handle) = self.timing {
297+
if let Ok(phases) = handle.lock() {
298+
super::timing::print_timing(&phases, method, url);
299+
}
300+
}
301+
}
302+
303+
async fn parse_response(&self, resp: reqwest::Response, method: &str, url: &str) -> Result<Value> {
303304
let status = resp.status();
304305
let remaining = resp
305306
.headers()
@@ -312,8 +313,24 @@ impl BitmexClient {
312313
.and_then(|v| v.to_str().ok())
313314
.and_then(|s| s.parse::<u64>().ok());
314315

316+
// Record TTFB — response headers have arrived.
317+
if let Some(ref handle) = self.timing {
318+
if let Ok(mut phases) = handle.lock() {
319+
phases.ttfb = Some(Instant::now());
320+
}
321+
}
322+
315323
let body = resp.text().await.map_err(BitmexError::from)?;
316324

325+
// Record transfer end — body fully received. Print timing now so it
326+
// appears regardless of whether the response is a success or an error.
327+
if let Some(ref handle) = self.timing {
328+
if let Ok(mut phases) = handle.lock() {
329+
phases.transfer_end = Some(Instant::now());
330+
}
331+
}
332+
self.end_request(method, url);
333+
317334
if self.verbose {
318335
crate::cli::output::verbose(&format!("Status: {status}"));
319336
if let Some(r) = remaining {
@@ -348,6 +365,7 @@ impl BitmexClient {
348365

349366
let value: Value = serde_json::from_str(&body)
350367
.map_err(|e| BitmexError::Parse { message: format!("Failed to parse response JSON: {e}") })?;
368+
351369
Ok(value)
352370
}
353371

src/exchange/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod client;
22
pub(crate) mod auth;
33
pub(crate) mod middleware;
4+
pub(crate) mod timing;
45

0 commit comments

Comments
 (0)