1use std::collections::HashMap;
4use std::time::Duration;
5
6use rand::Rng;
7use time::OffsetDateTime;
8use time::format_description::well_known::Iso8601;
9use tracing::{debug, trace, warn};
10
11use crate::api_types::*;
12use crate::auth::TokenManager;
13use crate::error::{QuestradeError, Result};
14
15const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
17const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
19const MAX_RETRIES: u32 = 3;
21const RETRY_BASE_DELAY_MS: u64 = 1000;
23
24fn backoff_delay(attempt: u32) -> Duration {
30 let base_ms = RETRY_BASE_DELAY_MS << attempt; let jitter_factor = rand::thread_rng().gen_range(0.8f64..=1.2f64);
32 let delay_ms = (base_ms as f64 * jitter_factor) as u64;
33 Duration::from_millis(delay_ms)
34}
35
36fn retry_after_or_backoff(response: &reqwest::Response, attempt: u32) -> Duration {
42 if let Some(val) = response.headers().get(reqwest::header::RETRY_AFTER)
43 && let Ok(s) = val.to_str()
44 && let Ok(secs) = s.trim().parse::<u64>()
45 {
46 let capped = secs.min(60);
47 return Duration::from_secs(capped);
48 }
49 backoff_delay(attempt)
50}
51
52fn format_query_datetime(dt: OffsetDateTime) -> Result<String> {
57 let utc = dt.to_offset(time::UtcOffset::UTC);
58 let fmt = time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]Z")
59 .map_err(|e| QuestradeError::DateTime {
60 context: "Failed to build datetime format".to_string(),
61 source: Box::new(e),
62 })?;
63 utc.format(&fmt).map_err(|e| QuestradeError::DateTime {
64 context: "Failed to format datetime for query parameter".to_string(),
65 source: Box::new(e),
66 })
67}
68
69pub struct QuestradeClient {
94 http: reqwest::Client,
95 token_manager: TokenManager,
96 log_raw_responses: bool,
97}
98
99pub struct QuestradeClientBuilder {
123 token_manager: Option<TokenManager>,
124 http_client: Option<reqwest::Client>,
125}
126
127impl QuestradeClientBuilder {
128 pub fn new() -> Self {
130 Self {
131 token_manager: None,
132 http_client: None,
133 }
134 }
135
136 pub fn token_manager(mut self, tm: TokenManager) -> Self {
138 self.token_manager = Some(tm);
139 self
140 }
141
142 pub fn http_client(mut self, client: reqwest::Client) -> Self {
149 self.http_client = Some(client);
150 self
151 }
152
153 pub fn build(self) -> Result<QuestradeClient> {
162 let token_manager = self.token_manager.ok_or_else(|| {
163 QuestradeError::EmptyResponse(
164 "QuestradeClientBuilder: token_manager is required".to_string(),
165 )
166 })?;
167
168 let http = match self.http_client {
169 Some(client) => client,
170 None => reqwest::Client::builder()
171 .timeout(REQUEST_TIMEOUT)
172 .connect_timeout(CONNECT_TIMEOUT)
173 .build()?,
174 };
175
176 Ok(QuestradeClient {
177 http,
178 token_manager,
179 log_raw_responses: false,
180 })
181 }
182}
183
184impl Default for QuestradeClientBuilder {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190impl QuestradeClient {
191 pub fn new(token_manager: TokenManager) -> Result<Self> {
210 QuestradeClientBuilder::new()
211 .token_manager(token_manager)
212 .build()
213 }
214
215 pub fn with_raw_logging(mut self, enabled: bool) -> Self {
222 self.log_raw_responses = enabled;
223 self
224 }
225
226 async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
231 let mut auth_retried = false;
232 loop {
233 let (token, api_server) = self.token_manager.get_token().await?;
234 let url = format!("{}v1{}", api_server, path);
235 debug!(method = "GET", endpoint = %url, "HTTP request");
236
237 let resp = {
238 let mut attempt = 0u32;
239 loop {
240 let resp = self.http.get(&url).bearer_auth(&token).send().await?;
241
242 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
243 if attempt < MAX_RETRIES {
244 let delay = retry_after_or_backoff(&resp, attempt);
245 warn!(attempt = attempt + 1, max_retries = MAX_RETRIES, delay = ?delay, "rate limited, retrying");
246 tokio::time::sleep(delay).await;
247 attempt += 1;
248 continue;
249 }
250 return Err(QuestradeError::RateLimited {
251 retries: MAX_RETRIES,
252 });
253 }
254
255 break resp;
256 }
257 };
258
259 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
260 warn!("received 401 Unauthorized, forcing token refresh and retrying");
261 self.token_manager.force_refresh().await?;
262 auth_retried = true;
263 continue;
264 }
265
266 if !resp.status().is_success() {
267 let status = resp.status();
268 let body = resp.text().await.unwrap_or_default();
269 return Err(QuestradeError::Api { status, body });
270 }
271
272 if self.log_raw_responses {
273 let text = resp.text().await?;
274 trace!(method = "GET", endpoint = %url, body = %text, "raw response");
275 return Ok(serde_json::from_str(&text)?);
276 } else {
277 return Ok(resp.json().await?);
278 }
279 }
280 }
281
282 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
287 &self,
288 path: &str,
289 body: &B,
290 ) -> Result<T> {
291 let mut auth_retried = false;
292 loop {
293 let (token, api_server) = self.token_manager.get_token().await?;
294 let url = format!("{}v1{}", api_server, path);
295 debug!(method = "POST", endpoint = %url, "HTTP request");
296
297 let resp = {
298 let mut attempt = 0u32;
299 loop {
300 let resp = self
301 .http
302 .post(&url)
303 .bearer_auth(&token)
304 .json(body)
305 .send()
306 .await?;
307
308 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
309 if attempt < MAX_RETRIES {
310 let delay = retry_after_or_backoff(&resp, attempt);
311 warn!(attempt = attempt + 1, max_retries = MAX_RETRIES, delay = ?delay, "rate limited (POST), retrying");
312 tokio::time::sleep(delay).await;
313 attempt += 1;
314 continue;
315 }
316 return Err(QuestradeError::RateLimited {
317 retries: MAX_RETRIES,
318 });
319 }
320
321 break resp;
322 }
323 };
324
325 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
326 warn!("received 401 Unauthorized, forcing token refresh and retrying");
327 self.token_manager.force_refresh().await?;
328 auth_retried = true;
329 continue;
330 }
331
332 if !resp.status().is_success() {
333 let status = resp.status();
334 let body_text = resp.text().await.unwrap_or_default();
335 return Err(QuestradeError::Api {
336 status,
337 body: body_text,
338 });
339 }
340
341 if self.log_raw_responses {
342 let text = resp.text().await?;
343 trace!(method = "POST", endpoint = %url, body = %text, "raw response");
344 return Ok(serde_json::from_str(&text)?);
345 } else {
346 return Ok(resp.json().await?);
347 }
348 }
349 }
350
351 pub async fn get_text(&self, path: &str) -> Result<String> {
357 let mut auth_retried = false;
358 loop {
359 let (token, api_server) = self.token_manager.get_token().await?;
360 let url = format!("{}v1{}", api_server, path);
361 debug!(method = "GET", endpoint = %url, "HTTP request (text)");
362
363 let resp = {
364 let mut attempt = 0u32;
365 loop {
366 let resp = self.http.get(&url).bearer_auth(&token).send().await?;
367
368 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
369 if attempt < MAX_RETRIES {
370 let delay = retry_after_or_backoff(&resp, attempt);
371 warn!(attempt = attempt + 1, max_retries = MAX_RETRIES, delay = ?delay, "rate limited, retrying");
372 tokio::time::sleep(delay).await;
373 attempt += 1;
374 continue;
375 }
376 return Err(QuestradeError::RateLimited {
377 retries: MAX_RETRIES,
378 });
379 }
380
381 break resp;
382 }
383 };
384
385 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
386 warn!("received 401 Unauthorized, forcing token refresh and retrying");
387 self.token_manager.force_refresh().await?;
388 auth_retried = true;
389 continue;
390 }
391
392 if !resp.status().is_success() {
393 let status = resp.status();
394 let body = resp.text().await.unwrap_or_default();
395 return Err(QuestradeError::Api { status, body });
396 }
397
398 return Ok(resp.text().await?);
399 }
400 }
401
402 pub fn parse_datetime(s: &str) -> Result<OffsetDateTime> {
406 OffsetDateTime::parse(s, &Iso8601::DEFAULT).map_err(|e| QuestradeError::DateTime {
407 context: format!("Failed to parse datetime: {}", s),
408 source: Box::new(e),
409 })
410 }
411
412 pub fn parse_date(s: &str) -> Result<time::Date> {
414 let dt = Self::parse_datetime(s)?;
415 Ok(dt.date())
416 }
417
418 pub async fn resolve_symbol(&self, ticker: &str) -> Result<u64> {
420 let key = ticker.to_uppercase();
421 let resp: SymbolSearchResponse =
422 self.get(&format!("/symbols/search?prefix={}", key)).await?;
423 let symbol = resp
424 .symbols
425 .into_iter()
426 .find(|s| s.symbol.to_uppercase() == key)
427 .ok_or_else(|| QuestradeError::SymbolNotFound(ticker.to_string()))?;
428 Ok(symbol.symbol_id)
429 }
430
431 pub async fn get_raw_quote(&self, symbol_id: u64) -> Result<Quote> {
433 let resp: QuoteResponse = self.get(&format!("/markets/quotes/{}", symbol_id)).await?;
434 resp.quotes
435 .into_iter()
436 .next()
437 .ok_or_else(|| QuestradeError::EmptyResponse("No quote returned".to_string()))
438 }
439
440 pub async fn get_option_chain_structure(&self, symbol_id: u64) -> Result<OptionChainResponse> {
442 self.get(&format!("/symbols/{}/options", symbol_id)).await
443 }
444
445 pub async fn get_option_quotes_by_ids(
448 &self,
449 symbol_ids: &[u64],
450 ) -> Result<HashMap<u64, (f64, f64)>> {
451 let mut result = HashMap::new();
452 for chunk in symbol_ids.chunks(100) {
453 let req = OptionQuoteRequest {
454 option_ids: chunk.to_vec(),
455 };
456 let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
457 for oq in resp.option_quotes {
458 result.insert(
459 oq.symbol_id,
460 (oq.bid_price.unwrap_or(0.0), oq.ask_price.unwrap_or(0.0)),
461 );
462 }
463 }
464 Ok(result)
465 }
466
467 pub async fn get_option_quotes_raw(&self, ids: &[u64]) -> Result<Vec<OptionQuote>> {
469 let mut result = Vec::new();
470 for chunk in ids.chunks(100) {
471 let req = OptionQuoteRequest {
472 option_ids: chunk.to_vec(),
473 };
474 let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
475 result.extend(resp.option_quotes);
476 }
477 Ok(result)
478 }
479
480 pub async fn get_candles(
482 &self,
483 symbol_id: u64,
484 start: OffsetDateTime,
485 end: OffsetDateTime,
486 interval: &str,
487 ) -> Result<Vec<Candle>> {
488 let start_str = start
489 .format(&Iso8601::DEFAULT)
490 .map_err(|e| QuestradeError::DateTime {
491 context: "Failed to format start time".to_string(),
492 source: Box::new(e),
493 })?;
494 let end_str = end
495 .format(&Iso8601::DEFAULT)
496 .map_err(|e| QuestradeError::DateTime {
497 context: "Failed to format end time".to_string(),
498 source: Box::new(e),
499 })?;
500 let resp: CandleResponse = self
501 .get(&format!(
502 "/markets/candles/{}?startTime={}&endTime={}&interval={}",
503 symbol_id, start_str, end_str, interval
504 ))
505 .await?;
506 Ok(resp.candles)
507 }
508
509 pub async fn get_server_time(&self) -> Result<OffsetDateTime> {
513 let resp: ServerTimeResponse = self.get("/time").await?;
514 Self::parse_datetime(&resp.time)
515 }
516
517 pub async fn get_accounts(&self) -> Result<Vec<Account>> {
519 let resp: AccountsResponse = self.get("/accounts").await?;
520 Ok(resp.accounts)
521 }
522
523 pub async fn get_positions(&self, account_id: &str) -> Result<Vec<PositionItem>> {
525 let resp: PositionsResponse = self
526 .get(&format!("/accounts/{}/positions", account_id))
527 .await?;
528 Ok(resp.positions)
529 }
530
531 pub async fn get_account_balances(&self, account_id: &str) -> Result<AccountBalances> {
533 self.get(&format!("/accounts/{}/balances", account_id))
534 .await
535 }
536
537 pub async fn get_markets(&self) -> Result<Vec<crate::api_types::MarketInfo>> {
539 let resp: crate::api_types::MarketsResponse = self.get("/markets").await?;
540 Ok(resp.markets)
541 }
542
543 pub async fn get_symbol(&self, symbol_id: u64) -> Result<SymbolDetail> {
545 let resp: SymbolDetailResponse = self.get(&format!("/symbols/{}", symbol_id)).await?;
546 resp.symbols.into_iter().next().ok_or_else(|| {
547 QuestradeError::EmptyResponse(format!("No symbol returned for id {}", symbol_id))
548 })
549 }
550
551 pub async fn get_activities(
558 &self,
559 account_id: &str,
560 start: OffsetDateTime,
561 end: OffsetDateTime,
562 ) -> Result<Vec<ActivityItem>> {
563 let windows = activity_windows(start, end);
564 let mut all = Vec::new();
565 for (w_start, w_end) in windows {
566 let start_str = format_query_datetime(w_start)?;
567 let end_str = format_query_datetime(w_end)?;
568 let resp: ActivitiesResponse = self
569 .get(&format!(
570 "/accounts/{}/activities?startTime={}&endTime={}",
571 account_id, start_str, end_str,
572 ))
573 .await?;
574 all.extend(resp.activities);
575 }
576 all.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
577 Ok(all)
578 }
579
580 pub async fn get_orders(
586 &self,
587 account_id: &str,
588 start: OffsetDateTime,
589 end: OffsetDateTime,
590 state_filter: OrderStateFilter,
591 ) -> Result<Vec<OrderItem>> {
592 let start_str = format_query_datetime(start)?;
593 let end_str = format_query_datetime(end)?;
594 let resp: OrdersResponse = self
595 .get(&format!(
596 "/accounts/{}/orders?startTime={}&endTime={}&stateFilter={}",
597 account_id, start_str, end_str, state_filter,
598 ))
599 .await?;
600 Ok(resp.orders)
601 }
602
603 pub async fn get_executions(
608 &self,
609 account_id: &str,
610 start: OffsetDateTime,
611 end: OffsetDateTime,
612 ) -> Result<Vec<Execution>> {
613 let windows = activity_windows(start, end);
614 let mut all = Vec::new();
615 for (w_start, w_end) in windows {
616 let start_str = format_query_datetime(w_start)?;
617 let end_str = format_query_datetime(w_end)?;
618 let resp: ExecutionsResponse = self
619 .get(&format!(
620 "/accounts/{}/executions?startTime={}&endTime={}",
621 account_id, start_str, end_str,
622 ))
623 .await?;
624 all.extend(resp.executions);
625 }
626 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
627 Ok(all)
628 }
629}
630
631fn activity_windows(
645 start: OffsetDateTime,
646 end: OffsetDateTime,
647) -> Vec<(OffsetDateTime, OffsetDateTime)> {
648 const MAX_WINDOW: time::Duration = time::Duration::days(30);
649 let mut windows = Vec::new();
650 let mut cursor = start;
651 while cursor < end {
652 let window_end = (cursor + MAX_WINDOW).min(end);
653 windows.push((cursor, window_end));
654 cursor = window_end;
655 }
656 windows
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::auth::{CachedToken, TokenManager};
663 use time::OffsetDateTime;
664 use wiremock::matchers::{header, method, path};
665 use wiremock::{Mock, MockServer, ResponseTemplate};
666
667 #[test]
668 fn server_time_response_deserializes() {
669 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
670 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
671 assert_eq!(resp.time, "2026-02-21T14:32:00.000000-05:00");
672 }
673
674 #[test]
675 fn parse_server_time_returns_correct_fields() {
676 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
677 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
678 let dt = QuestradeClient::parse_datetime(&resp.time).unwrap();
679 assert_eq!(dt.year(), 2026);
680 assert_eq!(dt.month(), time::Month::February);
681 assert_eq!(dt.day(), 21);
682 assert_eq!(dt.hour(), 14);
683 assert_eq!(dt.minute(), 32);
684 assert_eq!(dt.second(), 0);
685 assert_eq!(dt.offset().whole_hours(), -5);
686 }
687
688 #[test]
689 fn format_query_datetime_uses_utc_second_precision() {
690 let dt = OffsetDateTime::parse("2026-02-24T03:58:12.123456789-05:00", &Iso8601::DEFAULT)
691 .unwrap();
692 let s = format_query_datetime(dt).unwrap();
693 assert_eq!(s, "2026-02-24T08:58:12Z");
694 assert!(!s.contains('.'));
695 }
696
697 #[test]
698 fn backoff_delay_within_jitter_bounds() {
699 for attempt in 0..MAX_RETRIES {
700 for _ in 0..20 {
701 let delay = backoff_delay(attempt);
702 let base_ms = RETRY_BASE_DELAY_MS << attempt;
703 let min_ms = (base_ms as f64 * 0.8) as u64;
704 let max_ms = (base_ms as f64 * 1.2) as u64;
705 let actual_ms = delay.as_millis() as u64;
706 assert!(
707 actual_ms >= min_ms && actual_ms <= max_ms,
708 "attempt {attempt}: delay {actual_ms}ms not in [{min_ms}, {max_ms}]"
709 );
710 }
711 }
712 }
713
714 #[test]
715 fn backoff_delay_doubles_each_attempt() {
716 for attempt in 1..MAX_RETRIES {
717 let prev_base = RETRY_BASE_DELAY_MS << (attempt - 1);
718 let curr_base = RETRY_BASE_DELAY_MS << attempt;
719 assert_eq!(
720 curr_base,
721 prev_base * 2,
722 "base delay should double from attempt {} to {}",
723 attempt - 1,
724 attempt
725 );
726 }
727 }
728
729 #[test]
730 fn max_retries_constant() {
731 assert_eq!(MAX_RETRIES, 3, "expected 3 retries");
732 }
733
734 fn dt(s: &str) -> OffsetDateTime {
737 OffsetDateTime::parse(s, &Iso8601::DEFAULT).unwrap()
738 }
739
740 #[test]
741 fn activity_windows_empty_range_returns_empty() {
742 let start = dt("2026-01-01T00:00:00Z");
743 assert!(activity_windows(start, start).is_empty());
744 assert!(activity_windows(start, start - time::Duration::days(1)).is_empty());
746 }
747
748 #[test]
749 fn activity_windows_single_window_when_range_within_31_days() {
750 let start = dt("2026-01-01T00:00:00Z");
751 let end = start + time::Duration::days(30);
752 let windows = activity_windows(start, end);
753 assert_eq!(windows.len(), 1);
754 assert_eq!(windows[0], (start, end));
755 }
756
757 #[test]
758 fn activity_windows_exactly_30_days_is_single_window() {
759 let start = dt("2026-01-01T00:00:00Z");
760 let end = start + time::Duration::days(30);
761 let windows = activity_windows(start, end);
762 assert_eq!(windows.len(), 1);
763 assert_eq!(windows[0], (start, end));
764 }
765
766 #[test]
767 fn activity_windows_31_days_splits_into_two() {
768 let start = dt("2026-01-01T00:00:00Z");
769 let end = start + time::Duration::days(31);
770 let windows = activity_windows(start, end);
771 assert_eq!(windows.len(), 2);
772 assert_eq!(windows[0], (start, start + time::Duration::days(30)));
773 assert_eq!(windows[1], (start + time::Duration::days(30), end));
774 }
775
776 #[test]
777 fn activity_windows_365_days_all_within_limit_and_contiguous() {
778 let start = dt("2026-01-01T00:00:00Z");
779 let end = start + time::Duration::days(365);
780 let windows = activity_windows(start, end);
781 assert_eq!(windows.len(), 13);
783 assert_eq!(windows[0].0, start);
784 assert_eq!(windows.last().unwrap().1, end);
785 for (ws, we) in &windows {
786 assert!(
787 (*we - *ws).whole_days() <= 30,
788 "window exceeds 30 days: {} days",
789 (*we - *ws).whole_days()
790 );
791 }
792 for i in 1..windows.len() {
794 assert_eq!(
795 windows[i].0,
796 windows[i - 1].1,
797 "gap between window {i} and {}",
798 i - 1
799 );
800 }
801 }
802
803 #[tokio::test]
806 async fn get_retries_on_401_after_force_refresh() {
807 let server = MockServer::start().await;
808 let api_server = format!("{}/", server.uri());
809
810 Mock::given(method("GET"))
812 .and(path("/v1/time"))
813 .and(header("Authorization", "Bearer stale_token"))
814 .respond_with(ResponseTemplate::new(401))
815 .expect(1)
816 .named("stale request")
817 .mount(&server)
818 .await;
819
820 Mock::given(method("GET"))
822 .and(path("/oauth2/token"))
823 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
824 "access_token": "fresh_token",
825 "token_type": "Bearer",
826 "expires_in": 1800,
827 "refresh_token": "new_rt",
828 "api_server": api_server,
829 })))
830 .expect(1)
831 .named("oauth refresh")
832 .mount(&server)
833 .await;
834
835 Mock::given(method("GET"))
837 .and(path("/v1/time"))
838 .and(header("Authorization", "Bearer fresh_token"))
839 .respond_with(
840 ResponseTemplate::new(200)
841 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
842 )
843 .expect(1)
844 .named("fresh request")
845 .mount(&server)
846 .await;
847
848 let cached = CachedToken {
850 access_token: "stale_token".to_string(),
851 api_server: api_server.clone(),
852 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
853 };
854 let tm = TokenManager::new_with_login_url(
855 "old_rt".to_string(),
856 None,
857 server.uri(),
858 Some(cached),
859 )
860 .await
861 .unwrap();
862
863 let client = QuestradeClient::new(tm).unwrap();
864 let time = client.get_server_time().await.unwrap();
865 assert_eq!(time.year(), 2026);
866 }
867
868 #[tokio::test]
869 async fn get_does_not_retry_401_more_than_once() {
870 let server = MockServer::start().await;
871 let api_server = format!("{}/", server.uri());
872
873 Mock::given(method("GET"))
875 .and(path("/v1/time"))
876 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
877 .expect(2) .mount(&server)
879 .await;
880
881 Mock::given(method("GET"))
883 .and(path("/oauth2/token"))
884 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
885 "access_token": "still_bad",
886 "token_type": "Bearer",
887 "expires_in": 1800,
888 "refresh_token": "new_rt",
889 "api_server": api_server,
890 })))
891 .expect(1)
892 .mount(&server)
893 .await;
894
895 let cached = CachedToken {
896 access_token: "stale_token".to_string(),
897 api_server,
898 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
899 };
900 let tm = TokenManager::new_with_login_url(
901 "old_rt".to_string(),
902 None,
903 server.uri(),
904 Some(cached),
905 )
906 .await
907 .unwrap();
908
909 let client = QuestradeClient::new(tm).unwrap();
910 let result = client.get_server_time().await;
911 assert!(result.is_err());
912 assert!(
913 result.unwrap_err().to_string().contains("401"),
914 "error should mention 401"
915 );
916 }
917
918 #[tokio::test]
919 async fn post_retries_on_401_after_force_refresh() {
920 let server = MockServer::start().await;
921 let api_server = format!("{}/", server.uri());
922
923 Mock::given(method("POST"))
925 .and(path("/v1/markets/quotes/options"))
926 .and(header("Authorization", "Bearer stale_token"))
927 .respond_with(ResponseTemplate::new(401))
928 .expect(1)
929 .named("stale post")
930 .mount(&server)
931 .await;
932
933 Mock::given(method("GET"))
935 .and(path("/oauth2/token"))
936 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
937 "access_token": "fresh_token",
938 "token_type": "Bearer",
939 "expires_in": 1800,
940 "refresh_token": "new_rt",
941 "api_server": api_server,
942 })))
943 .expect(1)
944 .named("oauth refresh")
945 .mount(&server)
946 .await;
947
948 Mock::given(method("POST"))
950 .and(path("/v1/markets/quotes/options"))
951 .and(header("Authorization", "Bearer fresh_token"))
952 .respond_with(
953 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
954 )
955 .expect(1)
956 .named("fresh post")
957 .mount(&server)
958 .await;
959
960 let cached = CachedToken {
961 access_token: "stale_token".to_string(),
962 api_server,
963 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
964 };
965 let tm = TokenManager::new_with_login_url(
966 "old_rt".to_string(),
967 None,
968 server.uri(),
969 Some(cached),
970 )
971 .await
972 .unwrap();
973
974 let client = QuestradeClient::new(tm).unwrap();
975 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
976 assert!(quotes.is_empty());
977 }
978
979 #[tokio::test]
980 async fn get_with_raw_logging_deserializes_correctly() {
981 let server = MockServer::start().await;
982 let api_server = format!("{}/", server.uri());
983
984 Mock::given(method("GET"))
985 .and(path("/v1/time"))
986 .respond_with(
987 ResponseTemplate::new(200)
988 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
989 )
990 .expect(1)
991 .mount(&server)
992 .await;
993
994 let cached = CachedToken {
995 access_token: "token".to_string(),
996 api_server,
997 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
998 };
999 let tm =
1000 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1001 .await
1002 .unwrap();
1003
1004 let client = QuestradeClient::new(tm).unwrap().with_raw_logging(true);
1005 let time = client.get_server_time().await.unwrap();
1006 assert_eq!(time.year(), 2026);
1007 }
1008
1009 #[tokio::test]
1010 async fn get_text_returns_raw_body() {
1011 let server = MockServer::start().await;
1012 let api_server = format!("{}/", server.uri());
1013
1014 let expected_json = r#"{"time":"2026-03-02T12:00:00.000000-05:00"}"#;
1015 Mock::given(method("GET"))
1016 .and(path("/v1/time"))
1017 .respond_with(ResponseTemplate::new(200).set_body_string(expected_json))
1018 .expect(1)
1019 .mount(&server)
1020 .await;
1021
1022 let cached = CachedToken {
1023 access_token: "token".to_string(),
1024 api_server,
1025 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1026 };
1027 let tm =
1028 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1029 .await
1030 .unwrap();
1031
1032 let client = QuestradeClient::new(tm).unwrap();
1033 let text = client.get_text("/time").await.unwrap();
1034 assert_eq!(text, expected_json);
1035 }
1036
1037 #[tokio::test]
1040 async fn get_retries_on_429_then_succeeds() {
1041 let server = MockServer::start().await;
1042 let api_server = format!("{}/", server.uri());
1043
1044 Mock::given(method("GET"))
1045 .and(path("/v1/time"))
1046 .respond_with(ResponseTemplate::new(429))
1047 .expect(2)
1048 .up_to_n_times(2)
1049 .named("rate limited")
1050 .mount(&server)
1051 .await;
1052
1053 Mock::given(method("GET"))
1054 .and(path("/v1/time"))
1055 .respond_with(
1056 ResponseTemplate::new(200)
1057 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1058 )
1059 .expect(1)
1060 .named("success after rate limit")
1061 .mount(&server)
1062 .await;
1063
1064 let cached = CachedToken {
1065 access_token: "token".to_string(),
1066 api_server,
1067 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1068 };
1069 let tm =
1070 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1071 .await
1072 .unwrap();
1073
1074 let client = QuestradeClient::new(tm).unwrap();
1075 let time = client.get_server_time().await.unwrap();
1076 assert_eq!(time.year(), 2026);
1077 }
1078
1079 #[tokio::test]
1080 async fn post_retries_on_429_then_succeeds() {
1081 let server = MockServer::start().await;
1082 let api_server = format!("{}/", server.uri());
1083
1084 Mock::given(method("POST"))
1085 .and(path("/v1/markets/quotes/options"))
1086 .respond_with(ResponseTemplate::new(429))
1087 .expect(1)
1088 .up_to_n_times(1)
1089 .named("rate limited post")
1090 .mount(&server)
1091 .await;
1092
1093 Mock::given(method("POST"))
1094 .and(path("/v1/markets/quotes/options"))
1095 .respond_with(
1096 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1097 )
1098 .expect(1)
1099 .named("success post after rate limit")
1100 .mount(&server)
1101 .await;
1102
1103 let cached = CachedToken {
1104 access_token: "token".to_string(),
1105 api_server,
1106 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1107 };
1108 let tm =
1109 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1110 .await
1111 .unwrap();
1112
1113 let client = QuestradeClient::new(tm).unwrap();
1114 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1115 assert!(quotes.is_empty());
1116 }
1117
1118 #[tokio::test]
1119 async fn get_fails_after_max_429_retries() {
1120 let server = MockServer::start().await;
1121 let api_server = format!("{}/", server.uri());
1122
1123 Mock::given(method("GET"))
1124 .and(path("/v1/time"))
1125 .respond_with(ResponseTemplate::new(429))
1126 .expect((MAX_RETRIES + 1) as u64)
1127 .mount(&server)
1128 .await;
1129
1130 let cached = CachedToken {
1131 access_token: "token".to_string(),
1132 api_server,
1133 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1134 };
1135 let tm =
1136 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1137 .await
1138 .unwrap();
1139
1140 let client = QuestradeClient::new(tm).unwrap();
1141 let result = client.get_server_time().await;
1142 assert!(result.is_err());
1143 assert!(
1144 result.unwrap_err().to_string().contains("rate limit"),
1145 "error should mention rate limit"
1146 );
1147 }
1148
1149 #[test]
1150 fn retry_after_header_is_respected() {
1151 let resp = http::Response::builder()
1152 .status(429)
1153 .header("Retry-After", "5")
1154 .body("")
1155 .unwrap();
1156 let resp = reqwest::Response::from(resp);
1157 let delay = retry_after_or_backoff(&resp, 0);
1158 assert_eq!(delay, Duration::from_secs(5));
1159 }
1160
1161 #[test]
1162 fn retry_after_header_capped_at_60s() {
1163 let resp = http::Response::builder()
1164 .status(429)
1165 .header("Retry-After", "300")
1166 .body("")
1167 .unwrap();
1168 let resp = reqwest::Response::from(resp);
1169 let delay = retry_after_or_backoff(&resp, 0);
1170 assert_eq!(delay, Duration::from_secs(60));
1171 }
1172
1173 #[test]
1174 fn retry_after_missing_falls_back_to_backoff() {
1175 let resp = http::Response::builder().status(429).body("").unwrap();
1176 let resp = reqwest::Response::from(resp);
1177 let delay = retry_after_or_backoff(&resp, 0);
1178 let ms = delay.as_millis() as u64;
1179 assert!(ms >= 800 && ms <= 1200, "expected ~1000ms, got {}ms", ms);
1180 }
1181}