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_strategy_quotes(
486 &self,
487 variants: &[StrategyVariantRequest],
488 ) -> Result<Vec<StrategyQuote>> {
489 let req = StrategyQuoteRequest {
490 variants: variants.to_vec(),
491 };
492 let resp: StrategyQuotesResponse =
493 self.post("/markets/quotes/strategies", &req).await?;
494 Ok(resp.strategy_quotes)
495 }
496
497 pub async fn get_candles(
499 &self,
500 symbol_id: u64,
501 start: OffsetDateTime,
502 end: OffsetDateTime,
503 interval: &str,
504 ) -> Result<Vec<Candle>> {
505 let start_str = start
506 .format(&Iso8601::DEFAULT)
507 .map_err(|e| QuestradeError::DateTime {
508 context: "Failed to format start time".to_string(),
509 source: Box::new(e),
510 })?;
511 let end_str = end
512 .format(&Iso8601::DEFAULT)
513 .map_err(|e| QuestradeError::DateTime {
514 context: "Failed to format end time".to_string(),
515 source: Box::new(e),
516 })?;
517 let resp: CandleResponse = self
518 .get(&format!(
519 "/markets/candles/{}?startTime={}&endTime={}&interval={}",
520 symbol_id, start_str, end_str, interval
521 ))
522 .await?;
523 Ok(resp.candles)
524 }
525
526 pub async fn get_server_time(&self) -> Result<OffsetDateTime> {
530 let resp: ServerTimeResponse = self.get("/time").await?;
531 Self::parse_datetime(&resp.time)
532 }
533
534 pub async fn get_accounts(&self) -> Result<Vec<Account>> {
536 let resp: AccountsResponse = self.get("/accounts").await?;
537 Ok(resp.accounts)
538 }
539
540 pub async fn get_positions(&self, account_id: &str) -> Result<Vec<PositionItem>> {
542 let resp: PositionsResponse = self
543 .get(&format!("/accounts/{}/positions", account_id))
544 .await?;
545 Ok(resp.positions)
546 }
547
548 pub async fn get_account_balances(&self, account_id: &str) -> Result<AccountBalances> {
550 self.get(&format!("/accounts/{}/balances", account_id))
551 .await
552 }
553
554 pub async fn get_markets(&self) -> Result<Vec<crate::api_types::MarketInfo>> {
556 let resp: crate::api_types::MarketsResponse = self.get("/markets").await?;
557 Ok(resp.markets)
558 }
559
560 pub async fn get_symbol(&self, symbol_id: u64) -> Result<SymbolDetail> {
562 let resp: SymbolDetailResponse = self.get(&format!("/symbols/{}", symbol_id)).await?;
563 resp.symbols.into_iter().next().ok_or_else(|| {
564 QuestradeError::EmptyResponse(format!("No symbol returned for id {}", symbol_id))
565 })
566 }
567
568 pub async fn get_activities(
575 &self,
576 account_id: &str,
577 start: OffsetDateTime,
578 end: OffsetDateTime,
579 ) -> Result<Vec<ActivityItem>> {
580 let windows = activity_windows(start, end);
581 let mut all = Vec::new();
582 for (w_start, w_end) in windows {
583 let start_str = format_query_datetime(w_start)?;
584 let end_str = format_query_datetime(w_end)?;
585 let resp: ActivitiesResponse = self
586 .get(&format!(
587 "/accounts/{}/activities?startTime={}&endTime={}",
588 account_id, start_str, end_str,
589 ))
590 .await?;
591 all.extend(resp.activities);
592 }
593 all.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
594 Ok(all)
595 }
596
597 pub async fn get_orders(
603 &self,
604 account_id: &str,
605 start: OffsetDateTime,
606 end: OffsetDateTime,
607 state_filter: OrderStateFilter,
608 ) -> Result<Vec<OrderItem>> {
609 let start_str = format_query_datetime(start)?;
610 let end_str = format_query_datetime(end)?;
611 let resp: OrdersResponse = self
612 .get(&format!(
613 "/accounts/{}/orders?startTime={}&endTime={}&stateFilter={}",
614 account_id, start_str, end_str, state_filter,
615 ))
616 .await?;
617 Ok(resp.orders)
618 }
619
620 pub async fn get_executions(
625 &self,
626 account_id: &str,
627 start: OffsetDateTime,
628 end: OffsetDateTime,
629 ) -> Result<Vec<Execution>> {
630 let windows = activity_windows(start, end);
631 let mut all = Vec::new();
632 for (w_start, w_end) in windows {
633 let start_str = format_query_datetime(w_start)?;
634 let end_str = format_query_datetime(w_end)?;
635 let resp: ExecutionsResponse = self
636 .get(&format!(
637 "/accounts/{}/executions?startTime={}&endTime={}",
638 account_id, start_str, end_str,
639 ))
640 .await?;
641 all.extend(resp.executions);
642 }
643 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
644 Ok(all)
645 }
646}
647
648fn activity_windows(
662 start: OffsetDateTime,
663 end: OffsetDateTime,
664) -> Vec<(OffsetDateTime, OffsetDateTime)> {
665 const MAX_WINDOW: time::Duration = time::Duration::days(30);
666 let mut windows = Vec::new();
667 let mut cursor = start;
668 while cursor < end {
669 let window_end = (cursor + MAX_WINDOW).min(end);
670 windows.push((cursor, window_end));
671 cursor = window_end;
672 }
673 windows
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use crate::auth::{CachedToken, TokenManager};
680 use time::OffsetDateTime;
681 use wiremock::matchers::{header, method, path};
682 use wiremock::{Mock, MockServer, ResponseTemplate};
683
684 #[test]
685 fn server_time_response_deserializes() {
686 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
687 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
688 assert_eq!(resp.time, "2026-02-21T14:32:00.000000-05:00");
689 }
690
691 #[test]
692 fn parse_server_time_returns_correct_fields() {
693 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
694 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
695 let dt = QuestradeClient::parse_datetime(&resp.time).unwrap();
696 assert_eq!(dt.year(), 2026);
697 assert_eq!(dt.month(), time::Month::February);
698 assert_eq!(dt.day(), 21);
699 assert_eq!(dt.hour(), 14);
700 assert_eq!(dt.minute(), 32);
701 assert_eq!(dt.second(), 0);
702 assert_eq!(dt.offset().whole_hours(), -5);
703 }
704
705 #[test]
706 fn format_query_datetime_uses_utc_second_precision() {
707 let dt = OffsetDateTime::parse("2026-02-24T03:58:12.123456789-05:00", &Iso8601::DEFAULT)
708 .unwrap();
709 let s = format_query_datetime(dt).unwrap();
710 assert_eq!(s, "2026-02-24T08:58:12Z");
711 assert!(!s.contains('.'));
712 }
713
714 #[test]
715 fn backoff_delay_within_jitter_bounds() {
716 for attempt in 0..MAX_RETRIES {
717 for _ in 0..20 {
718 let delay = backoff_delay(attempt);
719 let base_ms = RETRY_BASE_DELAY_MS << attempt;
720 let min_ms = (base_ms as f64 * 0.8) as u64;
721 let max_ms = (base_ms as f64 * 1.2) as u64;
722 let actual_ms = delay.as_millis() as u64;
723 assert!(
724 actual_ms >= min_ms && actual_ms <= max_ms,
725 "attempt {attempt}: delay {actual_ms}ms not in [{min_ms}, {max_ms}]"
726 );
727 }
728 }
729 }
730
731 #[test]
732 fn backoff_delay_doubles_each_attempt() {
733 for attempt in 1..MAX_RETRIES {
734 let prev_base = RETRY_BASE_DELAY_MS << (attempt - 1);
735 let curr_base = RETRY_BASE_DELAY_MS << attempt;
736 assert_eq!(
737 curr_base,
738 prev_base * 2,
739 "base delay should double from attempt {} to {}",
740 attempt - 1,
741 attempt
742 );
743 }
744 }
745
746 #[test]
747 fn max_retries_constant() {
748 assert_eq!(MAX_RETRIES, 3, "expected 3 retries");
749 }
750
751 fn dt(s: &str) -> OffsetDateTime {
754 OffsetDateTime::parse(s, &Iso8601::DEFAULT).unwrap()
755 }
756
757 #[test]
758 fn activity_windows_empty_range_returns_empty() {
759 let start = dt("2026-01-01T00:00:00Z");
760 assert!(activity_windows(start, start).is_empty());
761 assert!(activity_windows(start, start - time::Duration::days(1)).is_empty());
763 }
764
765 #[test]
766 fn activity_windows_single_window_when_range_within_31_days() {
767 let start = dt("2026-01-01T00:00:00Z");
768 let end = start + time::Duration::days(30);
769 let windows = activity_windows(start, end);
770 assert_eq!(windows.len(), 1);
771 assert_eq!(windows[0], (start, end));
772 }
773
774 #[test]
775 fn activity_windows_exactly_30_days_is_single_window() {
776 let start = dt("2026-01-01T00:00:00Z");
777 let end = start + time::Duration::days(30);
778 let windows = activity_windows(start, end);
779 assert_eq!(windows.len(), 1);
780 assert_eq!(windows[0], (start, end));
781 }
782
783 #[test]
784 fn activity_windows_31_days_splits_into_two() {
785 let start = dt("2026-01-01T00:00:00Z");
786 let end = start + time::Duration::days(31);
787 let windows = activity_windows(start, end);
788 assert_eq!(windows.len(), 2);
789 assert_eq!(windows[0], (start, start + time::Duration::days(30)));
790 assert_eq!(windows[1], (start + time::Duration::days(30), end));
791 }
792
793 #[test]
794 fn activity_windows_365_days_all_within_limit_and_contiguous() {
795 let start = dt("2026-01-01T00:00:00Z");
796 let end = start + time::Duration::days(365);
797 let windows = activity_windows(start, end);
798 assert_eq!(windows.len(), 13);
800 assert_eq!(windows[0].0, start);
801 assert_eq!(windows.last().unwrap().1, end);
802 for (ws, we) in &windows {
803 assert!(
804 (*we - *ws).whole_days() <= 30,
805 "window exceeds 30 days: {} days",
806 (*we - *ws).whole_days()
807 );
808 }
809 for i in 1..windows.len() {
811 assert_eq!(
812 windows[i].0,
813 windows[i - 1].1,
814 "gap between window {i} and {}",
815 i - 1
816 );
817 }
818 }
819
820 #[tokio::test]
823 async fn get_retries_on_401_after_force_refresh() {
824 let server = MockServer::start().await;
825 let api_server = format!("{}/", server.uri());
826
827 Mock::given(method("GET"))
829 .and(path("/v1/time"))
830 .and(header("Authorization", "Bearer stale_token"))
831 .respond_with(ResponseTemplate::new(401))
832 .expect(1)
833 .named("stale request")
834 .mount(&server)
835 .await;
836
837 Mock::given(method("GET"))
839 .and(path("/oauth2/token"))
840 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
841 "access_token": "fresh_token",
842 "token_type": "Bearer",
843 "expires_in": 1800,
844 "refresh_token": "new_rt",
845 "api_server": api_server,
846 })))
847 .expect(1)
848 .named("oauth refresh")
849 .mount(&server)
850 .await;
851
852 Mock::given(method("GET"))
854 .and(path("/v1/time"))
855 .and(header("Authorization", "Bearer fresh_token"))
856 .respond_with(
857 ResponseTemplate::new(200)
858 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
859 )
860 .expect(1)
861 .named("fresh request")
862 .mount(&server)
863 .await;
864
865 let cached = CachedToken {
867 access_token: "stale_token".to_string(),
868 api_server: api_server.clone(),
869 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
870 };
871 let tm = TokenManager::new_with_login_url(
872 "old_rt".to_string(),
873 None,
874 server.uri(),
875 Some(cached),
876 )
877 .await
878 .unwrap();
879
880 let client = QuestradeClient::new(tm).unwrap();
881 let time = client.get_server_time().await.unwrap();
882 assert_eq!(time.year(), 2026);
883 }
884
885 #[tokio::test]
886 async fn get_does_not_retry_401_more_than_once() {
887 let server = MockServer::start().await;
888 let api_server = format!("{}/", server.uri());
889
890 Mock::given(method("GET"))
892 .and(path("/v1/time"))
893 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
894 .expect(2) .mount(&server)
896 .await;
897
898 Mock::given(method("GET"))
900 .and(path("/oauth2/token"))
901 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
902 "access_token": "still_bad",
903 "token_type": "Bearer",
904 "expires_in": 1800,
905 "refresh_token": "new_rt",
906 "api_server": api_server,
907 })))
908 .expect(1)
909 .mount(&server)
910 .await;
911
912 let cached = CachedToken {
913 access_token: "stale_token".to_string(),
914 api_server,
915 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
916 };
917 let tm = TokenManager::new_with_login_url(
918 "old_rt".to_string(),
919 None,
920 server.uri(),
921 Some(cached),
922 )
923 .await
924 .unwrap();
925
926 let client = QuestradeClient::new(tm).unwrap();
927 let result = client.get_server_time().await;
928 assert!(result.is_err());
929 assert!(
930 result.unwrap_err().to_string().contains("401"),
931 "error should mention 401"
932 );
933 }
934
935 #[tokio::test]
936 async fn post_retries_on_401_after_force_refresh() {
937 let server = MockServer::start().await;
938 let api_server = format!("{}/", server.uri());
939
940 Mock::given(method("POST"))
942 .and(path("/v1/markets/quotes/options"))
943 .and(header("Authorization", "Bearer stale_token"))
944 .respond_with(ResponseTemplate::new(401))
945 .expect(1)
946 .named("stale post")
947 .mount(&server)
948 .await;
949
950 Mock::given(method("GET"))
952 .and(path("/oauth2/token"))
953 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
954 "access_token": "fresh_token",
955 "token_type": "Bearer",
956 "expires_in": 1800,
957 "refresh_token": "new_rt",
958 "api_server": api_server,
959 })))
960 .expect(1)
961 .named("oauth refresh")
962 .mount(&server)
963 .await;
964
965 Mock::given(method("POST"))
967 .and(path("/v1/markets/quotes/options"))
968 .and(header("Authorization", "Bearer fresh_token"))
969 .respond_with(
970 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
971 )
972 .expect(1)
973 .named("fresh post")
974 .mount(&server)
975 .await;
976
977 let cached = CachedToken {
978 access_token: "stale_token".to_string(),
979 api_server,
980 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
981 };
982 let tm = TokenManager::new_with_login_url(
983 "old_rt".to_string(),
984 None,
985 server.uri(),
986 Some(cached),
987 )
988 .await
989 .unwrap();
990
991 let client = QuestradeClient::new(tm).unwrap();
992 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
993 assert!(quotes.is_empty());
994 }
995
996 #[tokio::test]
997 async fn get_with_raw_logging_deserializes_correctly() {
998 let server = MockServer::start().await;
999 let api_server = format!("{}/", server.uri());
1000
1001 Mock::given(method("GET"))
1002 .and(path("/v1/time"))
1003 .respond_with(
1004 ResponseTemplate::new(200)
1005 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1006 )
1007 .expect(1)
1008 .mount(&server)
1009 .await;
1010
1011 let cached = CachedToken {
1012 access_token: "token".to_string(),
1013 api_server,
1014 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1015 };
1016 let tm =
1017 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1018 .await
1019 .unwrap();
1020
1021 let client = QuestradeClient::new(tm).unwrap().with_raw_logging(true);
1022 let time = client.get_server_time().await.unwrap();
1023 assert_eq!(time.year(), 2026);
1024 }
1025
1026 #[tokio::test]
1027 async fn get_text_returns_raw_body() {
1028 let server = MockServer::start().await;
1029 let api_server = format!("{}/", server.uri());
1030
1031 let expected_json = r#"{"time":"2026-03-02T12:00:00.000000-05:00"}"#;
1032 Mock::given(method("GET"))
1033 .and(path("/v1/time"))
1034 .respond_with(ResponseTemplate::new(200).set_body_string(expected_json))
1035 .expect(1)
1036 .mount(&server)
1037 .await;
1038
1039 let cached = CachedToken {
1040 access_token: "token".to_string(),
1041 api_server,
1042 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1043 };
1044 let tm =
1045 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1046 .await
1047 .unwrap();
1048
1049 let client = QuestradeClient::new(tm).unwrap();
1050 let text = client.get_text("/time").await.unwrap();
1051 assert_eq!(text, expected_json);
1052 }
1053
1054 #[tokio::test]
1057 async fn get_retries_on_429_then_succeeds() {
1058 let server = MockServer::start().await;
1059 let api_server = format!("{}/", server.uri());
1060
1061 Mock::given(method("GET"))
1062 .and(path("/v1/time"))
1063 .respond_with(ResponseTemplate::new(429))
1064 .expect(2)
1065 .up_to_n_times(2)
1066 .named("rate limited")
1067 .mount(&server)
1068 .await;
1069
1070 Mock::given(method("GET"))
1071 .and(path("/v1/time"))
1072 .respond_with(
1073 ResponseTemplate::new(200)
1074 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1075 )
1076 .expect(1)
1077 .named("success after rate limit")
1078 .mount(&server)
1079 .await;
1080
1081 let cached = CachedToken {
1082 access_token: "token".to_string(),
1083 api_server,
1084 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1085 };
1086 let tm =
1087 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1088 .await
1089 .unwrap();
1090
1091 let client = QuestradeClient::new(tm).unwrap();
1092 let time = client.get_server_time().await.unwrap();
1093 assert_eq!(time.year(), 2026);
1094 }
1095
1096 #[tokio::test]
1097 async fn post_retries_on_429_then_succeeds() {
1098 let server = MockServer::start().await;
1099 let api_server = format!("{}/", server.uri());
1100
1101 Mock::given(method("POST"))
1102 .and(path("/v1/markets/quotes/options"))
1103 .respond_with(ResponseTemplate::new(429))
1104 .expect(1)
1105 .up_to_n_times(1)
1106 .named("rate limited post")
1107 .mount(&server)
1108 .await;
1109
1110 Mock::given(method("POST"))
1111 .and(path("/v1/markets/quotes/options"))
1112 .respond_with(
1113 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1114 )
1115 .expect(1)
1116 .named("success post after rate limit")
1117 .mount(&server)
1118 .await;
1119
1120 let cached = CachedToken {
1121 access_token: "token".to_string(),
1122 api_server,
1123 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1124 };
1125 let tm =
1126 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1127 .await
1128 .unwrap();
1129
1130 let client = QuestradeClient::new(tm).unwrap();
1131 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1132 assert!(quotes.is_empty());
1133 }
1134
1135 #[tokio::test]
1136 async fn get_fails_after_max_429_retries() {
1137 let server = MockServer::start().await;
1138 let api_server = format!("{}/", server.uri());
1139
1140 Mock::given(method("GET"))
1141 .and(path("/v1/time"))
1142 .respond_with(ResponseTemplate::new(429))
1143 .expect((MAX_RETRIES + 1) as u64)
1144 .mount(&server)
1145 .await;
1146
1147 let cached = CachedToken {
1148 access_token: "token".to_string(),
1149 api_server,
1150 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1151 };
1152 let tm =
1153 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1154 .await
1155 .unwrap();
1156
1157 let client = QuestradeClient::new(tm).unwrap();
1158 let result = client.get_server_time().await;
1159 assert!(result.is_err());
1160 assert!(
1161 result.unwrap_err().to_string().contains("rate limit"),
1162 "error should mention rate limit"
1163 );
1164 }
1165
1166 #[test]
1167 fn retry_after_header_is_respected() {
1168 let resp = http::Response::builder()
1169 .status(429)
1170 .header("Retry-After", "5")
1171 .body("")
1172 .unwrap();
1173 let resp = reqwest::Response::from(resp);
1174 let delay = retry_after_or_backoff(&resp, 0);
1175 assert_eq!(delay, Duration::from_secs(5));
1176 }
1177
1178 #[test]
1179 fn retry_after_header_capped_at_60s() {
1180 let resp = http::Response::builder()
1181 .status(429)
1182 .header("Retry-After", "300")
1183 .body("")
1184 .unwrap();
1185 let resp = reqwest::Response::from(resp);
1186 let delay = retry_after_or_backoff(&resp, 0);
1187 assert_eq!(delay, Duration::from_secs(60));
1188 }
1189
1190 #[test]
1191 fn retry_after_missing_falls_back_to_backoff() {
1192 let resp = http::Response::builder().status(429).body("").unwrap();
1193 let resp = reqwest::Response::from(resp);
1194 let delay = retry_after_or_backoff(&resp, 0);
1195 let ms = delay.as_millis() as u64;
1196 assert!(ms >= 800 && ms <= 1200, "expected ~1000ms, got {}ms", ms);
1197 }
1198}