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(client: &reqwest::Client, url: &str) -> Result<String, String> {
75    let mut last_err = String::new();
76    let mut backoff = INITIAL_BACKOFF_MS;
77
78    for attempt in 0..=MAX_RETRIES {
79        if attempt > 0 {
80            tokio::time::sleep(Duration::from_millis(backoff)).await;
81            backoff = (backoff * 2).min(16_000);
82        }
83
84        let resp = match client.get(url).send().await {
85            Ok(r) => r,
86            Err(e) => {
87                last_err = format!("GET {url}: {e}");
88                continue;
89            }
90        };
91
92        let status = resp.status();
93        if is_retryable_status(status) {
94            let reason = status.canonical_reason().unwrap_or("unknown");
95            last_err = format!("GET {url}: HTTP {status} {reason}");
96            continue;
97        }
98        if !status.is_success() {
99            let reason = status.canonical_reason().unwrap_or("unknown");
100            return Err(format!("GET {url}: HTTP {status} {reason}"));
101        }
102
103        return resp
104            .text()
105            .await
106            .map_err(|e| format!("GET {url}: failed to read response body: {e}"));
107    }
108
109    Err(format!("{last_err} (failed after {MAX_RETRIES} retries)"))
110}