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