Skip to main content

olai_http/
lib.rs

1//! Unified cloud credential abstraction and HTTP client for AWS, Azure, GCP, and
2//! Databricks.
3//!
4//! This crate provides a single authenticated HTTP client, [`CloudClient`], that
5//! signs outgoing requests for any supported cloud provider. Rather than pulling
6//! in a separate vendor SDK for each cloud, a service constructs one
7//! [`CloudClient`] per provider and issues requests through a familiar,
8//! `reqwest`-style builder ([`CloudRequestBuilder`]). Every provider is reached
9//! through the same [`RequestSigner`] trait, so credential resolution, token
10//! refresh, and request signing are uniform across clouds. The credential
11//! machinery is extracted from the
12//! [`object_store`](https://crates.io/crates/object_store) crate's internal client.
13//!
14//! # Providers
15//!
16//! Each provider has a builder under its own module and a matching
17//! [`CloudClient`] constructor:
18//!
19//! - **AWS** ([`aws`]) — SigV4 signing with static keys, IMDS, ECS/EKS task
20//!   roles, web identity, and STS `AssumeRole`. See [`CloudClient::new_aws`].
21//! - **Azure** ([`azure`]) — Azure AD bearer tokens via client secret, managed
22//!   identity, workload identity, or the Azure CLI. See [`CloudClient::new_azure`].
23//! - **Google Cloud** ([`gcp`]) — OAuth 2.0 bearer tokens via service-account
24//!   JWTs, the GCE metadata server, or workload identity federation. See
25//!   [`CloudClient::new_google`].
26//! - **Databricks** ([`databricks`]) — OAuth M2M and OIDC token exchange. See
27//!   [`CloudClient::new_databricks`].
28//!
29//! For a static token or no authentication at all, use
30//! [`CloudClient::new_with_token`] or [`CloudClient::new_unauthenticated`].
31//!
32//! # Examples
33//!
34//! ```no_run
35//! use olai_http::CloudClient;
36//!
37//! # async fn run() -> olai_http::Result<()> {
38//! let client = CloudClient::new_with_token("my-token");
39//! let resp = client
40//!     .get("https://api.example.com/data")
41//!     .send()
42//!     .await?;
43//! println!("status: {}", resp.status());
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! Enable the `recording` feature to capture HTTP interactions to JSON (with
49//! sensitive headers redacted) for test replay.
50
51#[cfg(feature = "recording")]
52use std::collections::HashMap;
53#[cfg(feature = "recording")]
54use std::path::PathBuf;
55use std::sync::Arc;
56#[cfg(feature = "recording")]
57use std::sync::atomic::{AtomicU64, Ordering};
58use std::time::Duration;
59
60use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
61use reqwest::{Body, Client, IntoUrl, Method, RequestBuilder};
62use serde::Serialize;
63use tokio::runtime::Handle;
64
65use self::retry::RetryExt;
66use self::service::{HttpService, make_service};
67pub use self::token::{TemporaryToken, TokenCache};
68
69pub mod aws;
70pub mod azure;
71mod backoff;
72mod client;
73mod config;
74#[cfg(feature = "connectrpc")]
75pub mod connectrpc;
76mod credential;
77pub mod databricks;
78mod error;
79pub mod gcp;
80pub mod service;
81
82mod retry;
83mod token;
84mod util;
85
86pub use client::{Certificate, ClientConfigKey, ClientOptions};
87pub use credential::*;
88pub use error::*;
89pub use retry::Error as RetryError;
90pub use retry::RetryConfig;
91pub use service::{ReqwestService, SpawnService};
92
93/// A shared, cloneable signer for a `CloudClient`.
94type SharedSigner = Arc<dyn RequestSigner>;
95
96/// A no-op signer used by `new_unauthenticated`.
97#[derive(Debug)]
98struct NoopSigner;
99
100impl RequestSigner for NoopSigner {
101    fn sign<'a>(
102        &'a self,
103        req: RequestBuilder,
104    ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
105        Box::pin(async move { Ok(req) })
106    }
107}
108
109/// A signer that injects a static bearer token.
110#[derive(Debug)]
111struct BearerTokenSigner {
112    token: String,
113}
114
115impl RequestSigner for BearerTokenSigner {
116    fn sign<'a>(
117        &'a self,
118        req: RequestBuilder,
119    ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
120        let token = self.token.clone();
121        Box::pin(async move { Ok(req.bearer_auth(&token)) })
122    }
123}
124
125#[cfg(feature = "recording")]
126#[derive(Debug, Clone)]
127struct RecordingState {
128    out_dir: PathBuf,
129    counter: Arc<AtomicU64>,
130}
131
132/// An authenticated HTTP client for cloud provider APIs.
133///
134/// Created via the provider-specific constructors [`CloudClient::new_aws`],
135/// [`CloudClient::new_azure`], [`CloudClient::new_google`], or
136/// [`CloudClient::new_databricks`], or the simpler [`CloudClient::new_with_token`]
137/// and [`CloudClient::new_unauthenticated`].
138#[derive(Clone)]
139pub struct CloudClient {
140    signer: SharedSigner,
141    reqwest_client: Client,
142    service: Arc<dyn HttpService>,
143    /// Retry configuration applied to requests sent through this client.
144    ///
145    /// Used both by credential providers (token refresh) and by user-initiated
146    /// requests via [`CloudRequestBuilder::send`] and
147    /// [`CloudClient::sign_and_send`]. Override with
148    /// [`CloudClient::with_retry_config`].
149    pub retry_config: RetryConfig,
150    #[cfg(feature = "recording")]
151    recording: Option<RecordingState>,
152}
153
154impl CloudClient {
155    fn new_with_signer(
156        signer: SharedSigner,
157        reqwest_client: Client,
158        service: Arc<dyn HttpService>,
159        retry_config: RetryConfig,
160    ) -> Self {
161        Self {
162            signer,
163            reqwest_client,
164            service,
165            retry_config,
166            #[cfg(feature = "recording")]
167            recording: None,
168        }
169    }
170
171    /// Create a new client with AWS credentials.
172    ///
173    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
174    /// will be spawned on the given runtime handle.
175    pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
176    where
177        I: IntoIterator<Item = (K, V)>,
178        K: AsRef<str>,
179        V: Into<String>,
180    {
181        let config = options
182            .into_iter()
183            .fold(
184                aws::AmazonBuilder::new(),
185                |builder, (key, value)| match key.as_ref().parse() {
186                    Ok(k) => builder.with_config(k, value),
187                    Err(_) => builder,
188                },
189            )
190            .build(runtime)?;
191
192        let reqwest_client = config.client_options.client()?;
193        let service = make_service(reqwest_client.clone(), runtime);
194        let retry_config = config.retry_config.clone();
195        Ok(Self::new_with_signer(
196            Arc::new(config),
197            reqwest_client,
198            service,
199            retry_config,
200        ))
201    }
202
203    /// Create a new client with Google Cloud credentials.
204    ///
205    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
206    /// will be spawned on the given runtime handle.
207    pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
208    where
209        I: IntoIterator<Item = (K, V)>,
210        K: AsRef<str>,
211        V: Into<String>,
212    {
213        let config = options
214            .into_iter()
215            .fold(
216                gcp::GoogleBuilder::new(),
217                |builder, (key, value)| match key.as_ref().parse() {
218                    Ok(k) => builder.with_config(k, value),
219                    Err(_) => builder,
220                },
221            )
222            .build(runtime)?;
223
224        let reqwest_client = config.client_options.client()?;
225        let service = make_service(reqwest_client.clone(), runtime);
226        let retry_config = config.retry_config.clone();
227        Ok(Self::new_with_signer(
228            Arc::new(config),
229            reqwest_client,
230            service,
231            retry_config,
232        ))
233    }
234
235    /// Create a new client with Azure credentials.
236    ///
237    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
238    /// will be spawned on the given runtime handle.
239    pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
240    where
241        I: IntoIterator<Item = (K, V)>,
242        K: AsRef<str>,
243        V: Into<String>,
244    {
245        let config = options
246            .into_iter()
247            .fold(
248                azure::AzureBuilder::new(),
249                |builder, (key, value)| match key.as_ref().parse() {
250                    Ok(k) => builder.with_config(k, value),
251                    Err(_) => builder,
252                },
253            )
254            .build(runtime)?;
255
256        let reqwest_client = config.client_options.client()?;
257        let service = make_service(reqwest_client.clone(), runtime);
258        let retry_config = config.retry_config.clone();
259        Ok(Self::new_with_signer(
260            Arc::new(config),
261            reqwest_client,
262            service,
263            retry_config,
264        ))
265    }
266
267    /// Create a new client with Databricks credentials.
268    ///
269    /// If `runtime` is provided, all HTTP I/O (including credential refresh)
270    /// will be spawned on the given runtime handle.
271    pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
272    where
273        I: IntoIterator<Item = (K, V)>,
274        K: AsRef<str>,
275        V: Into<String>,
276    {
277        use databricks::DatabricksBuilder;
278
279        let config = options
280            .into_iter()
281            .fold(
282                DatabricksBuilder::new(),
283                |builder, (key, value)| match key.as_ref().parse() {
284                    Ok(k) => builder.with_config(k, value),
285                    Err(_) => builder,
286                },
287            )
288            .build(runtime)?;
289
290        let reqwest_client = config.client_options.client()?;
291        let service = make_service(reqwest_client.clone(), runtime);
292        let retry_config = config.retry_config.clone();
293        Ok(Self::new_with_signer(
294            Arc::new(config),
295            reqwest_client,
296            service,
297            retry_config,
298        ))
299    }
300
301    /// Create a new client with a personal access token.
302    pub fn new_with_token(token: impl ToString) -> Self {
303        let reqwest_client = Client::new();
304        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
305        Self::new_with_signer(
306            Arc::new(BearerTokenSigner {
307                token: token.to_string(),
308            }),
309            reqwest_client,
310            service,
311            RetryConfig::default(),
312        )
313    }
314
315    /// Create a new unauthenticated client.
316    pub fn new_unauthenticated() -> Self {
317        let reqwest_client = Client::new();
318        let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
319        Self::new_with_signer(
320            Arc::new(NoopSigner),
321            reqwest_client,
322            service,
323            RetryConfig::default(),
324        )
325    }
326
327    /// Route all HTTP I/O through the given runtime handle.
328    ///
329    /// This is useful for simple constructors (`new_with_token`, `new_unauthenticated`)
330    /// where no credential providers need to perform HTTP I/O. For cloud provider
331    /// constructors, pass the handle at construction time instead.
332    pub fn with_runtime(mut self, handle: Handle) -> Self {
333        self.service = Arc::new(SpawnService::new(self.service, handle));
334        self
335    }
336
337    /// Replace the [`HttpService`] used for request execution.
338    pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
339        self.service = service;
340        self
341    }
342
343    /// Override the [`RetryConfig`] applied to requests sent through this client.
344    ///
345    /// Requests issued via [`CloudRequestBuilder::send`] and
346    /// [`sign_and_send`](Self::sign_and_send) are retried per this config
347    /// (exponential backoff with jitter; safe/idempotent requests are also
348    /// retried on timeout). The [default](RetryConfig::default) allows up to 10
349    /// retries — lower it for latency-sensitive interactive calls.
350    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
351        self.retry_config = retry_config;
352        self
353    }
354
355    /// Sign an already-built [`reqwest::Request`] and dispatch it, with retries.
356    ///
357    /// This is the request-level counterpart to the [`CloudRequestBuilder`]
358    /// flow: it applies the client's [`RequestSigner`] (refreshing credentials
359    /// as needed) and sends the request through the configured [`HttpService`],
360    /// retrying transient failures per the client's [`RetryConfig`]. It is
361    /// useful when the request is produced elsewhere (for example by a protocol
362    /// stack such as ConnectRPC) and only needs authentication and transport.
363    ///
364    /// The request body must be in memory (not a stream): retries clone the
365    /// request, and provider signers such as AWS SigV4 hash the body to sign it.
366    ///
367    /// # Errors
368    ///
369    /// Returns an [`Error`] if signing fails or if the request ultimately fails
370    /// after exhausting retries. A non-success HTTP status code is not itself an
371    /// error; inspect [`reqwest::Response::status`] on the returned response.
372    pub async fn sign_and_send(&self, request: reqwest::Request) -> Result<reqwest::Response> {
373        // The signer operates on a `RequestBuilder`; reconstruct one from the
374        // pre-built request (cloning the in-memory body) so the existing
375        // sign + retry path applies unchanged.
376        let builder = self
377            .reqwest_client
378            .request(request.method().clone(), request.url().clone());
379        let builder = builder.headers(request.headers().clone());
380        let builder = match request.body().and_then(|b| b.as_bytes()) {
381            Some(bytes) => builder.body(bytes.to_vec()),
382            None => builder,
383        };
384        let builder = self.signer.sign(builder).await?;
385        builder
386            .send_retry(&self.retry_config, self.service.clone())
387            .await
388            .map_err(|e| e.error())
389    }
390
391    /// Start building a request for the given HTTP `method` and `url`.
392    ///
393    /// The returned [`CloudRequestBuilder`] borrows this client's signer, so the
394    /// request is signed for the configured provider when
395    /// [`CloudRequestBuilder::send`] is called.
396    pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
397        CloudRequestBuilder {
398            builder: self.reqwest_client.request(method, url),
399            client: self.clone(),
400            #[cfg(feature = "recording")]
401            out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
402        }
403    }
404
405    /// Start building a `GET` request for `url`. Shortcut for [`request`](Self::request).
406    pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
407        self.request(Method::GET, url)
408    }
409
410    /// Start building a `POST` request for `url`. Shortcut for [`request`](Self::request).
411    pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
412        self.request(Method::POST, url)
413    }
414
415    /// Start building a `PUT` request for `url`. Shortcut for [`request`](Self::request).
416    pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
417        self.request(Method::PUT, url)
418    }
419
420    /// Start building a `DELETE` request for `url`. Shortcut for [`request`](Self::request).
421    pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
422        self.request(Method::DELETE, url)
423    }
424
425    /// Start building a `HEAD` request for `url`. Shortcut for [`request`](Self::request).
426    pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
427        self.request(Method::HEAD, url)
428    }
429
430    /// Start building a `PATCH` request for `url`. Shortcut for [`request`](Self::request).
431    pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
432        self.request(Method::PATCH, url)
433    }
434
435    /// Start building an `OPTIONS` request for `url`. Shortcut for [`request`](Self::request).
436    pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
437        self.request(Method::OPTIONS, url)
438    }
439
440    /// Start building a `TRACE` request for `url`. Shortcut for [`request`](Self::request).
441    pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
442        self.request(Method::TRACE, url)
443    }
444
445    /// Start building a `CONNECT` request for `url`. Shortcut for [`request`](Self::request).
446    pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
447        self.request(Method::CONNECT, url)
448    }
449
450    /// Enable request/response recording, writing each interaction to `out_dir`.
451    ///
452    /// Once set, every request sent through this client is captured to a
453    /// numbered JSON file (`0000.json`, `0001.json`, …) under `out_dir` for
454    /// later test replay. Sensitive response headers (`authorization`,
455    /// `x-amz-security-token`, `cookie`, and similar) are replaced with
456    /// `"<REDACTED>"` before anything is written to disk, so
457    /// recordings never persist bearer tokens or signing secrets. Request
458    /// headers are not recorded at all.
459    ///
460    /// `out_dir` is canonicalized eagerly, so it must already exist.
461    ///
462    /// Only available when the `recording` feature is enabled.
463    ///
464    /// # Errors
465    ///
466    /// Returns the [`io::Error`](std::io::Error) from canonicalizing `out_dir`,
467    /// for example if the directory does not exist or is not accessible.
468    #[cfg(feature = "recording")]
469    pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
470        let out_dir = std::fs::canonicalize(out_dir)?;
471        self.recording = Some(RecordingState {
472            out_dir,
473            counter: Arc::new(AtomicU64::new(0)),
474        });
475        Ok(())
476    }
477}
478
479/// A builder for a single request issued through a [`CloudClient`].
480///
481/// Created by [`CloudClient::request`] and the per-verb shortcuts such as
482/// [`CloudClient::get`]. Configure the request with the builder methods, then
483/// call [`send`](Self::send) to sign and dispatch it.
484pub struct CloudRequestBuilder {
485    builder: RequestBuilder,
486    client: CloudClient,
487    #[cfg(feature = "recording")]
488    out_dir: Option<PathBuf>,
489}
490
491impl CloudRequestBuilder {
492    /// Add a `Header` to this Request.
493    pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
494    where
495        HeaderName: TryFrom<K>,
496        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
497        HeaderValue: TryFrom<V>,
498        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
499    {
500        self.builder = self.builder.header(key, value);
501        self
502    }
503
504    /// Add a set of Headers to the existing ones on this Request.
505    ///
506    /// The headers will be merged in to any already set.
507    pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
508        self.builder = self.builder.headers(headers);
509        self
510    }
511
512    /// Set the request body.
513    pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
514        self.builder = self.builder.body(body);
515        self
516    }
517
518    /// Enables a request timeout.
519    ///
520    /// The timeout is applied from when the request starts connecting until the
521    /// response body has finished. It affects only this request and overrides
522    /// the timeout configured using `ClientBuilder::timeout()`.
523    pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
524        self.builder = self.builder.timeout(timeout);
525        self
526    }
527
528    /// Modify the query string of the URL.
529    ///
530    /// Modifies the URL of this request, adding the parameters provided.
531    /// This method appends and does not overwrite. This means that it can
532    /// be called multiple times and that existing query parameters are not
533    /// overwritten if the same key is used. The key will simply show up
534    /// twice in the query string.
535    /// Calling `.query(&[("foo", "a"), ("foo", "b")])` gives `"foo=a&foo=b"`.
536    ///
537    /// # Note
538    /// This method does not support serializing a single key-value
539    /// pair. Instead of using `.query(("key", "val"))`, use a sequence, such
540    /// as `.query(&[("key", "val")])`. It's also possible to serialize structs
541    /// and maps into a key-value pair.
542    ///
543    /// # Errors
544    /// This method will fail if the object you provide cannot be serialized
545    /// into a query string.
546    pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
547        self.builder = self.builder.query(query);
548        self
549    }
550
551    /// Send a JSON body.
552    ///
553    /// # Errors
554    ///
555    /// Serialization can fail if `T`'s implementation of `Serialize` decides to
556    /// fail, or if `T` contains a map with non-string keys.
557    pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
558        self.builder = self.builder.json(json);
559        self
560    }
561
562    /// Sign and send the request, returning the [`reqwest::Response`].
563    ///
564    /// The request is first passed to the client's [`RequestSigner`], which
565    /// attaches the provider-specific authentication (e.g. AWS SigV4 headers or
566    /// an `Authorization: Bearer` header), refreshing any cached credential as
567    /// needed. The signed request is then dispatched through the client's
568    /// [`HttpService`], retrying transient failures per the client's
569    /// [`RetryConfig`] and mapping a non-success status to the matching
570    /// [`Error`].
571    ///
572    /// When the `recording` feature is enabled and a recording directory has
573    /// been configured via `CloudClient::set_recording_dir`, the request and
574    /// response are instead captured to disk (with sensitive headers redacted)
575    /// in a single round-trip — this capture path does **not** retry or map
576    /// status codes to errors, so use the default build for production traffic.
577    ///
578    /// # Errors
579    ///
580    /// Returns an [`Error`] if signing fails, if the request cannot be built, or
581    /// if the underlying HTTP transport returns an error. A non-success HTTP
582    /// status code is not itself an error; inspect [`reqwest::Response::status`]
583    /// on the returned response.
584    pub async fn send(mut self) -> Result<reqwest::Response> {
585        self.builder = self.client.signer.sign(self.builder).await?;
586
587        #[cfg(not(feature = "recording"))]
588        {
589            self.builder
590                .send_retry(&self.client.retry_config, self.client.service.clone())
591                .await
592                .map_err(|e| e.error())
593        }
594        #[cfg(feature = "recording")]
595        {
596            let response = send_record(self).await?;
597            Ok(response)
598        }
599    }
600
601    /// Sign and send the request, preserving the classified non-success result.
602    ///
603    /// This behaves exactly like [`send`](Self::send) — same signing, same
604    /// [`RetryConfig`] semantics (5xx and transport failures retried, 4xx
605    /// returned immediately) — but a non-success outcome is surfaced as the
606    /// [`RetryError`] the retry layer already classified, rather than being
607    /// remapped through [`RetryError::error`] into an opaque [`Error`].
608    ///
609    /// Use this when the caller needs the HTTP **status** and **response body**
610    /// of a failed request — for example to parse a protocol-specific error
611    /// envelope. [`RetryError::status`] and [`RetryError::body`] expose them;
612    /// [`send`](Self::send) discards both by boxing the status into a coarse
613    /// [`Error`] variant (`NotFound`, `AlreadyExists`, …).
614    ///
615    /// When the `recording` feature is enabled the request is captured to disk
616    /// via the same single-shot path as [`send`](Self::send) and the recorded
617    /// [`reqwest::Response`] is returned for any status (no status→error
618    /// mapping), so a non-success status is `Ok(response)` in that build — the
619    /// caller inspects [`reqwest::Response::status`] itself.
620    ///
621    /// # Errors
622    ///
623    /// Returns [`Error`] if signing fails. Otherwise, on a non-success status
624    /// (in the default build) returns [`RetryError`] carrying the status and
625    /// response body; a transport failure surfaces as
626    /// [`RetryError::Reqwest`]/[`RetryError::Transport`].
627    #[cfg(not(feature = "recording"))]
628    pub async fn send_raw(mut self) -> Result<reqwest::Response, SendRawError> {
629        self.builder = self.client.signer.sign(self.builder).await?;
630        self.builder
631            .send_retry(&self.client.retry_config, self.client.service.clone())
632            .await
633            .map_err(SendRawError::Retry)
634    }
635
636    /// See [`send_raw`](Self::send_raw). Under the `recording` feature this maps
637    /// to the single-shot capture path and returns the response for any status.
638    #[cfg(feature = "recording")]
639    pub async fn send_raw(self) -> Result<reqwest::Response, SendRawError> {
640        send_record(self).await
641    }
642}
643
644/// The error returned by [`CloudRequestBuilder::send_raw`].
645///
646/// Distinguishes a failure to *sign* the request (an [`Error`] from the signer,
647/// e.g. a credential-refresh failure) from a failure of the *request itself*
648/// (a [`RetryError`] carrying the HTTP status and response body). This lets a
649/// caller parse a protocol error envelope from [`RetryError::body`] while still
650/// propagating signing failures faithfully.
651#[cfg(not(feature = "recording"))]
652#[derive(Debug, thiserror::Error)]
653pub enum SendRawError {
654    /// The request could not be signed (e.g. credential resolution failed).
655    #[error(transparent)]
656    Sign(#[from] Error),
657    /// The request was sent but failed; carries the classified status + body.
658    #[error(transparent)]
659    Retry(RetryError),
660}
661
662/// Under the `recording` feature `send_raw` returns the recorded response for
663/// any status, so the only error it can produce is a signing/capture [`Error`].
664#[cfg(feature = "recording")]
665pub type SendRawError = Error;
666
667#[cfg(feature = "recording")]
668#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
669pub struct RequestResponseInfo {
670    pub request: RequestInfo,
671    pub response: ResponseInfo,
672}
673
674#[cfg(feature = "recording")]
675#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
676pub struct RequestInfo {
677    pub method: String,
678    pub url_path: String,
679    pub body: Option<String>,
680}
681
682#[cfg(feature = "recording")]
683#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
684pub struct ResponseInfo {
685    pub status: u16,
686    pub headers: HashMap<String, String>,
687    pub body: Option<String>,
688}
689
690/// Header names whose values must never appear in recording files.
691///
692/// These headers carry bearer tokens, signing secrets, or session tokens.
693/// They are replaced with `"<REDACTED>"` before any recording is written to disk.
694#[cfg(feature = "recording")]
695const SENSITIVE_HEADERS: &[&str] = &[
696    "authorization",
697    "x-amz-security-token",
698    "x-amz-content-sha256",
699    "x-databricks-authorization",
700    "x-ms-identity-principal-id",
701    "x-goog-iam-credentials-token",
702    "cookie",
703    "set-cookie",
704];
705
706#[cfg(feature = "recording")]
707fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
708    headers
709        .iter()
710        .map(|(k, v)| {
711            let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
712                "<REDACTED>".to_string()
713            } else {
714                v.clone()
715            };
716            (k.clone(), v)
717        })
718        .collect()
719}
720
721#[cfg(feature = "recording")]
722async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
723    let Some(out_dir) = builder.out_dir else {
724        let request = builder.builder.build().expect("request to be valid");
725        return builder.client.service.call(request).await;
726    };
727    let (_client, request) = builder.builder.build_split();
728    let request = request.expect("request to be valid");
729
730    let request_info = RequestInfo {
731        method: request.method().as_str().to_string(),
732        url_path: {
733            let url = request.url();
734            match url.query() {
735                Some(query) => format!("{}?{}", url.path(), query),
736                None => url.path().to_string(),
737            }
738        },
739        body: request
740            .body()
741            .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
742    };
743
744    let response = builder.client.service.call(request).await?;
745
746    // Record the response
747    let status = response.status().as_u16();
748    let raw_headers: HashMap<String, String> = response
749        .headers()
750        .iter()
751        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
752        .collect();
753
754    // Get response body while preserving it for the caller
755    let response_bytes = response.bytes().await?;
756    let response_body = if response_bytes.is_empty() {
757        None
758    } else {
759        Some(String::from_utf8_lossy(&response_bytes).to_string())
760    };
761
762    let recording = RequestResponseInfo {
763        request: request_info,
764        response: ResponseInfo {
765            status,
766            // Redact sensitive headers before writing to disk
767            headers: redact_headers(&raw_headers),
768            body: response_body,
769        },
770    };
771
772    let counter = builder
773        .client
774        .recording
775        .as_ref()
776        .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
777        .unwrap_or(0);
778    let file_path = out_dir.join(format!("{counter:04}.json"));
779    if let Err(e) = std::fs::File::create(&file_path)
780        .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
781    {
782        tracing::warn!(
783            "Failed to write recording to {}: {}",
784            file_path.display(),
785            e
786        );
787    }
788
789    // Return a new response built from the recorded data, using the raw (unredacted) headers
790    let mut mock_response = http::Response::builder().status(status);
791    for (k, v) in &raw_headers {
792        mock_response = mock_response.header(k, v);
793    }
794    let mock_response = mock_response
795        .body(response_bytes)
796        .expect("valid status code and headers");
797
798    Ok(reqwest::Response::from(mock_response))
799}
800
801#[cfg(all(test, feature = "recording"))]
802mod tests {
803    use super::*;
804    use std::fs;
805    use tempfile::TempDir;
806
807    #[tokio::test]
808    async fn test_request_response_recording() {
809        // Create a temporary directory for recordings
810        let temp_dir = TempDir::new().unwrap();
811        let temp_path = temp_dir.path().to_path_buf();
812
813        // Set up a mock server
814        let mut server = mockito::Server::new_async().await;
815        let mock = server
816            .mock("GET", "/test")
817            .with_status(200)
818            .with_header("content-type", "application/json")
819            .with_body(r#"{"message": "Hello, World!"}"#)
820            .create_async()
821            .await;
822
823        // Create a cloud client with recording enabled
824        let mut client = CloudClient::new_unauthenticated();
825        client.set_recording_dir(temp_path.clone()).unwrap();
826
827        // Make a request
828        let url = format!("{}/test", server.url());
829        let response = client.get(&url).send().await.unwrap();
830
831        // Verify the response is correct
832        assert_eq!(response.status(), 200);
833        let body = response.text().await.unwrap();
834        assert_eq!(body, r#"{"message": "Hello, World!"}"#);
835
836        // Verify that a recording file was created
837        let recordings: Vec<_> = fs::read_dir(&temp_path)
838            .unwrap()
839            .filter_map(|entry| {
840                let entry = entry.ok()?;
841                let path = entry.path();
842                if path.extension()? == "json" {
843                    Some(path)
844                } else {
845                    None
846                }
847            })
848            .collect();
849
850        assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
851
852        // Read and verify the recording content
853        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
854        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
855
856        // Verify request information
857        assert_eq!(recording.request.method, "GET");
858        assert_eq!(recording.request.url_path, "/test");
859        assert_eq!(recording.request.body, None);
860
861        // Verify response information
862        assert_eq!(recording.response.status, 200);
863        assert_eq!(
864            recording.response.headers.get("content-type").unwrap(),
865            "application/json"
866        );
867        assert_eq!(
868            recording.response.body.as_ref().unwrap(),
869            r#"{"message": "Hello, World!"}"#
870        );
871
872        mock.assert_async().await;
873    }
874
875    #[tokio::test]
876    async fn test_recording_with_request_body() {
877        // Create a temporary directory for recordings
878        let temp_dir = TempDir::new().unwrap();
879        let temp_path = temp_dir.path().to_path_buf();
880
881        // Set up a mock server
882        let mut server = mockito::Server::new_async().await;
883        let mock = server
884            .mock("POST", "/create")
885            .with_status(201)
886            .with_header("location", "/resource/123")
887            .with_body(r#"{"id": 123, "status": "created"}"#)
888            .create_async()
889            .await;
890
891        // Create a cloud client with recording enabled
892        let mut client = CloudClient::new_unauthenticated();
893        client.set_recording_dir(temp_path.clone()).unwrap();
894
895        // Make a POST request with body
896        let url = format!("{}/create", server.url());
897        let response = client
898            .post(&url)
899            .json(&serde_json::json!({"name": "test resource"}))
900            .send()
901            .await
902            .unwrap();
903
904        // Verify the response
905        assert_eq!(response.status(), 201);
906
907        // Verify that a recording file was created
908        let recordings: Vec<_> = fs::read_dir(&temp_path)
909            .unwrap()
910            .filter_map(|entry| {
911                let entry = entry.ok()?;
912                let path = entry.path();
913                if path.extension()? == "json" {
914                    Some(path)
915                } else {
916                    None
917                }
918            })
919            .collect();
920
921        assert_eq!(recordings.len(), 1);
922
923        // Read and verify the recording content
924        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
925        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
926
927        // Verify request information
928        assert_eq!(recording.request.method, "POST");
929        assert_eq!(recording.request.url_path, "/create");
930        assert!(recording.request.body.is_some());
931        assert!(recording.request.body.unwrap().contains("test resource"));
932
933        // Verify response information
934        assert_eq!(recording.response.status, 201);
935        assert_eq!(
936            recording.response.headers.get("location").unwrap(),
937            "/resource/123"
938        );
939        assert!(recording.response.body.unwrap().contains("created"));
940
941        mock.assert_async().await;
942    }
943
944    #[tokio::test]
945    async fn test_counter_based_file_naming() {
946        // Create a temporary directory for recordings
947        let temp_dir = TempDir::new().unwrap();
948        let temp_path = temp_dir.path().to_path_buf();
949
950        // Set up a mock server
951        let mut server = mockito::Server::new_async().await;
952        let mock1 = server
953            .mock("GET", "/first")
954            .with_status(200)
955            .with_body("first response")
956            .create_async()
957            .await;
958        let mock2 = server
959            .mock("GET", "/second")
960            .with_status(200)
961            .with_body("second response")
962            .create_async()
963            .await;
964
965        // Create a cloud client with recording enabled
966        let mut client = CloudClient::new_unauthenticated();
967        client.set_recording_dir(temp_path.clone()).unwrap();
968
969        // Make multiple requests
970        let url1 = format!("{}/first", server.url());
971        let url2 = format!("{}/second", server.url());
972
973        let _response1 = client.get(&url1).send().await.unwrap();
974        let _response2 = client.get(&url2).send().await.unwrap();
975
976        // Verify that files are named with incrementing counter
977        let mut recordings: Vec<_> = fs::read_dir(&temp_path)
978            .unwrap()
979            .filter_map(|entry| {
980                let entry = entry.ok()?;
981                let path = entry.path();
982                if path.extension()? == "json" {
983                    Some(path)
984                } else {
985                    None
986                }
987            })
988            .collect();
989
990        recordings.sort();
991        assert_eq!(recordings.len(), 2);
992
993        // Check that files are named 000000.json and 000001.json
994        assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
995        assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
996
997        // Verify content matches the order of requests
998        let first_content = fs::read_to_string(&recordings[0]).unwrap();
999        let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
1000        assert_eq!(first_recording.request.url_path, "/first");
1001        assert_eq!(
1002            first_recording.response.body.as_ref().unwrap(),
1003            "first response"
1004        );
1005
1006        let second_content = fs::read_to_string(&recordings[1]).unwrap();
1007        let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
1008        assert_eq!(second_recording.request.url_path, "/second");
1009        assert_eq!(
1010            second_recording.response.body.as_ref().unwrap(),
1011            "second response"
1012        );
1013
1014        mock1.assert_async().await;
1015        mock2.assert_async().await;
1016    }
1017
1018    #[tokio::test]
1019    async fn test_query_parameter_recording() {
1020        // Create a temporary directory for recordings
1021        let temp_dir = TempDir::new().unwrap();
1022        let temp_path = temp_dir.path().to_path_buf();
1023
1024        // Start a mock server
1025        let mut server = mockito::Server::new_async().await;
1026
1027        // Create a mock that expects query parameters
1028        let mock = server
1029            .mock("GET", "/catalogs?max_results=10&page_token=abc123")
1030            .with_status(200)
1031            .with_header("content-type", "application/json")
1032            .with_body(r#"{"catalogs": []}"#)
1033            .create_async()
1034            .await;
1035
1036        // Create a client with recording enabled
1037        let mut client = CloudClient::new_unauthenticated();
1038        client.set_recording_dir(temp_path.clone()).unwrap();
1039
1040        // Make a request with query parameters
1041        let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
1042        let response = client.get(&url).send().await.unwrap();
1043
1044        assert!(response.status().is_success());
1045
1046        // Verify that the recording file was created
1047        let recordings: Vec<_> = fs::read_dir(&temp_path)
1048            .unwrap()
1049            .filter_map(|entry| {
1050                let entry = entry.ok()?;
1051                let path = entry.path();
1052                if path.extension()? == "json" {
1053                    Some(path)
1054                } else {
1055                    None
1056                }
1057            })
1058            .collect();
1059
1060        assert_eq!(recordings.len(), 1);
1061
1062        // Read and verify the recording content includes query parameters
1063        let recording_content = fs::read_to_string(&recordings[0]).unwrap();
1064        let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
1065
1066        // Verify request information includes query parameters
1067        assert_eq!(recording.request.method, "GET");
1068        assert_eq!(
1069            recording.request.url_path,
1070            "/catalogs?max_results=10&page_token=abc123"
1071        );
1072        assert_eq!(recording.request.body, None);
1073
1074        // Verify response information
1075        assert_eq!(recording.response.status, 200);
1076        assert_eq!(
1077            recording.response.body.as_ref().unwrap(),
1078            r#"{"catalogs": []}"#
1079        );
1080
1081        mock.assert_async().await;
1082    }
1083
1084    /// Verify that the bearer token injected by a signed request does not appear
1085    /// in the recording file. Request headers are currently not recorded, so this
1086    /// test confirms the token does not leak via any other path (e.g. echoed back
1087    /// in a response header or response body).
1088    #[tokio::test]
1089    async fn test_recording_does_not_contain_bearer_token_value() {
1090        let temp_dir = TempDir::new().unwrap();
1091        let temp_path = temp_dir.path().to_path_buf();
1092
1093        let mut server = mockito::Server::new_async().await;
1094        let mock = server
1095            .mock("GET", "/secret")
1096            .match_header("authorization", mockito::Matcher::Any)
1097            .with_status(200)
1098            // Server does NOT echo the token back — simulates a well-behaved API
1099            .with_body("ok")
1100            .create_async()
1101            .await;
1102
1103        let mut client = CloudClient::new_with_token("super-secret-token-12345");
1104        client.set_recording_dir(temp_path.clone()).unwrap();
1105
1106        let url = format!("{}/secret", server.url());
1107        client.get(&url).send().await.unwrap();
1108
1109        let recording_path = temp_path.join("0000.json");
1110        let content = fs::read_to_string(&recording_path).unwrap();
1111
1112        // The raw token must not appear in the file at all
1113        assert!(
1114            !content.contains("super-secret-token-12345"),
1115            "raw bearer token leaked into recording: {content}"
1116        );
1117
1118        mock.assert_async().await;
1119    }
1120
1121    /// Verify that sensitive headers returned by the server (e.g. a reflected
1122    /// Authorization or AWS security token) are redacted before being written
1123    /// to the recording file.
1124    #[tokio::test]
1125    async fn test_recording_redacts_sensitive_response_headers() {
1126        let temp_dir = TempDir::new().unwrap();
1127        let temp_path = temp_dir.path().to_path_buf();
1128
1129        let mut server = mockito::Server::new_async().await;
1130        // Simulate a server that echoes a sensitive header back in its response
1131        let mock = server
1132            .mock("GET", "/s3")
1133            .with_status(200)
1134            .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1135            .with_header("authorization", "Bearer should-be-redacted")
1136            .with_body("{}")
1137            .create_async()
1138            .await;
1139
1140        let mut client = CloudClient::new_unauthenticated();
1141        client.set_recording_dir(temp_path.clone()).unwrap();
1142
1143        let url = format!("{}/s3", server.url());
1144        client.get(&url).send().await.unwrap();
1145
1146        let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1147        assert!(
1148            !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1149            "x-amz-security-token leaked into recording: {content}"
1150        );
1151        assert!(
1152            !content.contains("should-be-redacted"),
1153            "Authorization value leaked into recording: {content}"
1154        );
1155        // Both headers should be replaced with the redaction sentinel
1156        assert_eq!(
1157            content.matches("<REDACTED>").count(),
1158            2,
1159            "expected 2 <REDACTED> entries in recording: {content}"
1160        );
1161
1162        mock.assert_async().await;
1163    }
1164
1165    /// Verify that a recording produced with redacted headers can still be parsed.
1166    #[tokio::test]
1167    async fn test_recording_remains_valid_json_after_redaction() {
1168        let temp_dir = TempDir::new().unwrap();
1169        let temp_path = temp_dir.path().to_path_buf();
1170
1171        let mut server = mockito::Server::new_async().await;
1172        let _mock = server
1173            .mock("GET", "/check")
1174            .with_status(200)
1175            .with_header("authorization", "Bearer should-be-redacted")
1176            .with_body(r#"{"ok":true}"#)
1177            .create_async()
1178            .await;
1179
1180        let mut client = CloudClient::new_unauthenticated();
1181        client.set_recording_dir(temp_path.clone()).unwrap();
1182
1183        let url = format!("{}/check", server.url());
1184        client.get(&url).send().await.unwrap();
1185
1186        let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1187        // Must parse cleanly as a RequestResponseInfo
1188        let parsed: RequestResponseInfo = serde_json::from_str(&content)
1189            .expect("recording file must be valid JSON even after redaction");
1190        assert_eq!(parsed.response.status, 200);
1191    }
1192}
1193
1194// These assert the production send path's retry + error-mapping contract.
1195// The `recording` feature replaces that path with a single-shot capture (no
1196// retry, no status->error mapping — see `send_record`), so they are scoped to
1197// the non-recording build where the behavior under test actually applies.
1198#[cfg(all(test, not(feature = "recording")))]
1199mod retry_integration_tests {
1200    use super::*;
1201    use std::time::Duration;
1202
1203    fn fast_retry(max_retries: usize) -> RetryConfig {
1204        RetryConfig {
1205            backoff: crate::backoff::BackoffConfig {
1206                init_backoff: Duration::from_millis(1),
1207                max_backoff: Duration::from_millis(5),
1208                base: 2.,
1209            },
1210            max_retries,
1211            retry_timeout: Duration::from_secs(30),
1212        }
1213    }
1214
1215    // A 5xx is retried by CloudClient::send: a single 503 followed by a 200
1216    // should surface the 200, proving the user-request path now honors
1217    // retry_config (previously it did a bare service.call with no retry).
1218    #[tokio::test]
1219    async fn send_retries_server_error_then_succeeds() {
1220        let mut server = mockito::Server::new_async().await;
1221        let fail = server
1222            .mock("GET", "/r")
1223            .with_status(503)
1224            .expect(1)
1225            .create_async()
1226            .await;
1227        let ok = server
1228            .mock("GET", "/r")
1229            .with_status(200)
1230            .with_body("ok")
1231            .expect(1)
1232            .create_async()
1233            .await;
1234
1235        let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1236        let resp = client
1237            .get(format!("{}/r", server.url()))
1238            .send()
1239            .await
1240            .unwrap();
1241
1242        assert_eq!(resp.status(), 200);
1243        assert_eq!(resp.text().await.unwrap(), "ok");
1244        fail.assert_async().await;
1245        ok.assert_async().await;
1246    }
1247
1248    // A 4xx is not retryable: it must surface immediately as an error without
1249    // consuming retries (a second mock would go unmatched).
1250    #[tokio::test]
1251    async fn send_does_not_retry_client_error() {
1252        let mut server = mockito::Server::new_async().await;
1253        let mock = server
1254            .mock("GET", "/r")
1255            .with_status(404)
1256            .expect(1)
1257            .create_async()
1258            .await;
1259
1260        let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1261        let err = client
1262            .get(format!("{}/r", server.url()))
1263            .send()
1264            .await
1265            .unwrap_err();
1266
1267        assert!(matches!(err, Error::NotFound { .. }), "got {err:?}");
1268        mock.assert_async().await;
1269    }
1270
1271    // send_raw preserves the classified status + body of a non-success
1272    // response instead of remapping it into an opaque Error. A 404 and a 409
1273    // must both surface as Err(RetryError) whose status()/body() are intact,
1274    // and a 5xx must still be retried (503 then 200 -> Ok(200)).
1275    #[tokio::test]
1276    async fn send_raw_preserves_status_and_body() {
1277        let mut server = mockito::Server::new_async().await;
1278
1279        // 404 with a body envelope: status + body must survive.
1280        let not_found = server
1281            .mock("GET", "/nf")
1282            .with_status(404)
1283            .with_body(r#"{"error":{"type":"NoSuchTableException"}}"#)
1284            .expect(1)
1285            .create_async()
1286            .await;
1287        // 409 conflict: same — non-retryable, body preserved.
1288        let conflict = server
1289            .mock("POST", "/cf")
1290            .with_status(409)
1291            .with_body(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1292            .expect(1)
1293            .create_async()
1294            .await;
1295        // 5xx is still retried under send_raw: one 503 then a 200.
1296        let fail = server
1297            .mock("GET", "/rt")
1298            .with_status(503)
1299            .expect(1)
1300            .create_async()
1301            .await;
1302        let ok = server
1303            .mock("GET", "/rt")
1304            .with_status(200)
1305            .with_body("ok")
1306            .expect(1)
1307            .create_async()
1308            .await;
1309
1310        let client = CloudClient::new_unauthenticated().with_retry_config(fast_retry(3));
1311
1312        let err = client
1313            .get(format!("{}/nf", server.url()))
1314            .send_raw()
1315            .await
1316            .unwrap_err();
1317        match err {
1318            SendRawError::Retry(e) => {
1319                assert_eq!(e.status(), Some(reqwest::StatusCode::NOT_FOUND));
1320                assert_eq!(
1321                    e.body(),
1322                    Some(r#"{"error":{"type":"NoSuchTableException"}}"#)
1323                );
1324            }
1325            other => panic!("expected Retry error, got {other:?}"),
1326        }
1327
1328        let err = client
1329            .post(format!("{}/cf", server.url()))
1330            .send_raw()
1331            .await
1332            .unwrap_err();
1333        match err {
1334            SendRawError::Retry(e) => {
1335                assert_eq!(e.status(), Some(reqwest::StatusCode::CONFLICT));
1336                assert_eq!(
1337                    e.body(),
1338                    Some(r#"{"error":{"type":"CommitVersionConflictException"}}"#)
1339                );
1340            }
1341            other => panic!("expected Retry error, got {other:?}"),
1342        }
1343
1344        let resp = client
1345            .get(format!("{}/rt", server.url()))
1346            .send_raw()
1347            .await
1348            .unwrap();
1349        assert_eq!(resp.status(), 200);
1350        assert_eq!(resp.text().await.unwrap(), "ok");
1351
1352        not_found.assert_async().await;
1353        conflict.assert_async().await;
1354        fail.assert_async().await;
1355        ok.assert_async().await;
1356    }
1357
1358    // sign_and_send takes a pre-built reqwest::Request, applies the signer
1359    // (here a bearer token), and retries transient failures just like send.
1360    #[tokio::test]
1361    async fn sign_and_send_signs_and_retries() {
1362        let mut server = mockito::Server::new_async().await;
1363        let fail = server
1364            .mock("POST", "/rpc")
1365            .match_header("authorization", "Bearer tok")
1366            .with_status(503)
1367            .expect(1)
1368            .create_async()
1369            .await;
1370        let ok = server
1371            .mock("POST", "/rpc")
1372            .match_header("authorization", "Bearer tok")
1373            .with_status(200)
1374            .with_body("pong")
1375            .expect(1)
1376            .create_async()
1377            .await;
1378
1379        let client = CloudClient::new_with_token("tok").with_retry_config(fast_retry(3));
1380        let request = reqwest::Client::new()
1381            .post(format!("{}/rpc", server.url()))
1382            .body("ping")
1383            .build()
1384            .unwrap();
1385        let resp = client.sign_and_send(request).await.unwrap();
1386
1387        assert_eq!(resp.status(), 200);
1388        assert_eq!(resp.text().await.unwrap(), "pong");
1389        fail.assert_async().await;
1390        ok.assert_async().await;
1391    }
1392}