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