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