polykit_core/remote_cache/
http.rs

1//! HTTP backend for remote cache.
2
3use std::time::Duration;
4
5use async_trait::async_trait;
6use reqwest::Client;
7use tokio::time::sleep;
8
9use crate::error::{Error, Result};
10
11use super::artifact::Artifact;
12use super::backend::RemoteCacheBackend;
13use super::cache_key::CacheKey;
14use super::config::RemoteCacheConfig;
15
16/// HTTP backend for remote cache.
17///
18/// Supports streaming upload/download, authentication, and retry logic.
19pub struct HttpBackend {
20    client: Client,
21    base_url: String,
22    token: Option<String>,
23    max_retries: u32,
24    retry_delay: Duration,
25}
26
27impl HttpBackend {
28    /// Creates a new HTTP backend.
29    ///
30    /// # Arguments
31    ///
32    /// * `config` - Remote cache configuration
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if the HTTP client cannot be created.
37    pub fn new(config: &RemoteCacheConfig) -> Result<Self> {
38        let client = Client::builder()
39            .timeout(Duration::from_secs(30))
40            .build()
41            .map_err(|e| Error::Adapter {
42                package: "http-backend".to_string(),
43                message: format!("Failed to create HTTP client: {}", e),
44            })?;
45
46        Ok(Self {
47            client,
48            base_url: config.url.trim_end_matches('/').to_string(),
49            token: config.token.clone(),
50            max_retries: 3,
51            retry_delay: Duration::from_millis(100),
52        })
53    }
54
55    /// Gets the URL for an artifact.
56    fn artifact_url(&self, key: &CacheKey) -> String {
57        let key_str = key.as_string();
58        format!("{}/v1/artifacts/{}", self.base_url, key_str)
59    }
60
61
62    /// Retries an operation with exponential backoff.
63    async fn retry<F, Fut, T>(&self, mut f: F) -> Result<T>
64    where
65        F: FnMut() -> Fut,
66        Fut: std::future::Future<Output = Result<T>> + Send,
67    {
68        let mut delay = self.retry_delay;
69        let mut last_error = None;
70
71        for attempt in 0..=self.max_retries {
72            match f().await {
73                Ok(result) => return Ok(result),
74                Err(e) => {
75                    last_error = Some(e);
76                    if attempt < self.max_retries {
77                        sleep(delay).await;
78                        delay *= 2; // Exponential backoff
79                    }
80                }
81            }
82        }
83
84        Err(last_error.unwrap_or_else(|| Error::Adapter {
85            package: "http-backend".to_string(),
86            message: "All retry attempts failed".to_string(),
87        }))
88    }
89}
90
91#[async_trait]
92impl RemoteCacheBackend for HttpBackend {
93    async fn upload_artifact(&self, key: &CacheKey, artifact: &Artifact) -> Result<()> {
94        let url = self.artifact_url(key);
95        let compressed_data = artifact.compressed_data();
96
97        let client = self.client.clone();
98        let token = self.token.clone();
99        self.retry(move || {
100            let url = url.clone();
101            let data = compressed_data.to_vec();
102            let client = client.clone();
103            let token = token.clone();
104            async move {
105                let mut builder = client.put(&url).body(data);
106                if let Some(ref token) = token {
107                    builder = builder.bearer_auth(token);
108                }
109
110                let response = builder.send().await.map_err(|e| Error::Adapter {
111                    package: "http-backend".to_string(),
112                    message: format!("Upload request failed: {}", e),
113                })?;
114
115                if response.status().is_success() {
116                    Ok(())
117                } else {
118                    let status = response.status();
119                    let text = response.text().await.unwrap_or_default();
120                    Err(Error::Adapter {
121                        package: "http-backend".to_string(),
122                        message: format!("Upload failed with status {}: {}", status, text),
123                    })
124                }
125            }
126        })
127        .await
128    }
129
130    async fn fetch_artifact(&self, key: &CacheKey) -> Result<Option<Artifact>> {
131        let url = self.artifact_url(key);
132
133        let client = self.client.clone();
134        let token = self.token.clone();
135        self.retry(move || {
136            let url = url.clone();
137            let client = client.clone();
138            let token = token.clone();
139            async move {
140                let mut builder = client.get(&url);
141                if let Some(ref token) = token {
142                    builder = builder.bearer_auth(token);
143                }
144
145                let response = builder.send().await.map_err(|e| Error::Adapter {
146                    package: "http-backend".to_string(),
147                    message: format!("Fetch request failed: {}", e),
148                })?;
149
150                match response.status() {
151                    status if status.is_success() => {
152                        let data = response.bytes().await.map_err(|e| Error::Adapter {
153                            package: "http-backend".to_string(),
154                            message: format!("Failed to read response body: {}", e),
155                        })?;
156                        let artifact = Artifact::from_compressed(data.to_vec())?;
157                        Ok(Some(artifact))
158                    }
159                    status if status == reqwest::StatusCode::NOT_FOUND => Ok(None),
160                    status => {
161                        let text = response.text().await.unwrap_or_default();
162                        Err(Error::Adapter {
163                            package: "http-backend".to_string(),
164                            message: format!("Fetch failed with status {}: {}", status, text),
165                        })
166                    }
167                }
168            }
169        })
170        .await
171    }
172
173    async fn has_artifact(&self, key: &CacheKey) -> Result<bool> {
174        let url = self.artifact_url(key);
175
176        let client = self.client.clone();
177        let token = self.token.clone();
178        self.retry(move || {
179            let url = url.clone();
180            let client = client.clone();
181            let token = token.clone();
182            async move {
183                let mut builder = client.head(&url);
184                if let Some(ref token) = token {
185                    builder = builder.bearer_auth(token);
186                }
187
188                let response = builder.send().await.map_err(|e| Error::Adapter {
189                    package: "http-backend".to_string(),
190                    message: format!("Exists check failed: {}", e),
191                })?;
192
193                match response.status() {
194                    status if status.is_success() => Ok(true),
195                    status if status == reqwest::StatusCode::NOT_FOUND => Ok(false),
196                    status => {
197                        let text = response.text().await.unwrap_or_default();
198                        Err(Error::Adapter {
199                            package: "http-backend".to_string(),
200                            message: format!("Exists check failed with status {}: {}", status, text),
201                        })
202                    }
203                }
204            }
205        })
206        .await
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_artifact_url() {
216        let config = RemoteCacheConfig::new("https://cache.example.com");
217        let backend = HttpBackend::new(&config).unwrap();
218
219        let key = CacheKey::builder()
220            .package_id("test")
221            .task_name("build")
222            .command("echo")
223            .dependency_graph_hash("abc")
224            .toolchain_version("node-v20")
225            .build()
226            .unwrap();
227
228        let url = backend.artifact_url(&key);
229        assert!(url.starts_with("https://cache.example.com/v1/artifacts/"));
230    }
231}