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