Skip to main content

minecraft_java_rs_core/net/
http.rs

1use std::time::Duration;
2
3use serde::de::DeserializeOwned;
4
5const MAX_RETRIES: u32 = 3;
6const INITIAL_BACKOFF_MS: u64 = 1_000;
7
8/// Returns true for errors that are worth retrying (network issues, server errors).
9/// 4xx client errors are not retried since they won't change.
10fn is_retryable_status(status: reqwest::StatusCode) -> bool {
11    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
12}
13
14/// GET `url`, check HTTP status, parse body as JSON.
15///
16/// Retries up to `MAX_RETRIES` times on network errors or 5xx/429 responses,
17/// with exponential backoff starting at `INITIAL_BACKOFF_MS`.
18///
19/// Returns a descriptive error that always includes the URL, HTTP status,
20/// attempt count, and a body preview when JSON parsing fails.
21pub async fn fetch_json<T: DeserializeOwned>(
22    client: &reqwest::Client,
23    url: &str,
24) -> Result<T, String> {
25    let mut last_err = String::new();
26    let mut backoff = INITIAL_BACKOFF_MS;
27
28    for attempt in 0..=MAX_RETRIES {
29        if attempt > 0 {
30            tokio::time::sleep(Duration::from_millis(backoff)).await;
31            backoff = (backoff * 2).min(16_000);
32        }
33
34        let resp = match client.get(url).send().await {
35            Ok(r) => r,
36            Err(e) => {
37                last_err = format!("GET {url}: {e}");
38                continue;
39            }
40        };
41
42        let status = resp.status();
43        if is_retryable_status(status) {
44            let reason = status.canonical_reason().unwrap_or("unknown");
45            last_err = format!("GET {url}: HTTP {status} {reason}");
46            continue;
47        }
48        if !status.is_success() {
49            let reason = status.canonical_reason().unwrap_or("unknown");
50            return Err(format!("GET {url}: HTTP {status} {reason}"));
51        }
52
53        let text = match resp.text().await {
54            Ok(t) => t,
55            Err(e) => {
56                last_err = format!("GET {url}: failed to read response body: {e}");
57                continue;
58            }
59        };
60
61        return serde_json::from_str(&text).map_err(|e| {
62            let preview: String = text.chars().take(300).collect();
63            format!("GET {url}: failed to parse JSON: {e}\nBody: {preview}")
64        });
65    }
66
67    Err(format!("{last_err} (failed after {MAX_RETRIES} retries)"))
68}
69
70/// GET `url`, check HTTP status, return body as text.
71///
72/// Retries up to `MAX_RETRIES` times on network errors or 5xx/429 responses,
73/// with exponential backoff starting at `INITIAL_BACKOFF_MS`.
74pub async fn fetch_text(
75    client: &reqwest::Client,
76    url: &str,
77) -> Result<String, String> {
78    let mut last_err = String::new();
79    let mut backoff = INITIAL_BACKOFF_MS;
80
81    for attempt in 0..=MAX_RETRIES {
82        if attempt > 0 {
83            tokio::time::sleep(Duration::from_millis(backoff)).await;
84            backoff = (backoff * 2).min(16_000);
85        }
86
87        let resp = match client.get(url).send().await {
88            Ok(r) => r,
89            Err(e) => {
90                last_err = format!("GET {url}: {e}");
91                continue;
92            }
93        };
94
95        let status = resp.status();
96        if is_retryable_status(status) {
97            let reason = status.canonical_reason().unwrap_or("unknown");
98            last_err = format!("GET {url}: HTTP {status} {reason}");
99            continue;
100        }
101        if !status.is_success() {
102            let reason = status.canonical_reason().unwrap_or("unknown");
103            return Err(format!("GET {url}: HTTP {status} {reason}"));
104        }
105
106        return resp
107            .text()
108            .await
109            .map_err(|e| format!("GET {url}: failed to read response body: {e}"));
110    }
111
112    Err(format!("{last_err} (failed after {MAX_RETRIES} retries)"))
113}