Skip to main content

lean_ctx/core/providers/
hardened_http.rs

1use std::time::{Duration, Instant};
2
3const DEFAULT_RESOLVE_TIMEOUT: Duration = Duration::from_secs(5);
4const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
5const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
6const DEGRADED_MULTIPLIER: u32 = 2;
7pub(crate) const MAX_RESPONSE_BODY_BYTES: u64 = 10 * 1024 * 1024;
8
9#[cfg(any(test, feature = "test-support"))]
10#[allow(clippy::unwrap_used)]
11pub mod redirect_support;
12#[cfg(test)]
13pub(crate) mod redirect_tests;
14
15#[derive(Debug, Clone)]
16pub enum HttpOutcome {
17    Success {
18        status: u16,
19        body: String,
20        elapsed_ms: u64,
21    },
22    Timeout {
23        elapsed_ms: u64,
24        phase: String,
25    },
26    NetworkError {
27        message: String,
28        elapsed_ms: u64,
29    },
30    HttpError {
31        status: u16,
32        body: String,
33        elapsed_ms: u64,
34    },
35}
36
37pub struct HardenedClient {
38    agent: ureq::Agent,
39    provider_id: String,
40}
41
42pub fn hardened_agent() -> ureq::Agent {
43    if let Some(remaining) = super::provenance::reuse_time_remaining() {
44        return ureq::config::Config::builder()
45            .tls_config(crate::core::http_client::platform_tls_config())
46            .max_redirects(0)
47            .timeout_global(Some(remaining.max(Duration::from_nanos(1))))
48            .build()
49            .into();
50    }
51    let multiplier = if crate::core::io_health::environment()
52        == crate::core::io_health::IoEnvironment::Degraded
53    {
54        DEGRADED_MULTIPLIER
55    } else {
56        1
57    };
58
59    // Provider credentials include custom headers (for example PRIVATE-TOKEN)
60    // that HTTP clients do not remove on cross-origin redirects. A configured
61    // endpoint is the authority: require the operator to select its final URL.
62    crate::core::http_client::ureq_agent_without_redirects(
63        Some(DEFAULT_RESOLVE_TIMEOUT * multiplier),
64        Some(DEFAULT_CONNECT_TIMEOUT * multiplier),
65        Some(DEFAULT_RESPONSE_TIMEOUT * multiplier),
66    )
67}
68
69impl HardenedClient {
70    pub fn new(provider_id: &str) -> Self {
71        Self {
72            agent: hardened_agent(),
73            provider_id: provider_id.to_owned(),
74        }
75    }
76
77    pub fn get(&self, url: &str) -> HttpOutcome {
78        let request = self.agent.get(url);
79        self.execute_request(|| request.call())
80    }
81
82    pub fn get_with_headers(&self, url: &str, headers: &[(&str, &str)]) -> HttpOutcome {
83        let mut request = self.agent.get(url);
84        for &(key, value) in headers {
85            request = request.header(key, value);
86        }
87        self.execute_request(|| request.call())
88    }
89
90    pub fn post(&self, url: &str, body: &str) -> HttpOutcome {
91        let request = self.agent.post(url);
92        self.execute_request(|| request.send(body))
93    }
94
95    pub fn post_with_headers(
96        &self,
97        url: &str,
98        body: &str,
99        headers: &[(&str, &str)],
100    ) -> HttpOutcome {
101        let mut request = self.agent.post(url);
102        for &(key, value) in headers {
103            request = request.header(key, value);
104        }
105        self.execute_request(|| request.send(body))
106    }
107
108    fn execute_request<F>(&self, request: F) -> HttpOutcome
109    where
110        F: FnOnce() -> Result<ureq::http::Response<ureq::Body>, ureq::Error>,
111    {
112        let started = Instant::now();
113        let response = match request() {
114            Ok(response) => response,
115            Err(error) => return self.error_outcome(error, elapsed_ms(started)),
116        };
117        let status = response.status().as_u16();
118        if !(200..300).contains(&status) {
119            // Neither a Location value nor a server error body is safe context:
120            // either may echo credentials, URLs or private response contents.
121            return HttpOutcome::HttpError {
122                status,
123                body: String::new(),
124                elapsed_ms: elapsed_ms(started),
125            };
126        }
127        let body = match response
128            .into_body()
129            .into_with_config()
130            .limit(MAX_RESPONSE_BODY_BYTES)
131            .read_to_string()
132        {
133            Ok(body) => body,
134            Err(error) => return self.error_outcome(error, elapsed_ms(started)),
135        };
136        let elapsed_ms = elapsed_ms(started);
137
138        HttpOutcome::Success {
139            status,
140            body,
141            elapsed_ms,
142        }
143    }
144
145    fn error_outcome(&self, error: ureq::Error, elapsed_ms: u64) -> HttpOutcome {
146        match error {
147            ureq::Error::Timeout(timeout) => HttpOutcome::Timeout {
148                elapsed_ms,
149                phase: timeout.to_string(),
150            },
151            ureq::Error::StatusCode(status) => HttpOutcome::HttpError {
152                status,
153                body: String::new(),
154                elapsed_ms,
155            },
156            error => HttpOutcome::NetworkError {
157                message: format!("{}: {error}", self.provider_id),
158                elapsed_ms,
159            },
160        }
161    }
162}
163
164impl HttpOutcome {
165    pub fn into_body(self) -> Result<String, String> {
166        match self {
167            Self::Success { body, .. } => Ok(body),
168            Self::Timeout { phase, .. } => Err(format!("timeout during {phase}")),
169            Self::NetworkError { message, .. } => Err(message),
170            Self::HttpError { status, body, .. } => Err(format!("HTTP {status}: {body}")),
171        }
172    }
173
174    pub fn is_success(&self) -> bool {
175        matches!(self, Self::Success { .. })
176    }
177
178    pub fn body(&self) -> Option<&str> {
179        match self {
180            Self::Success { body, .. } => Some(body),
181            _ => None,
182        }
183    }
184
185    pub fn elapsed_ms(&self) -> u64 {
186        match self {
187            Self::Success { elapsed_ms, .. }
188            | Self::Timeout { elapsed_ms, .. }
189            | Self::NetworkError { elapsed_ms, .. }
190            | Self::HttpError { elapsed_ms, .. } => *elapsed_ms,
191        }
192    }
193
194    pub fn into_result(self) -> Result<String, String> {
195        match self {
196            Self::Success { body, .. } => Ok(body),
197            Self::Timeout { phase, .. } => Err(format!("HTTP request timed out during {phase}")),
198            Self::NetworkError { message, .. } => Err(message),
199            Self::HttpError { status, body, .. } => {
200                if body.is_empty() {
201                    Err(format!("HTTP request failed with status {status}"))
202                } else {
203                    Err(format!("HTTP request failed with status {status}: {body}"))
204                }
205            }
206        }
207    }
208}
209
210pub fn provider_get(provider_id: &str, url: &str) -> HttpOutcome {
211    HardenedClient::new(provider_id).get(url)
212}
213
214pub fn provider_get_with_headers(
215    provider_id: &str,
216    url: &str,
217    headers: &[(&str, &str)],
218) -> HttpOutcome {
219    HardenedClient::new(provider_id).get_with_headers(url, headers)
220}
221
222pub fn provider_post(provider_id: &str, url: &str, body: &str) -> HttpOutcome {
223    HardenedClient::new(provider_id).post(url, body)
224}
225
226fn elapsed_ms(started: Instant) -> u64 {
227    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::{
233        DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESOLVE_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT,
234        DEGRADED_MULTIPLIER, HardenedClient, HttpOutcome, hardened_agent,
235    };
236    use std::io::{Read, Write};
237    use std::net::TcpListener;
238    use std::sync::mpsc::{self, Receiver};
239    use std::time::Duration;
240
241    #[test]
242    fn test_hardened_agent_creates_agent() {
243        let _agent = hardened_agent();
244    }
245
246    #[test]
247    fn test_http_outcome_success_is_success() {
248        let outcome = success_outcome(12);
249        assert!(outcome.is_success());
250    }
251
252    #[test]
253    fn test_http_outcome_timeout_not_success() {
254        let outcome = timeout_outcome(12);
255        assert!(!outcome.is_success());
256    }
257
258    #[test]
259    fn test_http_outcome_network_error_not_success() {
260        let outcome = HttpOutcome::NetworkError {
261            message: "offline".to_owned(),
262            elapsed_ms: 12,
263        };
264        assert!(!outcome.is_success());
265    }
266
267    #[test]
268    fn test_http_outcome_into_result_success() {
269        assert_eq!(success_outcome(12).into_result(), Ok("payload".to_owned()));
270    }
271
272    #[test]
273    fn test_http_outcome_into_result_timeout() {
274        let error = timeout_outcome(12)
275            .into_result()
276            .expect_err("timeout expected");
277        assert!(error.contains("timed out"));
278        assert!(error.contains("connect"));
279    }
280
281    #[test]
282    fn test_http_outcome_into_body_variants() {
283        assert_eq!(success_outcome(12).into_body(), Ok("payload".to_owned()));
284        assert_eq!(
285            timeout_outcome(12).into_body(),
286            Err("timeout during connect".to_owned())
287        );
288        assert_eq!(
289            HttpOutcome::NetworkError {
290                message: "offline".to_owned(),
291                elapsed_ms: 12,
292            }
293            .into_body(),
294            Err("offline".to_owned())
295        );
296        assert_eq!(
297            HttpOutcome::HttpError {
298                status: 503,
299                body: "unavailable".to_owned(),
300                elapsed_ms: 12,
301            }
302            .into_body(),
303            Err("HTTP 503: unavailable".to_owned())
304        );
305    }
306
307    #[test]
308    fn test_get_with_headers() {
309        let (get_url, get_request) = serve_once();
310        let client = HardenedClient::new("test");
311        assert!(
312            client
313                .get_with_headers(&get_url, &[("X-Test-Header", "get-value")])
314                .is_success()
315        );
316        assert!(
317            get_request
318                .recv()
319                .expect("GET request expected")
320                .to_ascii_lowercase()
321                .contains("x-test-header: get-value")
322        );
323    }
324
325    #[test]
326    fn test_http_outcome_body_extraction() {
327        let success = success_outcome(12);
328        let error = timeout_outcome(12);
329        assert_eq!(success.body(), Some("payload"));
330        assert_eq!(error.body(), None);
331    }
332
333    #[test]
334    fn test_http_outcome_elapsed_tracking() {
335        let outcomes = [
336            success_outcome(1),
337            timeout_outcome(2),
338            HttpOutcome::NetworkError {
339                message: "offline".to_owned(),
340                elapsed_ms: 3,
341            },
342            HttpOutcome::HttpError {
343                status: 503,
344                body: "unavailable".to_owned(),
345                elapsed_ms: 4,
346            },
347        ];
348        let elapsed: Vec<u64> = outcomes.iter().map(HttpOutcome::elapsed_ms).collect();
349        assert_eq!(elapsed, [1, 2, 3, 4]);
350    }
351
352    #[test]
353    fn test_degraded_multiplier_value() {
354        assert_eq!(DEGRADED_MULTIPLIER, 2);
355    }
356
357    #[test]
358    fn test_default_timeouts_reasonable() {
359        assert_eq!(DEFAULT_RESOLVE_TIMEOUT, Duration::from_secs(5));
360        assert_eq!(DEFAULT_CONNECT_TIMEOUT, Duration::from_secs(10));
361        assert_eq!(DEFAULT_RESPONSE_TIMEOUT, Duration::from_secs(30));
362    }
363
364    fn success_outcome(elapsed_ms: u64) -> HttpOutcome {
365        HttpOutcome::Success {
366            status: 200,
367            body: "payload".to_owned(),
368            elapsed_ms,
369        }
370    }
371
372    fn timeout_outcome(elapsed_ms: u64) -> HttpOutcome {
373        HttpOutcome::Timeout {
374            elapsed_ms,
375            phase: "connect".to_owned(),
376        }
377    }
378
379    fn serve_once() -> (String, Receiver<String>) {
380        let listener = TcpListener::bind("127.0.0.1:0").expect("test listener should bind");
381        let address = listener
382            .local_addr()
383            .expect("test listener address should exist");
384        let (sender, receiver) = mpsc::channel();
385        std::thread::spawn(move || {
386            let (mut stream, _) = listener.accept().expect("request should connect");
387            let mut buffer = [0_u8; 4096];
388            let length = stream
389                .read(&mut buffer)
390                .expect("request should be readable");
391            sender
392                .send(String::from_utf8_lossy(&buffer[..length]).into_owned())
393                .expect("request should be received");
394            stream
395                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
396                .expect("response should be writable");
397        });
398        (format!("http://{address}/test"), receiver)
399    }
400}