Skip to main content

mbx_cache_core/
lib.rs

1//! Protocol and storage primitives for mbx build caches.
2//!
3//! This crate contains the types shared by cache clients, the task-scoped
4//! cache agent, and remote cache implementations. Protocol records are
5//! serialized with [`canonical_json`] before hashing; changing their shape is
6//! therefore a wire-format change, not merely an implementation detail.
7//!
8//! Most consumers start with [`CacheDigest`] and the local stores
9//! [`LocalCas`] and [`LocalActionCache`]. Remote clients use
10//! [`RemoteCacheClient`], while mbx's compiler shim communicates with a
11//! [`CacheAgent`] using [`AgentRequest`] and [`AgentResponse`].
12//!
13//! ```
14//! use mbx_cache_core::{CacheDigest, canonical_json};
15//! use serde::Serialize;
16//!
17//! #[derive(Serialize)]
18//! struct Key<'a> {
19//!     compiler: &'a str,
20//!     source: CacheDigest,
21//! }
22//!
23//! let source = CacheDigest::blake3(b"fn main() {}\n");
24//! let bytes = canonical_json(&Key { compiler: "rustc", source })?;
25//! let action = CacheDigest::blake3(&bytes);
26//! assert_eq!(action.algorithm, "blake3");
27//! # Ok::<(), eyre::Report>(())
28//! ```
29#![deny(missing_docs)]
30
31use eyre::{Result, bail, eyre};
32use log::warn;
33use reqwest::header::HeaderValue;
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37use url::Url;
38
39mod agent;
40mod client;
41mod local;
42mod remote_http;
43mod remote_s3;
44mod sigv4;
45mod uploads;
46
47pub use agent::{
48    AGENT_PROTOCOL_VERSION, AgentEvent, AgentEventObserver, AgentRemoteCache, AgentRequest,
49    AgentResponse, AgentStats, CacheAgent, CompilerStats, RestoreStats, is_task_identity,
50    task_manifest_actions,
51};
52pub use client::BlockingAgentClient;
53pub use local::{LocalActionCache, LocalCas};
54pub use mbx_cache_protocol::{
55    ACTION_RESULT_BATCH_MEDIA_TYPE, ACTION_RESULT_MEDIA_TYPE, ActionPrediction,
56    ActionResult as RemoteActionResult, BLOB_MEDIA_TYPE, BLOB_PACK_BLOBS_HEADER,
57    BLOB_PACK_BYTES_HEADER, BLOB_PACK_HEADER_BYTES, BLOB_PACK_MAGIC, BLOB_PACK_MEDIA_TYPE,
58    BLOB_PACK_RECEIPT_MEDIA_TYPE, CLIENT_METADATA_MEDIA_TYPE, Capabilities, CapabilityFeatures,
59    CapabilityLimits, CapabilityProtocol, CcMetadata, DIGEST_LIST_MEDIA_TYPE, DIRECTORY_MEDIA_TYPE,
60    Digest as CacheDigest, DigestAlgorithm, Directory as CacheDirectory,
61    DirectoryNode as CacheDirectoryNode, FileNode as CacheFileNode, NAMESPACE_HEADER,
62    PROTOCOL_HEADER, PROTOCOL_VERSION, RustcMetadata, SymlinkNode as CacheSymlinkNode,
63    TASK_ACTION_MANIFEST_MEDIA_TYPE, TaskActionManifest,
64};
65use remote_http::HttpRemoteCache;
66#[cfg(feature = "fuzzing")]
67#[doc(hidden)]
68pub use remote_http::fuzz_decode_blob_pack;
69pub(crate) use remote_http::{BlobPackLimits, blob_pack_chunk};
70use remote_s3::S3RemoteCache;
71pub use remote_s3::{S3ConditionalWrites, S3RemoteCacheConfig};
72pub use sigv4::S3Credentials;
73/// Cap the JSON bodies a remote cache can hand back. Blob downloads are bounded
74/// by the size their digest promises, but action results and manifests carry no
75/// such claim, so without an explicit ceiling a hostile or broken server can
76/// stream until this process runs out of memory -- for manifests, long before
77/// `validate_task_manifest` ever sees the payload. The bound matches the agent's
78/// own request ceiling so both ends of the protocol refuse the same magnitude.
79const MAX_REMOTE_JSON_BYTES: u64 = 16 * 1024 * 1024;
80/// Ceiling on the opaque part of an entity tag this client will carry back.
81///
82/// A tag is only ever echoed into `If-Match`, so its length is bounded to keep
83/// a server from choosing how large a request header this client sends.
84const MAX_ETAG_BYTES: usize = 256;
85// Match the server's default maximum while retaining a client-side ceiling
86// when the remote advertises or names something larger.
87const MAX_REMOTE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
88const MAX_STAGED_BLOB_PACK_BYTES: u64 = 256 * 1024 * 1024;
89const MAX_STAGED_BLOB_PACK_ITEMS: usize = 2 * 1024;
90const BLOB_PACK_TIMEOUT_BYTES_PER_UNIT: u64 = MAX_STAGED_BLOB_PACK_BYTES / 4;
91const BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT: usize = MAX_STAGED_BLOB_PACK_ITEMS / 4;
92/// Bytes read from one pack member before the next chunk is yielded.
93const PACK_STREAM_CHUNK_BYTES: usize = 64 * 1024;
94/// Actions looked up in one batched request.
95///
96/// A prefetch wants its first results back while the rest are still being
97/// answered, so a batch stays small enough to keep the download pipeline fed
98/// rather than as large as a server would accept.
99const MAX_ACTION_BATCH_ITEMS: usize = 256;
100/// Ceiling on one batched action-result response, scaled by what was asked for.
101const MAX_ACTION_RESULT_BYTES: u64 = 64 * 1024;
102
103/// Serialize a protocol object using the JSON Canonicalization Scheme.
104///
105/// Action digests are computed from these bytes, so callers must not use
106/// serde's struct field order as part of the wire contract.
107pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
108    Ok(mbx_cache_protocol::canonical_json(value)?)
109}
110
111#[derive(
112    Debug,
113    Clone,
114    Copy,
115    Serialize,
116    Deserialize,
117    Default,
118    strum::EnumString,
119    strum::Display,
120    PartialEq,
121    Eq,
122)]
123#[serde(rename_all = "kebab-case")]
124#[strum(serialize_all = "kebab-case")]
125/// Operations permitted against a configured remote cache.
126pub enum RemoteCacheMode {
127    /// Permit reads from and writes to the remote cache.
128    #[default]
129    ReadWrite,
130    /// Permit reads but never publish new objects.
131    ReadOnly,
132    /// Publish objects but never satisfy lookups from the remote cache.
133    WriteOnly,
134}
135
136impl RemoteCacheMode {
137    /// Whether this mode permits remote cache reads.
138    pub fn reads(self) -> bool {
139        matches!(self, Self::ReadWrite | Self::ReadOnly)
140    }
141
142    /// Whether this mode permits remote cache writes.
143    pub fn writes(self) -> bool {
144        matches!(self, Self::ReadWrite | Self::WriteOnly)
145    }
146}
147
148/// Connection, authentication, and retry settings for [`RemoteCacheClient`].
149pub struct RemoteCacheConfig {
150    /// Base URL of the remote cache service.
151    pub base_url: Url,
152    /// Server-side namespace used to isolate cache objects.
153    pub namespace: String,
154    /// Static bearer token, if configured directly.
155    pub token: Option<String>,
156    /// File containing a bearer token that may be refreshed externally.
157    pub token_file: Option<PathBuf>,
158    /// Audience used when obtaining an OIDC token from the CI environment.
159    pub oidc_audience: Option<String>,
160    /// Maximum time allowed to establish a connection.
161    pub connect_timeout: Duration,
162    /// Maximum time without response progress for ordinary requests.
163    pub read_timeout: Duration,
164    /// Deadline for one blob download, spanning every retry attempt and the
165    /// backoff between them rather than bounding a single attempt.
166    ///
167    /// A single stalled attempt is already bounded by `connect_timeout` and
168    /// `read_timeout`, so this budget exists to cap the total wall-clock one
169    /// logical download may spend: exhausting it fails the download even when
170    /// retries remain. Size it for the largest artifact worth waiting on, not
171    /// for one attempt at it.
172    pub download_timeout: Duration,
173    /// Number of attempts after the initial request for retryable failures.
174    pub retries: i64,
175}
176
177/// Backing data for a blob upload.
178pub enum BlobSource {
179    /// Bytes held in memory.
180    Bytes(Vec<u8>),
181    /// A temporary file whose lifetime is owned by the upload.
182    File(tempfile::NamedTempFile),
183    /// A persistent file at the given path.
184    Path(PathBuf),
185}
186
187/// A digest paired with the data to upload under that digest.
188pub struct BlobUpload {
189    /// Expected digest and length of the source data.
190    pub digest: CacheDigest,
191    /// Data source read by [`RemoteCacheClient::put_blob`].
192    pub source: BlobSource,
193}
194
195/// Task action-manifest bytes returned with their concurrency token.
196pub struct RemoteActionManifest {
197    /// Raw canonical manifest JSON.
198    pub bytes: Vec<u8>,
199    /// Entity tag used for conditional manifest replacement.
200    pub etag: String,
201}
202
203/// A verified set of remote CAS objects downloaded through blob-pack streams.
204pub struct RemoteBlobPack {
205    _directory: tempfile::TempDir,
206    /// Verified blobs paired with paths in this pack's temporary directory.
207    pub blobs: Vec<(CacheDigest, PathBuf)>,
208    /// Number of HTTP pack requests needed to retrieve the requested set.
209    pub requests: u64,
210    /// Unique digests requested from the remote service.
211    pub requested: Vec<CacheDigest>,
212    /// Number of verified blob frames received.
213    pub blob_count: u64,
214    /// Total unframed blob payload bytes received.
215    pub payload_bytes: u64,
216    /// Total bytes received including framing.
217    pub framed_bytes: u64,
218}
219
220/// What a server did with the blobs in an uploaded pack.
221#[derive(Debug, Clone, Copy, Deserialize)]
222pub struct BlobPackReceipt {
223    /// Blobs this request added to the remote cache.
224    #[serde(default)]
225    pub created: u64,
226    /// Blobs the remote cache already held.
227    #[serde(default)]
228    pub existing: u64,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232/// Result of a conditional task-manifest write.
233pub enum ManifestPutOutcome {
234    /// The manifest was stored.
235    Stored,
236    /// The supplied entity-tag precondition did not match.
237    PreconditionFailed,
238}
239
240/// A client for a remote mbx cache.
241///
242/// The client validates digests, response sizes, media types, and redirects at
243/// the protocol boundary. It is safe to share between asynchronous tasks.
244///
245/// Which service is on the other end is not part of this type's contract. A
246/// cache server speaks the full protocol; an object store answers the same
247/// lookups without the extensions built on top of it, and reports that by
248/// declining them rather than by failing.
249pub struct RemoteCacheClient {
250    backend: Backend,
251}
252
253/// The service a [`RemoteCacheClient`] talks to.
254///
255/// Backends are a closed set, so they dispatch through an enum rather than a
256/// trait: an `async fn` in a trait is not object-safe, and boxing every call to
257/// work around that would buy nothing here.
258enum Backend {
259    Http(HttpRemoteCache),
260    S3(S3RemoteCache),
261}
262
263impl RemoteCacheClient {
264    /// Construct a client and validate its URL and authentication settings.
265    pub fn new(config: RemoteCacheConfig) -> Result<Self> {
266        Ok(Self {
267            backend: Backend::Http(HttpRemoteCache::new(config)?),
268        })
269    }
270
271    /// Construct a client backed directly by an S3-compatible object store.
272    ///
273    /// The store answers the same lookups a cache server does, without the
274    /// extensions built on top of the protocol. See [`S3RemoteCacheConfig`].
275    pub fn new_s3(config: S3RemoteCacheConfig) -> Result<Self> {
276        Ok(Self {
277            backend: Backend::S3(S3RemoteCache::new(config)?),
278        })
279    }
280
281    /// Connect to the service, authenticate, and negotiate protocol capabilities.
282    ///
283    /// This performs no cache reads or writes. It is intended for diagnostics
284    /// that need to distinguish a valid client configuration from a reachable,
285    /// compatible remote cache.
286    pub async fn check_connection(&self) -> Result<()> {
287        match &self.backend {
288            Backend::Http(client) => client.check_connection().await,
289            Backend::S3(store) => store.check_connection().await,
290        }
291    }
292
293    /// Download verified CAS objects using the server's negotiated blob-pack extension.
294    ///
295    /// `None` means the service does not support blob packs. Objects omitted by a
296    /// supported server are absent from `blobs`, so callers can retry them through
297    /// the ordinary single-blob endpoint.
298    pub async fn get_blob_pack(
299        &self,
300        digests: &[CacheDigest],
301        staging_dir: &Path,
302    ) -> Result<Option<RemoteBlobPack>> {
303        match &self.backend {
304            Backend::Http(client) => client.get_blob_pack(digests, staging_dir).await,
305            Backend::S3(store) => store.get_blob_pack(digests, staging_dir).await,
306        }
307    }
308
309    pub(crate) async fn get_blob_pack_with_limit(
310        &self,
311        digests: &[CacheDigest],
312        staging_dir: &Path,
313        max_bytes: u64,
314    ) -> Result<Option<RemoteBlobPack>> {
315        match &self.backend {
316            Backend::Http(client) => {
317                client
318                    .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
319                    .await
320            }
321            Backend::S3(store) => {
322                store
323                    .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
324                    .await
325            }
326        }
327    }
328
329    /// Fetch and validate an action-result record, returning `None` on a miss.
330    pub async fn get_action_result(
331        &self,
332        action: &CacheDigest,
333    ) -> Result<Option<RemoteActionResult>> {
334        match &self.backend {
335            Backend::Http(client) => client.get_action_result(action).await,
336            Backend::S3(store) => store.get_action_result(action).await,
337        }
338    }
339
340    /// How many actions one batched lookup may ask about.
341    ///
342    /// `None` means the service does not answer batched lookups.
343    pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
344        match &self.backend {
345            Backend::Http(client) => client.action_batch_limit().await,
346            Backend::S3(store) => store.action_batch_limit().await,
347        }
348    }
349
350    /// Look up several action results in one request.
351    ///
352    /// `None` means the service does not answer batched lookups, leaving the
353    /// caller to ask for each action individually. The response carries only the
354    /// results the service holds, in no particular order, so each record is bound
355    /// to its request by the action digest it names rather than by position.
356    pub async fn get_action_results(
357        &self,
358        actions: &[CacheDigest],
359    ) -> Result<Option<Vec<RemoteActionResult>>> {
360        match &self.backend {
361            Backend::Http(client) => client.get_action_results(actions).await,
362            Backend::S3(store) => store.get_action_results(actions).await,
363        }
364    }
365
366    /// Canonically serialize and store an action-result record.
367    pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
368        match &self.backend {
369            Backend::Http(client) => client.put_action_result(result).await,
370            Backend::S3(store) => store.put_action_result(result).await,
371        }
372    }
373
374    /// Fetch a task action manifest and the entity tag needed to update it.
375    pub async fn get_action_manifest(
376        &self,
377        key: &CacheDigest,
378    ) -> Result<Option<RemoteActionManifest>> {
379        match &self.backend {
380            Backend::Http(client) => client.get_action_manifest(key).await,
381            Backend::S3(store) => store.get_action_manifest(key).await,
382        }
383    }
384
385    /// Store a task action manifest, optionally requiring an entity-tag match.
386    pub async fn put_action_manifest(
387        &self,
388        key: &CacheDigest,
389        bytes: &[u8],
390        expected_etag: Option<&str>,
391    ) -> Result<ManifestPutOutcome> {
392        match &self.backend {
393            Backend::Http(client) => client.put_action_manifest(key, bytes, expected_etag).await,
394            Backend::S3(store) => store.put_action_manifest(key, bytes, expected_etag).await,
395        }
396    }
397
398    /// Download a small blob into memory and verify its digest.
399    pub async fn get_blob(
400        &self,
401        digest: &CacheDigest,
402        media_type: &'static str,
403    ) -> Result<Vec<u8>> {
404        match &self.backend {
405            Backend::Http(client) => client.get_blob(digest, media_type).await,
406            Backend::S3(store) => store.get_blob(digest, media_type).await,
407        }
408    }
409
410    /// Download a blob to a temporary file and verify its digest.
411    pub async fn get_blob_file(
412        &self,
413        digest: &CacheDigest,
414        staging_dir: &Path,
415    ) -> Result<tempfile::NamedTempFile> {
416        match &self.backend {
417            Backend::Http(client) => client.get_blob_file(digest, staging_dir).await,
418            Backend::S3(store) => store.get_blob_file(digest, staging_dir).await,
419        }
420    }
421
422    /// How many blobs, and how many payload bytes, one uploaded pack may carry.
423    ///
424    /// `None` means the service does not accept packed uploads.
425    pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<BlobPackLimits>> {
426        match &self.backend {
427            Backend::Http(client) => client.blob_pack_upload_limits().await,
428            Backend::S3(store) => store.blob_pack_upload_limits().await,
429        }
430    }
431
432    /// Upload several content-addressed blobs in one framed request.
433    ///
434    /// `None` means the service does not accept packed uploads, leaving the
435    /// caller to send each blob on its own. A rejected pack is reported as an
436    /// error and publishes nothing the caller may rely on: a server verifies
437    /// each frame as it arrives, so an accepted prefix may exist, but every blob
438    /// is content-addressed and storing one twice is not an error.
439    pub async fn put_blob_pack(&self, uploads: &[BlobUpload]) -> Result<Option<BlobPackReceipt>> {
440        match &self.backend {
441            Backend::Http(client) => client.put_blob_pack(uploads).await,
442            Backend::S3(store) => store.put_blob_pack(uploads).await,
443        }
444    }
445
446    /// Verify and upload a content-addressed blob.
447    pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
448        match &self.backend {
449            Backend::Http(client) => client.put_blob(upload).await,
450            Backend::S3(store) => store.put_blob(upload).await,
451        }
452    }
453}
454
455/// Buffer a JSON response body, refusing to grow past [`MAX_REMOTE_JSON_BYTES`].
456///
457/// A declared `Content-Length` is rejected up front so an oversized body costs
458/// nothing to refuse; the streaming check then covers servers that understate or
459/// omit it.
460async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
461    read_json_within(response, what, MAX_REMOTE_JSON_BYTES).await
462}
463
464async fn read_json_within(response: reqwest::Response, what: &str, limit: u64) -> Result<Vec<u8>> {
465    if let Some(length) = response.content_length()
466        && length > limit
467    {
468        bail!("remote cache {what} declared {length} bytes, over the {limit} byte limit");
469    }
470    let mut response = response;
471    let mut bytes = Vec::new();
472    while let Some(chunk) = response.chunk().await? {
473        if bytes.len() as u64 + chunk.len() as u64 > limit {
474            bail!("remote cache {what} exceeded the {limit} byte limit");
475        }
476        bytes.extend_from_slice(&chunk);
477    }
478    Ok(bytes)
479}
480/// Read the entity tag that a later conditional update has to send back.
481///
482/// The tag is carried through opaquely, and nothing may infer content from it.
483/// RFC 9110 section 8.8.3.3 requires an intermediary that re-encodes a response
484/// to vary the strong tag along with it, and proxies do: Caddy appends the
485/// content coding, so a manifest served through compression arrives tagged
486/// `"<hash>-zstd"`. What the body actually is gets established by the caller
487/// comparing it against canonical JSON, not by the shape of this header.
488fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
489    let value = value
490        .and_then(|value| value.to_str().ok())
491        .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
492    if value.starts_with("W/") {
493        // If-Match rejects a weak validator, so an update could not tell that it
494        // was overwriting a manifest someone else had published.
495        bail!("remote action manifest response has a weak ETag");
496    }
497    let etag = value
498        .strip_prefix('"')
499        .and_then(|value| value.strip_suffix('"'))
500        .filter(|value| is_entity_tag(value))
501        .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
502    Ok(etag.to_owned())
503}
504
505fn quoted_etag(etag: &str) -> Result<HeaderValue> {
506    if !is_entity_tag(etag) {
507        bail!("invalid remote action manifest ETag");
508    }
509    Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
510}
511
512/// Whether this is the opaque body of a strong entity tag (RFC 9110 `etagc`).
513///
514/// `HeaderValue::to_str` has already ruled out anything but visible ASCII, so
515/// the double quote that would end the tag early is all that is left to reject.
516fn is_entity_tag(value: &str) -> bool {
517    !value.is_empty()
518        && value.len() <= MAX_ETAG_BYTES
519        && value.bytes().all(|byte| matches!(byte, 0x21 | 0x23..=0x7e))
520}
521fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
522    [200u64, 1_000, 4_000, 15_000]
523        .into_iter()
524        .chain(std::iter::repeat(15_000))
525        .map(Duration::from_millis)
526        .map(|duration| {
527            let factor = 0.5 + rand::random::<f64>() * 0.5;
528            Duration::from_secs_f64(duration.as_secs_f64() * factor)
529        })
530        .take(retries.max(0) as usize)
531}
532
533/// hyper-util exposes DNS failures in the error chain as a `dns error` source,
534/// but reqwest intentionally erases the concrete connector type. Match that
535/// stable connector error label rather than platform-specific resolver text.
536fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
537    let mut current = Some(error);
538    while let Some(source) = current {
539        if source.to_string() == "dns error" {
540            return true;
541        }
542        current = source.source();
543    }
544    false
545}
546
547/// A failure a backend has identified as worth retrying.
548///
549/// [`is_transient`] recognizes the statuses that are transient for any HTTP
550/// service. A backend that knows one of its own -- S3 answers `409` while a
551/// concurrent conditional write to the same key is in flight, and asks that it
552/// be retried -- attaches this instead of teaching that function about it.
553#[derive(Debug)]
554pub(crate) struct TransientRequest(pub(crate) &'static str);
555
556impl std::fmt::Display for TransientRequest {
557    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        formatter.write_str(self.0)
559    }
560}
561
562impl std::error::Error for TransientRequest {}
563
564fn is_transient(error: &eyre::Report) -> bool {
565    // An unavailable hostname is a deterministic configuration error. reqwest
566    // categorizes it as a connect error, but retrying only delays the diagnosis.
567    if is_dns_error(error.as_ref()) {
568        return false;
569    }
570    error.chain().any(|source| {
571        if source.downcast_ref::<TransientRequest>().is_some() {
572            return true;
573        }
574        let Some(error) = source.downcast_ref::<reqwest::Error>() else {
575            return false;
576        };
577        if error.is_timeout() || error.is_connect() || error.is_body() {
578            return true;
579        }
580        error.status().is_some_and(|status| {
581            let status = status.as_u16();
582            status == 408 || status == 429 || (500..600).contains(&status)
583        })
584    })
585}
586
587async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
588where
589    F: FnMut() -> Fut,
590    Fut: std::future::Future<Output = Result<T>>,
591{
592    let mut delays = retry_delays(retries);
593    let mut attempt = 1;
594    loop {
595        let started_at = Instant::now();
596        match operation().await {
597            Ok(value) => return Ok(value),
598            Err(error) if is_transient(&error) => {
599                let Some(delay) = delays.next() else {
600                    return Err(error);
601                };
602                warn!(
603                    "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
604                    started_at.elapsed()
605                );
606                tokio::time::sleep(delay).await;
607                attempt += 1;
608            }
609            Err(error) => return Err(error),
610        }
611    }
612}