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