Skip to main content

oanda_client/
client.rs

1//! Client construction and shared HTTP execution policy.
2
3use 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/// A typed OANDA response with the provider's request and pagination metadata.
25#[derive(Debug, Clone)]
26pub struct ApiResponse<T> {
27    /// Deserialized response body.
28    pub body: T,
29    /// OANDA's `RequestID` response header as supplied evidence: `Absent` when
30    /// OANDA sent none, `Decoded` when this client read it, `Undecoded` when it
31    /// sent one this client cannot read as text.
32    pub request_id: Supplied<String>,
33    /// The Link header's next-page URL as supplied evidence: `Absent` when no
34    /// next page was offered, `Decoded` when validated, `Undecoded` when OANDA
35    /// offered one this client could not read. A readable link that leaves this
36    /// client's API origin is refused with an error instead: it decoded fine
37    /// and was rejected, not lost.
38    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/// Shareable OANDA v20 client.
49#[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
103/// Validated client configuration.
104pub 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    /// Replace the shared request reserve for cancellation and position or trade closes.
126    ///
127    /// Zero releases the reserve. All clones observe the latest replacement;
128    /// waiting reads and stream starts reconsider it immediately. One caller
129    /// coordinator should own this policy. Changes preserve spent capacity,
130    /// provider cooldowns and already admitted requests. Exit capacity is finite.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`Error::InvalidInput`] when `requests` is at least 100 or the rate
135    /// lock is poisoned. Invalid changes preserve the policy and admission state.
136    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    /// Start building a client with an injected personal access token.
157    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    /// Allow mutations after the caller has reconciled this account against OANDA state.
171    ///
172    /// This is an acknowledgement by the caller, not a state query performed by the client.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error when the shared fence set cannot be locked. The account then stays
177    /// fenced, so its remaining mutations are refused until a client that can lock succeeds.
178    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    // Transport success, rejection, and ambiguity handling stay together for auditability.
189    #[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        // Reserve the account before the first network await. This prevents two
233        // concurrent clones from sending mutations whose outcomes cannot be ordered.
234        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        // `errorCode` and `errorMessage` are read one key at a time, so a value
307        // with an unexpected type erases only its own key and not its sibling —
308        // exactly how `open_http_stream` reads a refused stream's reason. A
309        // body this client cannot read as JSON yields no reason, and `body`
310        // above is what tells the caller one arrived regardless.
311        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    /// Follow a provider pagination link on this client's REST origin.
324    ///
325    /// The caller supplies the expected typed body for the originating list operation.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error for an off-origin link, transport failure, provider rejection, or invalid response.
330    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    /// Install the cooldown OANDA's `Retry-After` asks for, shared by every clone.
434    ///
435    /// A header that is absent is a provider that gave no instruction, so the
436    /// documented local default applies. A header that is present but unreadable
437    /// is an instruction this client failed to read: it takes the maximum local
438    /// cooldown, which is local policy and is never attributed to OANDA.
439    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        // The critical section touches two plain fields, so a poisoned lock still
445        // holds a readable rate window. Skipping the install would report a
446        // rate-limited account as one the client may keep calling.
447        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
460/// Read an RFC 9110 `Retry-After` value: delta-seconds or an HTTP-date.
461///
462/// A date already in the past asks for no wait at all, which is a stated
463/// instruction rather than a missing one.
464fn 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    /// Set the maximum non-stream response body size in bytes.
481    #[must_use]
482    pub fn max_response_bytes(mut self, value: usize) -> Self {
483        self.max_body = value;
484        self
485    }
486
487    /// Reserve requests in the shared local 100-request, one-second window for exits.
488    ///
489    /// The default is zero. Only `cancel_order`, `close_trade`, and `close_position`
490    /// can use the reserve. All requests still count toward the same aggregate limit
491    /// and provider cooldown. The value must be less than 100; `build` validates it.
492    #[must_use]
493    pub fn exit_reserve(mut self, requests: u32) -> Self {
494        self.exit_reserve = requests;
495        self
496    }
497
498    /// Set the REST request timeout.
499    #[must_use]
500    pub fn timeout(mut self, value: Duration) -> Self {
501        self.timeout = value;
502        self
503    }
504    /// Select the documented OANDA timestamp wire format for responses.
505    #[must_use]
506    pub fn datetime_format(mut self, value: AcceptDatetimeFormat) -> Self {
507        self.datetime_format = value;
508        self
509    }
510    /// Override both hosts for deterministic loopback fixtures.
511    /// Remote endpoints must remain the selected OANDA environment pair.
512    #[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    /// Construct a client without making a network request.
520    ///
521    /// # Errors
522    ///
523    /// Returns an error if credentials, limits, or endpoint overrides are invalid, or if the
524    /// underlying HTTP clients cannot be constructed.
525    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
626/// Read a provider header as supplied evidence.
627///
628/// A header OANDA did not send, and one it sent with bytes this client cannot
629/// read as text, are different facts; the second must not collapse into the
630/// first.
631fn 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
641/// Read the Link header's next-page URL as supplied evidence.
642///
643/// A Link header OANDA did not send, and one that is readable but offers no
644/// `rel="next"` item, are both `Absent`: no next page arrived. A Link this
645/// client cannot read as text, or whose next item does not match the
646/// documented shape, is `Undecoded`: a next page was offered and could not be
647/// surfaced. A next link that decodes but leaves this client's own API origin
648/// is an error instead: that value was read and refused, never lost.
649fn 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    // The item was readable and offered a next page, so a shape that does not
663    // parse is a decoded instruction this client must refuse loudly — the same
664    // treatment a readable link that leaves this client's origin gets. A
665    // caller paging a result set learns immediately that pagination broke
666    // instead of reading Undecoded as "no next page".
667    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    /// Read each reason key on its own. A struct-level deserialize would fail
719    /// on the first mistyped field and erase both keys, reporting a reason
720    /// OANDA sent as one it never did.
721    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    /// No reason this client could read. It is distinct from a reason OANDA read
739    /// and omitted, and only the rejection body's own state separates the two
740    /// for a caller.
741    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        // The previous guard may have fenced the account between the first
774        // check and this reservation. Recheck while holding the reservation.
775        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        // A dropped guard cannot report a failure, so it must not skip the fence:
800        // the ambiguity it stands for is exactly what a later mutation must not
801        // act on. The critical sections hold plain `HashSet` values, so a
802        // poisoned lock still carries the readable account set.
803        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// Poisoning a lock requires a panic, and fixture failures are clearer as panics;
831// production paths return typed errors.
832#[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        // The locks fail after the guard is armed, which is the only ordering in
964        // which a dropped guard owes the caller a fence.
965        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        // An RFC 9110 date carries whole seconds, so formatting a later
1030        // instant floors it and `num_seconds` floors again; a slow runner can
1031        // legitimately land one second under the nominal span. One second of
1032        // tolerance each way keeps the bound meaningful without flaking.
1033        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        // An HTTP-date OANDA is entitled to send is read, not treated as garbage.
1048        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        // A date already past asks for no wait at all.
1054        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        // No header: the documented local default, not a value OANDA supplied.
1065        assert_cooldown(None, DEFAULT_COOLDOWN);
1066        // A header this client cannot read: the local maximum, never a value the
1067        // provider did not send and never the minimum the old fallback assumed.
1068        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}