1use crate::{
4 AccountID, ClientRequestID, Environment, Error, GenericRejection, OperationError, Result,
5 Supplied, models::AcceptDatetimeFormat,
6};
7use futures_util::StreamExt;
8use reqwest::{Method, StatusCode, Url};
9use serde::{Serialize, de::DeserializeOwned};
10use std::{
11 collections::HashSet,
12 fmt,
13 ops::Deref,
14 sync::{Arc, Mutex},
15 time::{Duration, Instant},
16};
17use tokio::{sync::Notify, time::Instant as RateInstant};
18
19const DEFAULT_MAX_BODY: usize = 8 * 1024 * 1024;
20const REQUESTS_PER_SECOND: u32 = 100;
21const DEFAULT_COOLDOWN: Duration = Duration::from_secs(1);
22const MAX_COOLDOWN: Duration = Duration::from_mins(1);
23
24#[derive(Debug, Clone)]
26pub struct ApiResponse<T> {
27 pub body: T,
29 pub request_id: Supplied<String>,
33 pub next_page: Supplied<Url>,
39}
40
41impl<T> Deref for ApiResponse<T> {
42 type Target = T;
43 fn deref(&self) -> &Self::Target {
44 &self.body
45 }
46}
47
48#[derive(Clone)]
50pub struct Client {
51 pub(crate) inner: Arc<Inner>,
52}
53
54impl fmt::Debug for Client {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.debug_struct("Client")
57 .field("environment", &self.inner.environment)
58 .finish_non_exhaustive()
59 }
60}
61
62pub(crate) struct Inner {
63 pub environment: Environment,
64 pub rest: Url,
65 pub stream: Url,
66 pub http: reqwest::Client,
67 pub stream_http: reqwest::Client,
68 pub token: String,
69 pub max_body: usize,
70 pub datetime_format: AcceptDatetimeFormat,
71 rate: Mutex<Rate>,
72 policy_changed: Notify,
73 fenced: Mutex<HashSet<String>>,
74 in_flight: Mutex<HashSet<String>>,
75 pub stream_slots: Mutex<StreamSlots>,
76}
77
78struct Rate {
79 exit_reserve: u32,
80 start: RateInstant,
81 used: u32,
82 cooldown_until: Option<RateInstant>,
83}
84pub(crate) struct StreamSlots {
85 pub active: usize,
86 pub last_start: Option<Instant>,
87}
88
89#[derive(Clone, Copy)]
90pub(crate) enum MutationAdmission<'a> {
91 Ordinary(&'a AccountID),
92 Exit(&'a AccountID),
93}
94
95impl<'a> MutationAdmission<'a> {
96 fn account(self) -> &'a AccountID {
97 match self {
98 Self::Ordinary(account) | Self::Exit(account) => account,
99 }
100 }
101}
102
103pub struct ClientBuilder {
105 environment: Environment,
106 token: String,
107 rest_override: Option<Url>,
108 stream_override: Option<Url>,
109 max_body: usize,
110 timeout: Duration,
111 exit_reserve: u32,
112 datetime_format: AcceptDatetimeFormat,
113}
114
115impl fmt::Debug for ClientBuilder {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.debug_struct("ClientBuilder")
118 .field("environment", &self.environment)
119 .field("max_body", &self.max_body)
120 .finish_non_exhaustive()
121 }
122}
123
124impl Client {
125 pub fn set_exit_reserve(&self, requests: u32) -> Result<()> {
137 let mut rate = self
138 .inner
139 .rate
140 .lock()
141 .map_err(|_| Error::InvalidInput("rate lock poisoned".into()))?;
142 if requests >= REQUESTS_PER_SECOND {
143 return Err(Error::InvalidInput(
144 "exit reserve must be less than 100 requests".into(),
145 ));
146 }
147 let changed = rate.exit_reserve != requests;
148 rate.exit_reserve = requests;
149 drop(rate);
150 if changed {
151 self.inner.policy_changed.notify_waiters();
152 }
153 Ok(())
154 }
155
156 pub fn builder(environment: Environment, bearer_token: impl Into<String>) -> ClientBuilder {
158 ClientBuilder {
159 environment,
160 token: bearer_token.into(),
161 rest_override: None,
162 stream_override: None,
163 max_body: DEFAULT_MAX_BODY,
164 timeout: Duration::from_secs(30),
165 exit_reserve: 0,
166 datetime_format: AcceptDatetimeFormat::Rfc3339,
167 }
168 }
169
170 pub fn acknowledge_reconciliation(&self, account: &AccountID) -> Result<()> {
179 let mut fenced = self
180 .inner
181 .fenced
182 .lock()
183 .map_err(|_| Error::InvalidInput("reconciliation lock poisoned".into()))?;
184 fenced.remove(account.as_str());
185 Ok(())
186 }
187
188 #[allow(clippy::too_many_lines)]
190 pub(crate) async fn execute<
191 T: DeserializeOwned,
192 R: DeserializeOwned,
193 Q: Serialize,
194 B: Serialize,
195 >(
196 &self,
197 method: Method,
198 path: &str,
199 query: Option<&Q>,
200 body: Option<&B>,
201 mutation: Option<MutationAdmission<'_>>,
202 client_request_id: Option<&ClientRequestID>,
203 ) -> std::result::Result<ApiResponse<T>, OperationError<R>> {
204 let mutation_account = mutation.map(MutationAdmission::account);
205 let exit = matches!(mutation, Some(MutationAdmission::Exit(_)));
206 let query_value = query
207 .map(serde_json::to_value)
208 .transpose()
209 .map_err(|e| Error::InvalidInput(e.to_string()))?;
210 let body_value = body
211 .map(serde_json::to_value)
212 .transpose()
213 .map_err(|e| Error::InvalidInput(e.to_string()))?;
214 crate::validation::validate(path, query_value.as_ref(), body_value.as_ref())?;
215 self.admit(mutation_account.is_some(), exit).await?;
216 let url = Self::build_url(&self.inner.rest, path, query)?;
217 let mut request = self
218 .inner
219 .http
220 .request(method, url)
221 .bearer_auth(&self.inner.token)
222 .header(
223 "Accept-Datetime-Format",
224 self.inner.datetime_format.as_str(),
225 );
226 if let Some(id) = client_request_id {
227 request = request.header("ClientRequestID", id.as_str());
228 }
229 if let Some(body) = body {
230 request = request.json(body);
231 }
232 let mut guard = mutation_account
235 .map(|account| MutationGuard::new(self.inner.clone(), account))
236 .transpose()?;
237 let response = request.send().await.map_err(|e| match mutation_account {
238 Some(account) => Error::AmbiguousMutation {
239 account: account.to_string(),
240 },
241 None => Error::Transport(e.to_string()),
242 })?;
243 let status = response.status();
244 let request_id = supplied_header(response.headers(), "RequestID");
245 let next_page = if status.is_success() {
246 parse_next_page(&self.inner.rest, response.headers()).map_err(|error| {
247 match mutation_account {
248 Some(account) => Error::AmbiguousMutation {
249 account: account.to_string(),
250 },
251 None => error,
252 }
253 })?
254 } else {
255 Supplied::Absent
256 };
257 if status == StatusCode::TOO_MANY_REQUESTS {
258 self.install_cooldown(response.headers().get(reqwest::header::RETRY_AFTER));
259 if let Some(ref mut guard) = guard {
260 guard.disarm();
261 }
262 return Err(Error::ProviderRateLimited.into());
263 }
264 let bytes = read_bounded(response, self.inner.max_body)
265 .await
266 .map_err(|e| match mutation_account {
267 Some(account) => Error::AmbiguousMutation {
268 account: account.to_string(),
269 },
270 None => e,
271 })?;
272 if status.is_success() {
273 let value = serde_json::from_slice(&bytes).map_err(|e| match mutation_account {
274 Some(account) => Error::AmbiguousMutation {
275 account: account.to_string(),
276 },
277 None => Error::Decode(e.to_string()),
278 })?;
279 if let Some(ref mut guard) = guard {
280 guard.disarm();
281 }
282 return Ok(ApiResponse {
283 body: value,
284 request_id,
285 next_page,
286 });
287 }
288 if mutation_account.is_some()
289 && (status.is_server_error()
290 || status == StatusCode::REQUEST_TIMEOUT
291 || !matches!(status.as_u16(), 400 | 401 | 403 | 404 | 405))
292 {
293 return Err(Error::AmbiguousMutation {
294 account: mutation_account.map_or(String::new(), ToString::to_string),
295 }
296 .into());
297 }
298 let body: Supplied<R> = if bytes.iter().all(u8::is_ascii_whitespace) {
299 Supplied::Absent
300 } else {
301 match serde_json::from_slice::<R>(&bytes) {
302 Ok(value) => Supplied::Decoded(value),
303 Err(_) => Supplied::Undecoded,
304 }
305 };
306 let reason = ProviderError::read(&bytes);
312 if let Some(ref mut guard) = guard {
313 guard.disarm();
314 }
315 Err(OperationError::Rejected {
316 status: status.as_u16(),
317 code: reason.error_code,
318 message: reason.error_message,
319 body,
320 })
321 }
322
323 pub async fn fetch_page<T: DeserializeOwned>(
331 &self,
332 next_page: &Url,
333 ) -> std::result::Result<ApiResponse<T>, OperationError<GenericRejection>> {
334 if next_page.scheme() != self.inner.rest.scheme()
335 || next_page.host_str() != self.inner.rest.host_str()
336 || next_page.port_or_known_default() != self.inner.rest.port_or_known_default()
337 || !next_page.path().starts_with("/v3/")
338 || next_page.fragment().is_some()
339 || next_page.username() != ""
340 || next_page.password().is_some()
341 {
342 return Err(Error::InvalidInput(
343 "pagination URL is outside the OANDA REST origin".into(),
344 )
345 .into());
346 }
347 let path = if let Some(query) = next_page.query() {
348 format!("{}?{query}", next_page.path())
349 } else {
350 next_page.path().to_owned()
351 };
352 self.execute(Method::GET, &path, None::<&()>, None::<&()>, None, None)
353 .await
354 }
355
356 pub(crate) async fn admit(&self, mutation: bool, exit: bool) -> Result<()> {
357 loop {
358 let changed = self.inner.policy_changed.notified();
359 tokio::pin!(changed);
360 changed.as_mut().enable();
361 let wait = {
362 let mut rate = self
363 .inner
364 .rate
365 .lock()
366 .map_err(|_| Error::InvalidInput("rate lock poisoned".into()))?;
367 let capacity = if exit {
368 REQUESTS_PER_SECOND
369 } else {
370 REQUESTS_PER_SECOND - rate.exit_reserve
371 };
372 let now = RateInstant::now();
373 if now.duration_since(rate.start) >= Duration::from_secs(1) {
374 rate.start = now;
375 rate.used = 0;
376 }
377 if let Some(until) = rate.cooldown_until.filter(|until| *until > now) {
378 until.duration_since(now)
379 } else if rate.used < capacity {
380 rate.used += 1;
381 return Ok(());
382 } else {
383 Duration::from_secs(1).saturating_sub(now.duration_since(rate.start))
384 }
385 };
386 if mutation {
387 return Err(Error::RateLimited);
388 }
389 tokio::select! {
390 () = &mut changed => {},
391 () = tokio::time::sleep(wait) => {},
392 }
393 }
394 }
395
396 pub(crate) fn build_url<Q: Serialize>(
397 base: &Url,
398 path: &str,
399 query: Option<&Q>,
400 ) -> Result<Url> {
401 if !path.starts_with("/v3/") {
402 return Err(Error::InvalidInput("path must start with /v3/".into()));
403 }
404 let mut url = base
405 .join(path)
406 .map_err(|e| Error::InvalidInput(e.to_string()))?;
407 if let Some(query) = query {
408 let value =
409 serde_json::to_value(query).map_err(|e| Error::InvalidInput(e.to_string()))?;
410 let object = value
411 .as_object()
412 .ok_or_else(|| Error::InvalidInput("query must be an object".into()))?;
413 let mut pairs = url.query_pairs_mut();
414 for (key, value) in object {
415 if value.is_null() {
416 continue;
417 }
418 if let Some(items) = value.as_array() {
419 let csv = items
420 .iter()
421 .map(query_atom)
422 .collect::<Result<Vec<_>>>()?
423 .join(",");
424 pairs.append_pair(key, &csv);
425 } else {
426 pairs.append_pair(key, &query_atom(value)?);
427 }
428 }
429 }
430 Ok(url)
431 }
432
433 fn install_cooldown(&self, retry_after: Option<&reqwest::header::HeaderValue>) {
440 let cooldown = match retry_after {
441 None => DEFAULT_COOLDOWN,
442 Some(value) => parse_retry_after(value).unwrap_or(MAX_COOLDOWN),
443 };
444 let mut rate = self
448 .inner
449 .rate
450 .lock()
451 .unwrap_or_else(std::sync::PoisonError::into_inner);
452 let until = RateInstant::now() + cooldown;
453 rate.cooldown_until = Some(
454 rate.cooldown_until
455 .map_or(until, |existing| existing.max(until)),
456 );
457 }
458}
459
460fn parse_retry_after(value: &reqwest::header::HeaderValue) -> Option<Duration> {
465 let text = value.to_str().ok()?.trim();
466 if let Ok(seconds) = text.parse::<u64>() {
467 return Some(Duration::from_secs(seconds).min(MAX_COOLDOWN));
468 }
469 let when = chrono::DateTime::parse_from_rfc2822(text).ok()?;
470 let seconds = u64::try_from(
471 (when.with_timezone(&chrono::Utc) - chrono::Utc::now())
472 .num_seconds()
473 .max(0),
474 )
475 .ok()?;
476 Some(Duration::from_secs(seconds).min(MAX_COOLDOWN))
477}
478
479impl ClientBuilder {
480 #[must_use]
482 pub fn max_response_bytes(mut self, value: usize) -> Self {
483 self.max_body = value;
484 self
485 }
486
487 #[must_use]
493 pub fn exit_reserve(mut self, requests: u32) -> Self {
494 self.exit_reserve = requests;
495 self
496 }
497
498 #[must_use]
500 pub fn timeout(mut self, value: Duration) -> Self {
501 self.timeout = value;
502 self
503 }
504 #[must_use]
506 pub fn datetime_format(mut self, value: AcceptDatetimeFormat) -> Self {
507 self.datetime_format = value;
508 self
509 }
510 #[must_use]
513 pub fn endpoints(mut self, rest: Url, stream: Url) -> Self {
514 self.rest_override = Some(rest);
515 self.stream_override = Some(stream);
516 self
517 }
518
519 pub fn build(self) -> Result<Client> {
526 if self.token.trim().is_empty() || self.token.chars().any(char::is_control) {
527 return Err(Error::InvalidInput("invalid bearer token".into()));
528 }
529 if self.max_body == 0 || self.timeout.is_zero() {
530 return Err(Error::InvalidInput("limits must be positive".into()));
531 }
532 if self.exit_reserve >= REQUESTS_PER_SECOND {
533 return Err(Error::InvalidInput(
534 "exit reserve must be less than 100 requests".into(),
535 ));
536 }
537 let (default_rest, default_stream) = self.environment.urls()?;
538 let rest = self.rest_override.unwrap_or_else(|| default_rest.clone());
539 let stream = self
540 .stream_override
541 .unwrap_or_else(|| default_stream.clone());
542 validate_url(&rest)?;
543 validate_url(&stream)?;
544 let paired_loopback = is_loopback(&rest) && is_loopback(&stream);
545 if !paired_loopback && (rest != default_rest || stream != default_stream) {
546 return Err(Error::InvalidInput(
547 "endpoints must use the selected OANDA environment pair or loopback fixtures"
548 .into(),
549 ));
550 }
551 if matches!(self.datetime_format, AcceptDatetimeFormat::Unknown(_)) {
552 return Err(Error::InvalidInput("unsupported datetime format".into()));
553 }
554 let http = reqwest::Client::builder()
555 .timeout(self.timeout)
556 .redirect(reqwest::redirect::Policy::none())
557 .retry(reqwest::retry::never())
558 .no_proxy()
559 .build()
560 .map_err(|e| Error::InvalidInput(e.to_string()))?;
561 let stream_http = stream_transport(reqwest::Client::builder(), self.timeout)
562 .build()
563 .map_err(|e| Error::InvalidInput(e.to_string()))?;
564 Ok(Client {
565 inner: Arc::new(Inner {
566 environment: self.environment,
567 rest,
568 stream,
569 http,
570 stream_http,
571 token: self.token,
572 max_body: self.max_body,
573 datetime_format: self.datetime_format,
574 policy_changed: Notify::new(),
575 rate: Mutex::new(Rate {
576 exit_reserve: self.exit_reserve,
577 start: RateInstant::now(),
578 used: 0,
579 cooldown_until: None,
580 }),
581 fenced: Mutex::new(HashSet::new()),
582 in_flight: Mutex::new(HashSet::new()),
583 stream_slots: Mutex::new(StreamSlots {
584 active: 0,
585 last_start: None,
586 }),
587 }),
588 })
589 }
590}
591
592fn validate_url(url: &Url) -> Result<()> {
593 let host = url
594 .host_str()
595 .ok_or_else(|| Error::InvalidInput("endpoint must have a host".into()))?;
596 let loopback = host == "localhost" || host == "127.0.0.1" || host == "::1";
597 if url.scheme() != "https" && !(loopback && url.scheme() == "http") {
598 return Err(Error::InvalidInput("remote endpoints require HTTPS".into()));
599 }
600 if url.path() != "/"
601 || url.username() != ""
602 || url.password().is_some()
603 || url.query().is_some()
604 || url.fragment().is_some()
605 {
606 return Err(Error::InvalidInput(
607 "endpoint URL must not contain credentials, query, or fragment".into(),
608 ));
609 }
610 Ok(())
611}
612
613fn is_loopback(url: &Url) -> bool {
614 matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1"))
615}
616
617fn query_atom(value: &serde_json::Value) -> Result<String> {
618 match value {
619 serde_json::Value::String(s) => Ok(s.clone()),
620 serde_json::Value::Number(n) => Ok(n.to_string()),
621 serde_json::Value::Bool(b) => Ok(b.to_string()),
622 _ => Err(Error::InvalidInput("unsupported query value".into())),
623 }
624}
625
626fn supplied_header(headers: &reqwest::header::HeaderMap, name: &str) -> Supplied<String> {
632 match headers.get(name) {
633 None => Supplied::Absent,
634 Some(value) => match value.to_str() {
635 Ok(text) => Supplied::Decoded(text.to_owned()),
636 Err(_) => Supplied::Undecoded,
637 },
638 }
639}
640
641fn parse_next_page(base: &Url, headers: &reqwest::header::HeaderMap) -> Result<Supplied<Url>> {
650 let Some(link) = headers.get(reqwest::header::LINK) else {
651 return Ok(Supplied::Absent);
652 };
653 let Ok(value) = link.to_str() else {
654 return Ok(Supplied::Undecoded);
655 };
656 let Some(item) = value
657 .split(',')
658 .find(|item| item.contains("rel=\"next\"") || item.contains("rel=next"))
659 else {
660 return Ok(Supplied::Absent);
661 };
662 let Some(start) = item.find('<') else {
668 return Err(Error::Decode("invalid pagination link".into()));
669 };
670 let Some(end) = item[start + 1..].find('>') else {
671 return Err(Error::Decode("invalid pagination link".into()));
672 };
673 let url = base
674 .join(&item[start + 1..start + 1 + end])
675 .map_err(|e| Error::Decode(e.to_string()))?;
676 if url.scheme() != base.scheme()
677 || url.host_str() != base.host_str()
678 || url.port_or_known_default() != base.port_or_known_default()
679 || !url.path().starts_with("/v3/")
680 {
681 return Err(Error::Decode(
682 "pagination link changed origin or API version".into(),
683 ));
684 }
685 Ok(Supplied::Decoded(url))
686}
687
688pub(crate) fn segment(value: &str) -> String {
689 const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
690 .remove(b'-')
691 .remove(b'_')
692 .remove(b'.')
693 .remove(b'~');
694 percent_encoding::utf8_percent_encode(value, PATH_SEGMENT).to_string()
695}
696
697pub(crate) async fn read_bounded(response: reqwest::Response, max: usize) -> Result<Vec<u8>> {
698 let mut stream = response.bytes_stream();
699 let mut bytes = Vec::new();
700 while let Some(next) = stream.next().await {
701 let chunk = next.map_err(|e| Error::Transport(e.to_string()))?;
702 if bytes.len().saturating_add(chunk.len()) > max {
703 return Err(Error::ResponseTooLarge);
704 }
705 bytes.extend_from_slice(&chunk);
706 }
707 Ok(bytes)
708}
709
710#[derive(serde::Deserialize)]
711#[serde(rename_all = "camelCase")]
712struct ProviderError {
713 error_code: Option<String>,
714 error_message: Option<String>,
715}
716
717impl ProviderError {
718 fn read(bytes: &[u8]) -> Self {
722 let value: serde_json::Value = match serde_json::from_slice(bytes) {
723 Ok(value) => value,
724 Err(_) => return Self::UNREAD,
725 };
726 Self {
727 error_code: value
728 .get("errorCode")
729 .and_then(serde_json::Value::as_str)
730 .map(str::to_owned),
731 error_message: value
732 .get("errorMessage")
733 .and_then(serde_json::Value::as_str)
734 .map(str::to_owned),
735 }
736 }
737
738 const UNREAD: Self = Self {
742 error_code: None,
743 error_message: None,
744 };
745}
746
747struct MutationGuard {
748 inner: Arc<Inner>,
749 account: String,
750 armed: bool,
751}
752impl MutationGuard {
753 fn new(inner: Arc<Inner>, account: &AccountID) -> Result<Self> {
754 if inner
755 .fenced
756 .lock()
757 .map_err(|_| Error::InvalidInput("reconciliation lock poisoned".into()))?
758 .contains(account.as_str())
759 {
760 return Err(Error::ReconciliationRequired {
761 account: account.to_string(),
762 });
763 }
764 let mut in_flight = inner
765 .in_flight
766 .lock()
767 .map_err(|_| Error::InvalidInput("mutation lock poisoned".into()))?;
768 if !in_flight.insert(account.to_string()) {
769 return Err(Error::MutationInFlight {
770 account: account.to_string(),
771 });
772 }
773 if inner
776 .fenced
777 .lock()
778 .map_err(|_| Error::InvalidInput("reconciliation lock poisoned".into()))?
779 .contains(account.as_str())
780 {
781 in_flight.remove(account.as_str());
782 return Err(Error::ReconciliationRequired {
783 account: account.to_string(),
784 });
785 }
786 drop(in_flight);
787 Ok(Self {
788 inner,
789 account: account.to_string(),
790 armed: true,
791 })
792 }
793 fn disarm(&mut self) {
794 self.armed = false;
795 }
796}
797impl Drop for MutationGuard {
798 fn drop(&mut self) {
799 if self.armed {
804 let mut fenced = self
805 .inner
806 .fenced
807 .lock()
808 .unwrap_or_else(std::sync::PoisonError::into_inner);
809 fenced.insert(self.account.clone());
810 }
811 let mut in_flight = self
812 .inner
813 .in_flight
814 .lock()
815 .unwrap_or_else(std::sync::PoisonError::into_inner);
816 in_flight.remove(&self.account);
817 }
818}
819
820fn stream_transport(builder: reqwest::ClientBuilder, timeout: Duration) -> reqwest::ClientBuilder {
821 builder
822 .http1_only()
823 .connect_timeout(timeout)
824 .redirect(reqwest::redirect::Policy::none())
825 .retry(reqwest::retry::never())
826 .no_proxy()
827}
828
829#[cfg(test)]
830#[allow(clippy::expect_used, clippy::panic)]
833mod tests {
834 use super::*;
835
836 #[tokio::test]
837 async fn streaming_uses_http11_when_the_caller_enables_http2() {
838 use tokio::io::{AsyncReadExt, AsyncWriteExt};
839 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
840 .await
841 .expect("bind");
842 let address = listener.local_addr().expect("address");
843 let server = tokio::spawn(async move {
844 let (mut socket, _) = listener.accept().await.expect("accept");
845 let mut bytes = Vec::new();
846 let mut buffer = [0; 1024];
847 loop {
848 let count = socket.read(&mut buffer).await.expect("read request");
849 if count == 0 {
850 break;
851 }
852 bytes.extend_from_slice(&buffer[..count]);
853 if bytes.windows(4).any(|window| window == b"\r\n\r\n") {
854 break;
855 }
856 }
857 socket
858 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
859 .await
860 .expect("response");
861 bytes
862 });
863 let client = stream_transport(
864 reqwest::Client::builder().http2_prior_knowledge(),
865 Duration::from_secs(5),
866 )
867 .build()
868 .expect("client");
869 let response = client
870 .get(format!(
871 "http://{address}/v3/accounts/fixture/transactions/stream"
872 ))
873 .send()
874 .await;
875 let request = server.await.expect("server");
876 assert!(
877 request.starts_with(b"GET /v3/accounts/fixture/transactions/stream HTTP/1.1\r\n"),
878 "request protocol: {}",
879 String::from_utf8_lossy(&request)
880 );
881 assert_eq!(
882 response.expect("stream response").version(),
883 reqwest::Version::HTTP_11
884 );
885 }
886
887 #[tokio::test]
888 async fn mutation_rate_admission_is_shared_by_clones() {
889 let client = Client::builder(Environment::Practice, "fixture").build();
890 assert!(client.is_ok());
891 let Ok(client) = client else { return };
892 if let Ok(mut rate) = client.inner.rate.lock() {
893 rate.used = REQUESTS_PER_SECOND;
894 rate.start = RateInstant::now();
895 }
896 assert!(matches!(
897 client.clone().admit(true, false).await,
898 Err(Error::RateLimited)
899 ));
900 }
901
902 #[test]
903 fn account_reservation_and_reconciliation_are_shared() {
904 let client = Client::builder(Environment::Practice, "fixture").build();
905 assert!(client.is_ok());
906 let Ok(client) = client else { return };
907 let account = AccountID::new("101-001-1-001");
908 assert!(account.is_ok());
909 let Ok(account) = account else { return };
910 let first = MutationGuard::new(client.inner.clone(), &account);
911 assert!(first.is_ok());
912 let Ok(first) = first else { return };
913 assert!(matches!(
914 MutationGuard::new(client.clone().inner, &account),
915 Err(Error::MutationInFlight { .. })
916 ));
917 drop(first);
918 assert!(matches!(
919 MutationGuard::new(client.inner.clone(), &account),
920 Err(Error::ReconciliationRequired { .. })
921 ));
922 let other = AccountID::new("101-001-1-002");
923 assert!(other.is_ok());
924 if let Ok(other) = other {
925 let mut independent = MutationGuard::new(client.inner.clone(), &other);
926 assert!(independent.is_ok());
927 if let Ok(ref mut guard) = independent {
928 guard.disarm();
929 }
930 }
931 client
932 .acknowledge_reconciliation(&account)
933 .expect("fence released");
934 let mut next = MutationGuard::new(client.inner.clone(), &account);
935 assert!(next.is_ok());
936 if let Ok(ref mut guard) = next {
937 guard.disarm();
938 }
939 }
940
941 fn poison<T>(lock: &Mutex<T>) {
942 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
943 let Ok(_held) = lock.lock() else {
944 return;
945 };
946 panic!("poison the lock");
947 }));
948 assert!(result.is_err());
949 assert!(lock.is_poisoned());
950 }
951
952 #[test]
953 fn an_ambiguous_mutation_is_fenced_even_after_a_poisoned_lock() {
954 let client = Client::builder(Environment::Practice, "fixture").build();
955 assert!(client.is_ok());
956 let Ok(client) = client else { return };
957 let account = AccountID::new("101-001-1-001");
958 assert!(account.is_ok());
959 let Ok(account) = account else { return };
960 let guard = MutationGuard::new(client.inner.clone(), &account);
961 assert!(guard.is_ok());
962 let Ok(guard) = guard else { return };
963 poison(&client.inner.fenced);
966 poison(&client.inner.in_flight);
967 drop(guard);
968 let fenced = client
969 .inner
970 .fenced
971 .lock()
972 .unwrap_or_else(std::sync::PoisonError::into_inner);
973 assert!(
974 fenced.contains(account.as_str()),
975 "the ambiguity must be fenced, not dropped"
976 );
977 let in_flight = client
978 .inner
979 .in_flight
980 .lock()
981 .unwrap_or_else(std::sync::PoisonError::into_inner);
982 assert!(!in_flight.contains(account.as_str()));
983 }
984
985 #[test]
986 fn a_failed_acknowledgement_is_reported_and_leaves_the_account_fenced() {
987 let client = Client::builder(Environment::Practice, "fixture").build();
988 assert!(client.is_ok());
989 let Ok(client) = client else { return };
990 let account = AccountID::new("101-001-1-001");
991 assert!(account.is_ok());
992 let Ok(account) = account else { return };
993 {
994 let mut fenced = client
995 .inner
996 .fenced
997 .lock()
998 .unwrap_or_else(std::sync::PoisonError::into_inner);
999 fenced.insert(account.to_string());
1000 }
1001 poison(&client.inner.fenced);
1002 assert!(matches!(
1003 client.acknowledge_reconciliation(&account),
1004 Err(Error::InvalidInput(_))
1005 ));
1006 let fenced = client
1007 .inner
1008 .fenced
1009 .lock()
1010 .unwrap_or_else(std::sync::PoisonError::into_inner);
1011 assert!(fenced.contains(account.as_str()));
1012 }
1013
1014 fn assert_cooldown(header: Option<&str>, expected: Duration) {
1015 let client = Client::builder(Environment::Practice, "fixture").build();
1016 assert!(client.is_ok());
1017 let Ok(client) = client else { return };
1018 poison(&client.inner.rate);
1019 let header =
1020 header.map(|text| reqwest::header::HeaderValue::from_str(text).expect("header"));
1021 let start = RateInstant::now();
1022 client.install_cooldown(header.as_ref());
1023 let rate = client
1024 .inner
1025 .rate
1026 .lock()
1027 .unwrap_or_else(std::sync::PoisonError::into_inner);
1028 let until = rate.cooldown_until.expect("cooldown installed");
1029 let tolerance = Duration::from_secs(1);
1034 assert!(
1035 until + tolerance >= start + expected,
1036 "cooldown shorter than {expected:?}"
1037 );
1038 assert!(
1039 until <= RateInstant::now() + expected + tolerance,
1040 "cooldown longer than {expected:?}"
1041 );
1042 }
1043
1044 #[test]
1045 fn retry_after_reads_both_rfc_9110_forms() {
1046 assert_cooldown(Some("7"), Duration::from_secs(7));
1047 let later = chrono::Utc::now() + chrono::Duration::seconds(30);
1049 assert_cooldown(
1050 Some(&later.format("%a, %d %b %Y %H:%M:%S GMT").to_string()),
1051 Duration::from_secs(29),
1052 );
1053 let earlier = chrono::Utc::now() - chrono::Duration::seconds(30);
1055 assert_cooldown(
1056 Some(&earlier.format("%a, %d %b %Y %H:%M:%S GMT").to_string()),
1057 Duration::ZERO,
1058 );
1059 assert_cooldown(Some("9999"), MAX_COOLDOWN);
1060 }
1061
1062 #[test]
1063 fn an_unreadable_or_absent_retry_after_is_not_read_as_a_provider_instruction() {
1064 assert_cooldown(None, DEFAULT_COOLDOWN);
1066 assert_cooldown(Some("soon"), MAX_COOLDOWN);
1069 }
1070
1071 fn reserved_client() -> Client {
1072 Client::builder(Environment::Practice, "fixture")
1073 .exit_reserve(2)
1074 .build()
1075 .expect("valid reserve")
1076 }
1077
1078 #[test]
1079 fn exit_reserve_rejects_the_full_window_and_larger_values() {
1080 for reserve in [100, 101, u32::MAX] {
1081 assert!(matches!(
1082 Client::builder(Environment::Practice, "fixture")
1083 .exit_reserve(reserve)
1084 .build(),
1085 Err(Error::InvalidInput(_))
1086 ));
1087 }
1088 }
1089
1090 #[tokio::test(start_paused = true)]
1091 async fn ordinary_admission_leaves_two_exits_and_preserves_the_aggregate_limit() {
1092 let client = reserved_client();
1093 for _ in 0..98 {
1094 client.admit(false, false).await.expect("ordinary slot");
1095 }
1096 assert!(matches!(
1097 client.admit(true, false).await,
1098 Err(Error::RateLimited)
1099 ));
1100 let read = client.admit(false, false);
1101 tokio::pin!(read);
1102 assert!(futures_util::poll!(read.as_mut()).is_pending());
1103 client.clone().admit(true, true).await.expect("first exit");
1104 client.clone().admit(true, true).await.expect("second exit");
1105 assert!(matches!(
1106 client.admit(true, true).await,
1107 Err(Error::RateLimited)
1108 ));
1109 assert!(futures_util::poll!(read.as_mut()).is_pending());
1110 tokio::time::advance(Duration::from_millis(999)).await;
1111 assert!(futures_util::poll!(read.as_mut()).is_pending());
1112 tokio::time::advance(Duration::from_millis(1)).await;
1113 read.await.expect("ordinary slot after exact expiry");
1114 }
1115
1116 #[tokio::test(start_paused = true)]
1117 async fn concurrent_clones_cannot_exceed_the_two_remaining_exit_slots() {
1118 let client = reserved_client();
1119 for _ in 0..98 {
1120 client.admit(false, false).await.expect("ordinary slot");
1121 }
1122 let mut tasks = Vec::new();
1123 for _ in 0..20 {
1124 let clone = client.clone();
1125 tasks.push(tokio::spawn(async move { clone.admit(true, true).await }));
1126 }
1127 let mut admitted = 0;
1128 let mut refused = 0;
1129 for task in tasks {
1130 match task.await.expect("join") {
1131 Ok(()) => admitted += 1,
1132 Err(Error::RateLimited) => refused += 1,
1133 result => panic!("unexpected admission: {result:?}"),
1134 }
1135 }
1136 assert_eq!(admitted, 2);
1137 assert_eq!(refused, 18);
1138 }
1139
1140 #[tokio::test(start_paused = true)]
1141 async fn cancelled_ordinary_waiters_do_not_charge_the_next_window() {
1142 let client = reserved_client();
1143 for _ in 0..98 {
1144 client.admit(false, false).await.expect("ordinary slot");
1145 }
1146 {
1147 let read = client.admit(false, false);
1148 tokio::pin!(read);
1149 assert!(futures_util::poll!(read.as_mut()).is_pending());
1150 }
1151 tokio::time::advance(Duration::from_secs(1)).await;
1152 for _ in 0..98 {
1153 client.admit(true, false).await.expect("new ordinary slot");
1154 }
1155 assert!(matches!(
1156 client.admit(true, false).await,
1157 Err(Error::RateLimited)
1158 ));
1159 client.admit(true, true).await.expect("first exit");
1160 client.admit(true, true).await.expect("second exit");
1161 }
1162
1163 #[tokio::test(start_paused = true)]
1164 async fn provider_cooldown_blocks_both_classes_until_exact_expiry() {
1165 let client = reserved_client();
1166 client.install_cooldown(Some(&reqwest::header::HeaderValue::from_static("2")));
1167 assert!(matches!(
1168 client.clone().admit(true, false).await,
1169 Err(Error::RateLimited)
1170 ));
1171 assert!(matches!(
1172 client.clone().admit(true, true).await,
1173 Err(Error::RateLimited)
1174 ));
1175 let read = client.admit(false, false);
1176 tokio::pin!(read);
1177 assert!(futures_util::poll!(read.as_mut()).is_pending());
1178 tokio::time::advance(Duration::from_millis(1999)).await;
1179 assert!(futures_util::poll!(read.as_mut()).is_pending());
1180 assert!(matches!(
1181 client.admit(true, true).await,
1182 Err(Error::RateLimited)
1183 ));
1184 tokio::time::advance(Duration::from_millis(1)).await;
1185 read.await.expect("read after cooldown");
1186 client.admit(true, true).await.expect("exit after cooldown");
1187 }
1188
1189 #[tokio::test(start_paused = true)]
1190 async fn a_shorter_provider_cooldown_cannot_release_an_existing_deadline_early() {
1191 let client = reserved_client();
1192 client.install_cooldown(Some(&reqwest::header::HeaderValue::from_static("5")));
1193 tokio::time::advance(Duration::from_secs(1)).await;
1194 client.install_cooldown(Some(&reqwest::header::HeaderValue::from_static("1")));
1195 tokio::time::advance(Duration::from_secs(1)).await;
1196 assert!(matches!(
1197 client.admit(true, true).await,
1198 Err(Error::RateLimited)
1199 ));
1200 assert!(matches!(
1201 client.admit(true, false).await,
1202 Err(Error::RateLimited)
1203 ));
1204 tokio::time::advance(Duration::from_secs(3)).await;
1205 client
1206 .admit(true, true)
1207 .await
1208 .expect("original cooldown expired");
1209 }
1210
1211 #[tokio::test(start_paused = true)]
1212 async fn runtime_release_wakes_all_registered_waiters_without_advancing_time() {
1213 let client = reserved_client();
1214 for _ in 0..98 {
1215 client.admit(false, false).await.expect("ordinary slot");
1216 }
1217 let first = client.admit(false, false);
1218 let second = client.admit(false, false);
1219 tokio::pin!(first, second);
1220 assert!(futures_util::poll!(first.as_mut()).is_pending());
1221 assert!(futures_util::poll!(second.as_mut()).is_pending());
1222 let started = RateInstant::now();
1223 client.clone().set_exit_reserve(0).expect("release reserve");
1224 assert!(matches!(
1225 futures_util::poll!(first.as_mut()),
1226 std::task::Poll::Ready(Ok(()))
1227 ));
1228 assert!(matches!(
1229 futures_util::poll!(second.as_mut()),
1230 std::task::Poll::Ready(Ok(()))
1231 ));
1232 assert_eq!(RateInstant::now(), started);
1233 assert!(matches!(
1234 client.admit(true, true).await,
1235 Err(Error::RateLimited)
1236 ));
1237 }
1238
1239 #[tokio::test(start_paused = true)]
1240 async fn runtime_growth_rechecks_waiters_and_preserves_spent_capacity() {
1241 let client = reserved_client();
1242 for _ in 0..98 {
1243 client.admit(false, false).await.expect("ordinary slot");
1244 }
1245 let read = client.admit(false, false);
1246 tokio::pin!(read);
1247 assert!(futures_util::poll!(read.as_mut()).is_pending());
1248 client.set_exit_reserve(0).expect("release reserve");
1249 client
1250 .clone()
1251 .set_exit_reserve(99)
1252 .expect("replace reserve");
1253 assert!(futures_util::poll!(read.as_mut()).is_pending());
1254 client.admit(true, true).await.expect("first exit");
1255 client.admit(true, true).await.expect("second exit");
1256 assert!(matches!(
1257 client.admit(true, true).await,
1258 Err(Error::RateLimited)
1259 ));
1260 client
1261 .set_exit_reserve(0)
1262 .expect("release exhausted reserve");
1263 client
1264 .set_exit_reserve(99)
1265 .expect("raise exhausted reserve");
1266 assert!(matches!(
1267 client.admit(true, true).await,
1268 Err(Error::RateLimited)
1269 ));
1270 client.set_exit_reserve(0).expect("release reserve");
1271 assert!(futures_util::poll!(read.as_mut()).is_pending());
1272 tokio::time::advance(Duration::from_secs(1)).await;
1273 assert!(matches!(
1274 futures_util::poll!(read.as_mut()),
1275 std::task::Poll::Ready(Ok(()))
1276 ));
1277 }
1278
1279 #[tokio::test(start_paused = true)]
1280 async fn runtime_changes_preserve_cooldown_window_and_invalid_policy_state() {
1281 let client = reserved_client();
1282 for _ in 0..98 {
1283 client.admit(false, false).await.expect("ordinary slot");
1284 }
1285 client.install_cooldown(Some(&reqwest::header::HeaderValue::from_static("2")));
1286 for reserve in [99, 0, 2] {
1287 client.set_exit_reserve(reserve).expect("replace reserve");
1288 assert!(matches!(
1289 client.admit(true, true).await,
1290 Err(Error::RateLimited)
1291 ));
1292 }
1293 for reserve in [100, u32::MAX] {
1294 assert!(matches!(
1295 client.set_exit_reserve(reserve),
1296 Err(Error::InvalidInput(_))
1297 ));
1298 let rate = client.inner.rate.lock().expect("rate lock");
1299 assert_eq!(rate.exit_reserve, 2);
1300 assert_eq!(rate.used, 98);
1301 }
1302 client.set_exit_reserve(0).expect("release reserve");
1303 let read = client.admit(false, false);
1304 tokio::pin!(read);
1305 assert!(futures_util::poll!(read.as_mut()).is_pending());
1306 tokio::time::advance(Duration::from_secs(1)).await;
1307 assert!(futures_util::poll!(read.as_mut()).is_pending());
1308 tokio::time::advance(Duration::from_secs(1)).await;
1309 assert!(matches!(
1310 futures_util::poll!(read.as_mut()),
1311 std::task::Poll::Ready(Ok(()))
1312 ));
1313 for _ in 0..99 {
1314 client
1315 .admit(true, true)
1316 .await
1317 .expect("remaining aggregate capacity");
1318 }
1319 assert!(matches!(
1320 client.admit(true, true).await,
1321 Err(Error::RateLimited)
1322 ));
1323 }
1324
1325 #[tokio::test(start_paused = true)]
1326 async fn runtime_replacement_keeps_the_original_window_deadline_and_independent_client() {
1327 let client = reserved_client();
1328 for _ in 0..98 {
1329 client.admit(false, false).await.expect("ordinary slot");
1330 }
1331 tokio::time::advance(Duration::from_millis(900)).await;
1332 client.set_exit_reserve(3).expect("replace reserve");
1333 assert!(matches!(
1334 client.admit(true, false).await,
1335 Err(Error::RateLimited)
1336 ));
1337 let independent = reserved_client();
1338 independent
1339 .admit(true, false)
1340 .await
1341 .expect("independent budget");
1342 client.admit(true, true).await.expect("first exit");
1343 client.admit(true, true).await.expect("second exit");
1344 tokio::time::advance(Duration::from_millis(100)).await;
1345 for _ in 0..97 {
1346 client
1347 .admit(true, false)
1348 .await
1349 .expect("new ordinary capacity");
1350 }
1351 assert!(matches!(
1352 client.admit(true, false).await,
1353 Err(Error::RateLimited)
1354 ));
1355 }
1356
1357 #[test]
1358 fn runtime_setter_reports_poison_and_preserves_policy_and_admission_state() {
1359 let client = reserved_client();
1360 poison(&client.inner.rate);
1361 assert!(matches!(
1362 client.set_exit_reserve(0),
1363 Err(Error::InvalidInput(_))
1364 ));
1365 let rate = client
1366 .inner
1367 .rate
1368 .lock()
1369 .unwrap_or_else(std::sync::PoisonError::into_inner);
1370 assert_eq!(rate.exit_reserve, 2);
1371 assert_eq!(rate.used, 0);
1372 }
1373
1374 #[tokio::test(start_paused = true)]
1375 async fn runtime_waiter_rechecks_each_replacement_including_release_then_growth() {
1376 let client = reserved_client();
1377 client.set_exit_reserve(99).expect("raise reserve");
1378 client.admit(false, false).await.expect("one ordinary slot");
1379 let read = client.admit(false, false);
1380 tokio::pin!(read);
1381 assert!(futures_util::poll!(read.as_mut()).is_pending());
1382 client.set_exit_reserve(0).expect("release reserve");
1383 client.set_exit_reserve(99).expect("raise reserve");
1384 assert!(futures_util::poll!(read.as_mut()).is_pending());
1385 client.set_exit_reserve(98).expect("replace reserve");
1386 assert!(matches!(
1387 futures_util::poll!(read.as_mut()),
1388 std::task::Poll::Ready(Ok(()))
1389 ));
1390 assert!(matches!(
1391 client.admit(true, false).await,
1392 Err(Error::RateLimited)
1393 ));
1394 }
1395}