polykit_core/remote_cache/
http.rs1use 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
16pub 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 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 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 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; }
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}