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, error, trace, warn};
10
11use crate::api_types::*;
12use crate::auth::TokenManager;
13use crate::error::{QuestradeError, Result};
14use crate::rate_limit::RateLimiter;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
20const MAX_RETRIES: u32 = 3;
22const RETRY_BASE_DELAY_MS: u64 = 1000;
24
25fn backoff_delay(attempt: u32) -> Duration {
31 let base_ms = RETRY_BASE_DELAY_MS << attempt; let jitter_factor = rand::thread_rng().gen_range(0.8f64..=1.2f64);
33 let delay_ms = (base_ms as f64 * jitter_factor) as u64;
34 Duration::from_millis(delay_ms)
35}
36
37fn retry_after_or_backoff(response: &reqwest::Response, attempt: u32) -> Duration {
43 if let Some(val) = response.headers().get(reqwest::header::RETRY_AFTER)
44 && let Ok(s) = val.to_str()
45 && let Ok(secs) = s.trim().parse::<u64>()
46 {
47 let capped = secs.min(60);
48 return Duration::from_secs(capped);
49 }
50 backoff_delay(attempt)
51}
52
53fn format_query_datetime(dt: OffsetDateTime) -> Result<String> {
58 let utc = dt.to_offset(time::UtcOffset::UTC);
59 let fmt = time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]Z")
60 .map_err(|e| QuestradeError::DateTime {
61 context: "Failed to build datetime format".to_string(),
62 source: Box::new(e),
63 })?;
64 utc.format(&fmt).map_err(|e| QuestradeError::DateTime {
65 context: "Failed to format datetime for query parameter".to_string(),
66 source: Box::new(e),
67 })
68}
69
70pub struct QuestradeClient {
95 http: reqwest::Client,
96 token_manager: TokenManager,
97 log_raw_responses: bool,
98 rate_limiter: RateLimiter,
99}
100
101pub struct QuestradeClientBuilder {
125 token_manager: Option<TokenManager>,
126 http_client: Option<reqwest::Client>,
127}
128
129impl QuestradeClientBuilder {
130 pub fn new() -> Self {
132 Self {
133 token_manager: None,
134 http_client: None,
135 }
136 }
137
138 pub fn token_manager(mut self, tm: TokenManager) -> Self {
140 self.token_manager = Some(tm);
141 self
142 }
143
144 pub fn http_client(mut self, client: reqwest::Client) -> Self {
151 self.http_client = Some(client);
152 self
153 }
154
155 pub fn build(self) -> Result<QuestradeClient> {
164 let token_manager = self.token_manager.ok_or_else(|| {
165 QuestradeError::EmptyResponse(
166 "QuestradeClientBuilder: token_manager is required".to_string(),
167 )
168 })?;
169
170 let http = match self.http_client {
171 Some(client) => client,
172 None => reqwest::Client::builder()
173 .timeout(REQUEST_TIMEOUT)
174 .connect_timeout(CONNECT_TIMEOUT)
175 .build()?,
176 };
177
178 Ok(QuestradeClient {
179 http,
180 token_manager,
181 log_raw_responses: false,
182 rate_limiter: RateLimiter::new(),
183 })
184 }
185}
186
187impl Default for QuestradeClientBuilder {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl QuestradeClient {
194 pub fn new(token_manager: TokenManager) -> Result<Self> {
213 QuestradeClientBuilder::new()
214 .token_manager(token_manager)
215 .build()
216 }
217
218 pub fn with_raw_logging(mut self, enabled: bool) -> Self {
225 self.log_raw_responses = enabled;
226 self
227 }
228
229 async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
239 let category = RateLimiter::classify(path);
240 let mut auth_retried = false;
241 loop {
242 let (token, api_server) = self.token_manager.get_token().await?;
243 let url = format!("{}v1{}", api_server, path);
244 debug!(method = "GET", endpoint = %url, "HTTP request");
245
246 let resp = {
247 let mut attempt = 0u32;
248 loop {
249 if let Some(wait) = self.rate_limiter.wait_duration(category) {
250 debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
251 tokio::time::sleep(wait).await;
252 }
253
254 let resp = match self.http.get(&url).bearer_auth(&token).send().await {
255 Ok(r) => r,
256 Err(e) => {
257 error!(method = "GET", endpoint = %url, err = %e, "HTTP send failed");
258 return Err(e.into());
259 }
260 };
261 self.rate_limiter
262 .update_from_headers(category, resp.headers());
263
264 debug!(method = "GET", endpoint = %url, status = %resp.status(), "HTTP response");
265
266 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
267 if attempt < MAX_RETRIES {
268 if self.rate_limiter.wait_duration(category).is_none() {
272 let delay = retry_after_or_backoff(&resp, attempt);
273 warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response, no rate-limit headers, backing off");
274 tokio::time::sleep(delay).await;
275 }
276 attempt += 1;
277 continue;
278 }
279 return Err(QuestradeError::RateLimited {
280 retries: MAX_RETRIES,
281 });
282 }
283
284 break resp;
285 }
286 };
287
288 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
289 warn!("received 401 Unauthorized, forcing token refresh and retrying");
290 self.token_manager.force_refresh().await?;
291 auth_retried = true;
292 continue;
293 }
294
295 if !resp.status().is_success() {
296 let status = resp.status();
297 let body = resp.text().await.unwrap_or_default();
298 error!(method = "GET", endpoint = %url, status = %status, body = %body, "HTTP error response");
299 return Err(QuestradeError::Api { status, body });
300 }
301
302 if self.log_raw_responses {
303 let text = resp.text().await?;
304 trace!(method = "GET", endpoint = %url, body = %text, "raw response");
305 return Ok(serde_json::from_str(&text)?);
306 } else {
307 return Ok(resp.json().await?);
308 }
309 }
310 }
311
312 async fn post<T: serde::de::DeserializeOwned, B: serde::Serialize>(
316 &self,
317 path: &str,
318 body: &B,
319 ) -> Result<T> {
320 let category = RateLimiter::classify(path);
321 let mut auth_retried = false;
322 loop {
323 let (token, api_server) = self.token_manager.get_token().await?;
324 let url = format!("{}v1{}", api_server, path);
325 debug!(method = "POST", endpoint = %url, "HTTP request");
326
327 let resp = {
328 let mut attempt = 0u32;
329 loop {
330 if let Some(wait) = self.rate_limiter.wait_duration(category) {
331 debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
332 tokio::time::sleep(wait).await;
333 }
334
335 let resp = match self
336 .http
337 .post(&url)
338 .bearer_auth(&token)
339 .json(body)
340 .send()
341 .await
342 {
343 Ok(r) => r,
344 Err(e) => {
345 error!(method = "POST", endpoint = %url, err = %e, "HTTP send failed");
346 return Err(e.into());
347 }
348 };
349 self.rate_limiter
350 .update_from_headers(category, resp.headers());
351
352 debug!(method = "POST", endpoint = %url, status = %resp.status(), "HTTP response");
353
354 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
355 if attempt < MAX_RETRIES {
356 if self.rate_limiter.wait_duration(category).is_none() {
357 let delay = retry_after_or_backoff(&resp, attempt);
358 warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response (POST), no rate-limit headers, backing off");
359 tokio::time::sleep(delay).await;
360 }
361 attempt += 1;
362 continue;
363 }
364 return Err(QuestradeError::RateLimited {
365 retries: MAX_RETRIES,
366 });
367 }
368
369 break resp;
370 }
371 };
372
373 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
374 warn!("received 401 Unauthorized, forcing token refresh and retrying");
375 self.token_manager.force_refresh().await?;
376 auth_retried = true;
377 continue;
378 }
379
380 if !resp.status().is_success() {
381 let status = resp.status();
382 let body_text = resp.text().await.unwrap_or_default();
383 error!(method = "POST", endpoint = %url, status = %status, body = %body_text, "HTTP error response");
384 return Err(QuestradeError::Api {
385 status,
386 body: body_text,
387 });
388 }
389
390 if self.log_raw_responses {
391 let text = resp.text().await?;
392 trace!(method = "POST", endpoint = %url, body = %text, "raw response");
393 return Ok(serde_json::from_str(&text)?);
394 } else {
395 return Ok(resp.json().await?);
396 }
397 }
398 }
399
400 pub async fn get_text(&self, path: &str) -> Result<String> {
406 let category = RateLimiter::classify(path);
407 let mut auth_retried = false;
408 loop {
409 let (token, api_server) = self.token_manager.get_token().await?;
410 let url = format!("{}v1{}", api_server, path);
411 debug!(method = "GET", endpoint = %url, "HTTP request (text)");
412
413 let resp = {
414 let mut attempt = 0u32;
415 loop {
416 if let Some(wait) = self.rate_limiter.wait_duration(category) {
417 debug!(category = %category, wait = ?wait, "sleeping until rate-limit window resets");
418 tokio::time::sleep(wait).await;
419 }
420
421 let resp = match self.http.get(&url).bearer_auth(&token).send().await {
422 Ok(r) => r,
423 Err(e) => {
424 error!(method = "GET", endpoint = %url, err = %e, "HTTP send failed (text)");
425 return Err(e.into());
426 }
427 };
428 self.rate_limiter
429 .update_from_headers(category, resp.headers());
430
431 debug!(method = "GET", endpoint = %url, status = %resp.status(), "HTTP response (text)");
432
433 if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
434 if attempt < MAX_RETRIES {
435 if self.rate_limiter.wait_duration(category).is_none() {
436 let delay = retry_after_or_backoff(&resp, attempt);
437 warn!(attempt = attempt + 1, delay = ?delay, reason = "429", "rate limited: 429 response, no rate-limit headers, backing off");
438 tokio::time::sleep(delay).await;
439 }
440 attempt += 1;
441 continue;
442 }
443 return Err(QuestradeError::RateLimited {
444 retries: MAX_RETRIES,
445 });
446 }
447
448 break resp;
449 }
450 };
451
452 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && !auth_retried {
453 warn!("received 401 Unauthorized, forcing token refresh and retrying");
454 self.token_manager.force_refresh().await?;
455 auth_retried = true;
456 continue;
457 }
458
459 if !resp.status().is_success() {
460 let status = resp.status();
461 let body = resp.text().await.unwrap_or_default();
462 error!(method = "GET", endpoint = %url, status = %status, body = %body, "HTTP error response (text)");
463 return Err(QuestradeError::Api { status, body });
464 }
465
466 return Ok(resp.text().await?);
467 }
468 }
469
470 pub fn parse_datetime(s: &str) -> Result<OffsetDateTime> {
474 OffsetDateTime::parse(s, &Iso8601::DEFAULT).map_err(|e| QuestradeError::DateTime {
475 context: format!("Failed to parse datetime: {}", s),
476 source: Box::new(e),
477 })
478 }
479
480 pub fn parse_date(s: &str) -> Result<time::Date> {
482 let dt = Self::parse_datetime(s)?;
483 Ok(dt.date())
484 }
485
486 pub async fn resolve_symbol(&self, ticker: &str) -> Result<u64> {
488 let key = ticker.to_uppercase();
489 let resp: SymbolSearchResponse =
490 self.get(&format!("/symbols/search?prefix={}", key)).await?;
491 let symbol = resp
492 .symbols
493 .into_iter()
494 .find(|s| s.symbol.to_uppercase() == key)
495 .ok_or_else(|| QuestradeError::SymbolNotFound(ticker.to_string()))?;
496 Ok(symbol.symbol_id)
497 }
498
499 pub async fn get_raw_quote(&self, symbol_id: u64) -> Result<Quote> {
501 let resp: QuoteResponse = self.get(&format!("/markets/quotes/{}", symbol_id)).await?;
502 resp.quotes
503 .into_iter()
504 .next()
505 .ok_or_else(|| QuestradeError::EmptyResponse("No quote returned".to_string()))
506 }
507
508 pub async fn get_raw_quotes(&self, symbol_ids: &[u64]) -> Result<Vec<Quote>> {
513 if symbol_ids.is_empty() {
514 return Ok(vec![]);
515 }
516 let ids = symbol_ids
517 .iter()
518 .map(|id| id.to_string())
519 .collect::<Vec<_>>()
520 .join(",");
521 let resp: QuoteResponse = self.get(&format!("/markets/quotes?ids={}", ids)).await?;
522 Ok(resp.quotes)
523 }
524
525 pub async fn get_option_chain_structure(&self, symbol_id: u64) -> Result<OptionChainResponse> {
527 self.get(&format!("/symbols/{}/options", symbol_id)).await
528 }
529
530 pub async fn get_option_quotes_by_ids(
533 &self,
534 symbol_ids: &[u64],
535 ) -> Result<HashMap<u64, (f64, f64)>> {
536 let mut result = HashMap::new();
537 for chunk in symbol_ids.chunks(100) {
538 let req = OptionQuoteRequest {
539 option_ids: chunk.to_vec(),
540 };
541 let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
542 for oq in resp.option_quotes {
543 result.insert(
544 oq.symbol_id,
545 (oq.bid_price.unwrap_or(0.0), oq.ask_price.unwrap_or(0.0)),
546 );
547 }
548 }
549 Ok(result)
550 }
551
552 pub async fn get_option_quotes_raw(&self, ids: &[u64]) -> Result<Vec<OptionQuote>> {
554 let mut result = Vec::new();
555 for chunk in ids.chunks(100) {
556 let req = OptionQuoteRequest {
557 option_ids: chunk.to_vec(),
558 };
559 let resp: OptionQuoteResponse = self.post("/markets/quotes/options", &req).await?;
560 result.extend(resp.option_quotes);
561 }
562 Ok(result)
563 }
564
565 pub async fn get_strategy_quotes(
571 &self,
572 variants: &[StrategyVariantRequest],
573 ) -> Result<Vec<StrategyQuote>> {
574 let req = StrategyQuoteRequest {
575 variants: variants.to_vec(),
576 };
577 let resp: StrategyQuotesResponse = self.post("/markets/quotes/strategies", &req).await?;
578 Ok(resp.strategy_quotes)
579 }
580
581 pub async fn get_candles(
583 &self,
584 symbol_id: u64,
585 start: OffsetDateTime,
586 end: OffsetDateTime,
587 interval: &str,
588 ) -> Result<Vec<Candle>> {
589 let start_str = format_query_datetime(start)?;
590 let end_str = format_query_datetime(end)?;
591 let resp: CandleResponse = self
592 .get(&format!(
593 "/markets/candles/{}?startTime={}&endTime={}&interval={}",
594 symbol_id, start_str, end_str, interval
595 ))
596 .await?;
597 Ok(resp.candles)
598 }
599
600 pub async fn get_server_time(&self) -> Result<OffsetDateTime> {
604 let resp: ServerTimeResponse = self.get("/time").await?;
605 Self::parse_datetime(&resp.time)
606 }
607
608 pub async fn get_accounts(&self) -> Result<Vec<Account>> {
610 let resp: AccountsResponse = self.get("/accounts").await?;
611 Ok(resp.accounts)
612 }
613
614 pub async fn get_positions(&self, account_id: &str) -> Result<Vec<PositionItem>> {
616 let resp: PositionsResponse = self
617 .get(&format!("/accounts/{}/positions", account_id))
618 .await?;
619 Ok(resp.positions)
620 }
621
622 pub async fn get_account_balances(&self, account_id: &str) -> Result<AccountBalances> {
624 self.get(&format!("/accounts/{}/balances", account_id))
625 .await
626 }
627
628 pub async fn get_markets(&self) -> Result<Vec<crate::api_types::MarketInfo>> {
630 let resp: crate::api_types::MarketsResponse = self.get("/markets").await?;
631 Ok(resp.markets)
632 }
633
634 pub async fn get_symbol(&self, symbol_id: u64) -> Result<SymbolDetail> {
636 let resp: SymbolDetailResponse = self.get(&format!("/symbols/{}", symbol_id)).await?;
637 resp.symbols.into_iter().next().ok_or_else(|| {
638 QuestradeError::EmptyResponse(format!("No symbol returned for id {}", symbol_id))
639 })
640 }
641
642 pub async fn get_symbols(&self, symbol_ids: &[u64]) -> Result<Vec<SymbolDetail>> {
647 if symbol_ids.is_empty() {
648 return Ok(vec![]);
649 }
650 let ids = symbol_ids
651 .iter()
652 .map(|id| id.to_string())
653 .collect::<Vec<_>>()
654 .join(",");
655 let resp: SymbolDetailResponse = self.get(&format!("/symbols?ids={}", ids)).await?;
656 Ok(resp.symbols)
657 }
658
659 pub async fn get_activities(
666 &self,
667 account_id: &str,
668 start: OffsetDateTime,
669 end: OffsetDateTime,
670 ) -> Result<Vec<ActivityItem>> {
671 let windows = activity_windows(start, end);
672 let mut all = Vec::new();
673 for (w_start, w_end) in windows {
674 let start_str = format_query_datetime(w_start)?;
675 let end_str = format_query_datetime(w_end)?;
676 let resp: ActivitiesResponse = self
677 .get(&format!(
678 "/accounts/{}/activities?startTime={}&endTime={}",
679 account_id, start_str, end_str,
680 ))
681 .await?;
682 all.extend(resp.activities);
683 }
684 all.sort_by(|a, b| a.trade_date.cmp(&b.trade_date));
685 Ok(all)
686 }
687
688 pub async fn get_orders(
694 &self,
695 account_id: &str,
696 start: OffsetDateTime,
697 end: OffsetDateTime,
698 state_filter: OrderStateFilter,
699 ) -> Result<Vec<OrderItem>> {
700 let start_str = format_query_datetime(start)?;
701 let end_str = format_query_datetime(end)?;
702 let resp: OrdersResponse = self
703 .get(&format!(
704 "/accounts/{}/orders?startTime={}&endTime={}&stateFilter={}",
705 account_id, start_str, end_str, state_filter,
706 ))
707 .await?;
708 Ok(resp.orders)
709 }
710
711 pub async fn get_executions(
716 &self,
717 account_id: &str,
718 start: OffsetDateTime,
719 end: OffsetDateTime,
720 ) -> Result<Vec<Execution>> {
721 let windows = activity_windows(start, end);
722 let mut all = Vec::new();
723 for (w_start, w_end) in windows {
724 let start_str = format_query_datetime(w_start)?;
725 let end_str = format_query_datetime(w_end)?;
726 let resp: ExecutionsResponse = self
727 .get(&format!(
728 "/accounts/{}/executions?startTime={}&endTime={}",
729 account_id, start_str, end_str,
730 ))
731 .await?;
732 all.extend(resp.executions);
733 }
734 all.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
735 Ok(all)
736 }
737}
738
739fn activity_windows(
753 start: OffsetDateTime,
754 end: OffsetDateTime,
755) -> Vec<(OffsetDateTime, OffsetDateTime)> {
756 const MAX_WINDOW: time::Duration = time::Duration::days(30);
757 let mut windows = Vec::new();
758 let mut cursor = start;
759 while cursor < end {
760 let window_end = (cursor + MAX_WINDOW).min(end);
761 windows.push((cursor, window_end));
762 cursor = window_end;
763 }
764 windows
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::auth::{CachedToken, TokenManager};
771 use time::OffsetDateTime;
772 use wiremock::matchers::{header, method, path};
773 use wiremock::{Mock, MockServer, ResponseTemplate};
774
775 #[test]
776 fn server_time_response_deserializes() {
777 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
778 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
779 assert_eq!(resp.time, "2026-02-21T14:32:00.000000-05:00");
780 }
781
782 #[test]
783 fn parse_server_time_returns_correct_fields() {
784 let json = r#"{"time":"2026-02-21T14:32:00.000000-05:00"}"#;
785 let resp: ServerTimeResponse = serde_json::from_str(json).unwrap();
786 let dt = QuestradeClient::parse_datetime(&resp.time).unwrap();
787 assert_eq!(dt.year(), 2026);
788 assert_eq!(dt.month(), time::Month::February);
789 assert_eq!(dt.day(), 21);
790 assert_eq!(dt.hour(), 14);
791 assert_eq!(dt.minute(), 32);
792 assert_eq!(dt.second(), 0);
793 assert_eq!(dt.offset().whole_hours(), -5);
794 }
795
796 #[test]
797 fn format_query_datetime_uses_utc_second_precision() {
798 let dt = OffsetDateTime::parse("2026-02-24T03:58:12.123456789-05:00", &Iso8601::DEFAULT)
799 .unwrap();
800 let s = format_query_datetime(dt).unwrap();
801 assert_eq!(s, "2026-02-24T08:58:12Z");
802 assert!(!s.contains('.'));
803 }
804
805 #[test]
806 fn backoff_delay_within_jitter_bounds() {
807 for attempt in 0..MAX_RETRIES {
808 for _ in 0..20 {
809 let delay = backoff_delay(attempt);
810 let base_ms = RETRY_BASE_DELAY_MS << attempt;
811 let min_ms = (base_ms as f64 * 0.8) as u64;
812 let max_ms = (base_ms as f64 * 1.2) as u64;
813 let actual_ms = delay.as_millis() as u64;
814 assert!(
815 actual_ms >= min_ms && actual_ms <= max_ms,
816 "attempt {attempt}: delay {actual_ms}ms not in [{min_ms}, {max_ms}]"
817 );
818 }
819 }
820 }
821
822 #[test]
823 fn backoff_delay_doubles_each_attempt() {
824 for attempt in 1..MAX_RETRIES {
825 let prev_base = RETRY_BASE_DELAY_MS << (attempt - 1);
826 let curr_base = RETRY_BASE_DELAY_MS << attempt;
827 assert_eq!(
828 curr_base,
829 prev_base * 2,
830 "base delay should double from attempt {} to {}",
831 attempt - 1,
832 attempt
833 );
834 }
835 }
836
837 #[test]
838 fn max_retries_constant() {
839 assert_eq!(MAX_RETRIES, 3, "expected 3 retries");
840 }
841
842 fn dt(s: &str) -> OffsetDateTime {
845 OffsetDateTime::parse(s, &Iso8601::DEFAULT).unwrap()
846 }
847
848 #[test]
849 fn activity_windows_empty_range_returns_empty() {
850 let start = dt("2026-01-01T00:00:00Z");
851 assert!(activity_windows(start, start).is_empty());
852 assert!(activity_windows(start, start - time::Duration::days(1)).is_empty());
854 }
855
856 #[test]
857 fn activity_windows_single_window_when_range_within_31_days() {
858 let start = dt("2026-01-01T00:00:00Z");
859 let end = start + time::Duration::days(30);
860 let windows = activity_windows(start, end);
861 assert_eq!(windows.len(), 1);
862 assert_eq!(windows[0], (start, end));
863 }
864
865 #[test]
866 fn activity_windows_exactly_30_days_is_single_window() {
867 let start = dt("2026-01-01T00:00:00Z");
868 let end = start + time::Duration::days(30);
869 let windows = activity_windows(start, end);
870 assert_eq!(windows.len(), 1);
871 assert_eq!(windows[0], (start, end));
872 }
873
874 #[test]
875 fn activity_windows_31_days_splits_into_two() {
876 let start = dt("2026-01-01T00:00:00Z");
877 let end = start + time::Duration::days(31);
878 let windows = activity_windows(start, end);
879 assert_eq!(windows.len(), 2);
880 assert_eq!(windows[0], (start, start + time::Duration::days(30)));
881 assert_eq!(windows[1], (start + time::Duration::days(30), end));
882 }
883
884 #[test]
885 fn activity_windows_365_days_all_within_limit_and_contiguous() {
886 let start = dt("2026-01-01T00:00:00Z");
887 let end = start + time::Duration::days(365);
888 let windows = activity_windows(start, end);
889 assert_eq!(windows.len(), 13);
891 assert_eq!(windows[0].0, start);
892 assert_eq!(windows.last().unwrap().1, end);
893 for (ws, we) in &windows {
894 assert!(
895 (*we - *ws).whole_days() <= 30,
896 "window exceeds 30 days: {} days",
897 (*we - *ws).whole_days()
898 );
899 }
900 for i in 1..windows.len() {
902 assert_eq!(
903 windows[i].0,
904 windows[i - 1].1,
905 "gap between window {i} and {}",
906 i - 1
907 );
908 }
909 }
910
911 #[tokio::test]
914 async fn get_retries_on_401_after_force_refresh() {
915 let server = MockServer::start().await;
916 let api_server = format!("{}/", server.uri());
917
918 Mock::given(method("GET"))
920 .and(path("/v1/time"))
921 .and(header("Authorization", "Bearer stale_token"))
922 .respond_with(ResponseTemplate::new(401))
923 .expect(1)
924 .named("stale request")
925 .mount(&server)
926 .await;
927
928 Mock::given(method("GET"))
930 .and(path("/oauth2/token"))
931 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
932 "access_token": "fresh_token",
933 "token_type": "Bearer",
934 "expires_in": 1800,
935 "refresh_token": "new_rt",
936 "api_server": api_server,
937 })))
938 .expect(1)
939 .named("oauth refresh")
940 .mount(&server)
941 .await;
942
943 Mock::given(method("GET"))
945 .and(path("/v1/time"))
946 .and(header("Authorization", "Bearer fresh_token"))
947 .respond_with(
948 ResponseTemplate::new(200)
949 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
950 )
951 .expect(1)
952 .named("fresh request")
953 .mount(&server)
954 .await;
955
956 let cached = CachedToken {
958 access_token: "stale_token".to_string(),
959 api_server: api_server.clone(),
960 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
961 };
962 let tm = TokenManager::new_with_login_url(
963 "old_rt".to_string(),
964 None,
965 server.uri(),
966 Some(cached),
967 )
968 .await
969 .unwrap();
970
971 let client = QuestradeClient::new(tm).unwrap();
972 let time = client.get_server_time().await.unwrap();
973 assert_eq!(time.year(), 2026);
974 }
975
976 #[tokio::test]
977 async fn get_does_not_retry_401_more_than_once() {
978 let server = MockServer::start().await;
979 let api_server = format!("{}/", server.uri());
980
981 Mock::given(method("GET"))
983 .and(path("/v1/time"))
984 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
985 .expect(2) .mount(&server)
987 .await;
988
989 Mock::given(method("GET"))
991 .and(path("/oauth2/token"))
992 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
993 "access_token": "still_bad",
994 "token_type": "Bearer",
995 "expires_in": 1800,
996 "refresh_token": "new_rt",
997 "api_server": api_server,
998 })))
999 .expect(1)
1000 .mount(&server)
1001 .await;
1002
1003 let cached = CachedToken {
1004 access_token: "stale_token".to_string(),
1005 api_server,
1006 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1007 };
1008 let tm = TokenManager::new_with_login_url(
1009 "old_rt".to_string(),
1010 None,
1011 server.uri(),
1012 Some(cached),
1013 )
1014 .await
1015 .unwrap();
1016
1017 let client = QuestradeClient::new(tm).unwrap();
1018 let result = client.get_server_time().await;
1019 assert!(result.is_err());
1020 assert!(
1021 result.unwrap_err().to_string().contains("401"),
1022 "error should mention 401"
1023 );
1024 }
1025
1026 #[tokio::test]
1027 async fn post_retries_on_401_after_force_refresh() {
1028 let server = MockServer::start().await;
1029 let api_server = format!("{}/", server.uri());
1030
1031 Mock::given(method("POST"))
1033 .and(path("/v1/markets/quotes/options"))
1034 .and(header("Authorization", "Bearer stale_token"))
1035 .respond_with(ResponseTemplate::new(401))
1036 .expect(1)
1037 .named("stale post")
1038 .mount(&server)
1039 .await;
1040
1041 Mock::given(method("GET"))
1043 .and(path("/oauth2/token"))
1044 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1045 "access_token": "fresh_token",
1046 "token_type": "Bearer",
1047 "expires_in": 1800,
1048 "refresh_token": "new_rt",
1049 "api_server": api_server,
1050 })))
1051 .expect(1)
1052 .named("oauth refresh")
1053 .mount(&server)
1054 .await;
1055
1056 Mock::given(method("POST"))
1058 .and(path("/v1/markets/quotes/options"))
1059 .and(header("Authorization", "Bearer fresh_token"))
1060 .respond_with(
1061 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1062 )
1063 .expect(1)
1064 .named("fresh post")
1065 .mount(&server)
1066 .await;
1067
1068 let cached = CachedToken {
1069 access_token: "stale_token".to_string(),
1070 api_server,
1071 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1072 };
1073 let tm = TokenManager::new_with_login_url(
1074 "old_rt".to_string(),
1075 None,
1076 server.uri(),
1077 Some(cached),
1078 )
1079 .await
1080 .unwrap();
1081
1082 let client = QuestradeClient::new(tm).unwrap();
1083 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1084 assert!(quotes.is_empty());
1085 }
1086
1087 #[tokio::test]
1088 async fn get_with_raw_logging_deserializes_correctly() {
1089 let server = MockServer::start().await;
1090 let api_server = format!("{}/", server.uri());
1091
1092 Mock::given(method("GET"))
1093 .and(path("/v1/time"))
1094 .respond_with(
1095 ResponseTemplate::new(200)
1096 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1097 )
1098 .expect(1)
1099 .mount(&server)
1100 .await;
1101
1102 let cached = CachedToken {
1103 access_token: "token".to_string(),
1104 api_server,
1105 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1106 };
1107 let tm =
1108 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1109 .await
1110 .unwrap();
1111
1112 let client = QuestradeClient::new(tm).unwrap().with_raw_logging(true);
1113 let time = client.get_server_time().await.unwrap();
1114 assert_eq!(time.year(), 2026);
1115 }
1116
1117 #[tokio::test]
1118 async fn get_text_returns_raw_body() {
1119 let server = MockServer::start().await;
1120 let api_server = format!("{}/", server.uri());
1121
1122 let expected_json = r#"{"time":"2026-03-02T12:00:00.000000-05:00"}"#;
1123 Mock::given(method("GET"))
1124 .and(path("/v1/time"))
1125 .respond_with(ResponseTemplate::new(200).set_body_string(expected_json))
1126 .expect(1)
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 text = client.get_text("/time").await.unwrap();
1142 assert_eq!(text, expected_json);
1143 }
1144
1145 #[tokio::test]
1148 async fn get_retries_on_429_then_succeeds() {
1149 let server = MockServer::start().await;
1150 let api_server = format!("{}/", server.uri());
1151
1152 Mock::given(method("GET"))
1153 .and(path("/v1/time"))
1154 .respond_with(ResponseTemplate::new(429))
1155 .expect(2)
1156 .up_to_n_times(2)
1157 .named("rate limited")
1158 .mount(&server)
1159 .await;
1160
1161 Mock::given(method("GET"))
1162 .and(path("/v1/time"))
1163 .respond_with(
1164 ResponseTemplate::new(200)
1165 .set_body_json(serde_json::json!({"time": "2026-03-02T12:00:00.000000-05:00"})),
1166 )
1167 .expect(1)
1168 .named("success after rate limit")
1169 .mount(&server)
1170 .await;
1171
1172 let cached = CachedToken {
1173 access_token: "token".to_string(),
1174 api_server,
1175 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1176 };
1177 let tm =
1178 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1179 .await
1180 .unwrap();
1181
1182 let client = QuestradeClient::new(tm).unwrap();
1183 let time = client.get_server_time().await.unwrap();
1184 assert_eq!(time.year(), 2026);
1185 }
1186
1187 #[tokio::test]
1188 async fn post_retries_on_429_then_succeeds() {
1189 let server = MockServer::start().await;
1190 let api_server = format!("{}/", server.uri());
1191
1192 Mock::given(method("POST"))
1193 .and(path("/v1/markets/quotes/options"))
1194 .respond_with(ResponseTemplate::new(429))
1195 .expect(1)
1196 .up_to_n_times(1)
1197 .named("rate limited post")
1198 .mount(&server)
1199 .await;
1200
1201 Mock::given(method("POST"))
1202 .and(path("/v1/markets/quotes/options"))
1203 .respond_with(
1204 ResponseTemplate::new(200).set_body_json(serde_json::json!({"optionQuotes": []})),
1205 )
1206 .expect(1)
1207 .named("success post after rate limit")
1208 .mount(&server)
1209 .await;
1210
1211 let cached = CachedToken {
1212 access_token: "token".to_string(),
1213 api_server,
1214 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1215 };
1216 let tm =
1217 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1218 .await
1219 .unwrap();
1220
1221 let client = QuestradeClient::new(tm).unwrap();
1222 let quotes = client.get_option_quotes_raw(&[12345]).await.unwrap();
1223 assert!(quotes.is_empty());
1224 }
1225
1226 #[tokio::test]
1227 async fn get_fails_after_max_429_retries() {
1228 let server = MockServer::start().await;
1229 let api_server = format!("{}/", server.uri());
1230
1231 Mock::given(method("GET"))
1232 .and(path("/v1/time"))
1233 .respond_with(ResponseTemplate::new(429))
1234 .expect((MAX_RETRIES + 1) as u64)
1235 .mount(&server)
1236 .await;
1237
1238 let cached = CachedToken {
1239 access_token: "token".to_string(),
1240 api_server,
1241 expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(25),
1242 };
1243 let tm =
1244 TokenManager::new_with_login_url("rt".to_string(), None, server.uri(), Some(cached))
1245 .await
1246 .unwrap();
1247
1248 let client = QuestradeClient::new(tm).unwrap();
1249 let result = client.get_server_time().await;
1250 assert!(result.is_err());
1251 assert!(
1252 result.unwrap_err().to_string().contains("rate limit"),
1253 "error should mention rate limit"
1254 );
1255 }
1256
1257 #[test]
1258 fn retry_after_header_is_respected() {
1259 let resp = http::Response::builder()
1260 .status(429)
1261 .header("Retry-After", "5")
1262 .body("")
1263 .unwrap();
1264 let resp = reqwest::Response::from(resp);
1265 let delay = retry_after_or_backoff(&resp, 0);
1266 assert_eq!(delay, Duration::from_secs(5));
1267 }
1268
1269 #[test]
1270 fn retry_after_header_capped_at_60s() {
1271 let resp = http::Response::builder()
1272 .status(429)
1273 .header("Retry-After", "300")
1274 .body("")
1275 .unwrap();
1276 let resp = reqwest::Response::from(resp);
1277 let delay = retry_after_or_backoff(&resp, 0);
1278 assert_eq!(delay, Duration::from_secs(60));
1279 }
1280
1281 #[test]
1282 fn retry_after_missing_falls_back_to_backoff() {
1283 let resp = http::Response::builder().status(429).body("").unwrap();
1284 let resp = reqwest::Response::from(resp);
1285 let delay = retry_after_or_backoff(&resp, 0);
1286 let ms = delay.as_millis() as u64;
1287 assert!(ms >= 800 && ms <= 1200, "expected ~1000ms, got {}ms", ms);
1288 }
1289}