1use std::collections::HashMap;
2use std::time::Instant;
3
4use crate::types::HttpResponse;
5
6#[derive(Debug, Clone, thiserror::Error)]
8pub enum RequestError {
9 #[error("Timeout after {elapsed_ms}ms (limit: {limit_ms}ms)")]
11 Timeout { elapsed_ms: u64, limit_ms: u64 },
12 #[error("Connection error: {0}")]
14 Connection(String),
15 #[error("DNS resolution failed: {0}")]
17 Dns(String),
18 #[error("TLS error: {0}")]
20 Tls(String),
21 #[error("Request error: {0}")]
23 Other(String),
24}
25
26impl RequestError {
27 pub fn error_key(&self) -> &'static str {
29 match self {
30 Self::Timeout { .. } => "timeout",
31 Self::Connection(_) => "connection",
32 Self::Dns(_) => "dns",
33 Self::Tls(_) => "tls",
34 Self::Other(_) => "other",
35 }
36 }
37
38 fn classify(err: reqwest::Error, elapsed_ms: u64, limit_ms: u64) -> Self {
40 if err.is_timeout() {
41 return Self::Timeout {
42 elapsed_ms,
43 limit_ms,
44 };
45 }
46 if err.is_connect() {
47 return Self::Connection(err.without_url().to_string());
48 }
49 let msg_lower = err.to_string().to_lowercase();
50 if msg_lower.contains("dns") || msg_lower.contains("resolve") {
51 return Self::Dns(err.without_url().to_string());
52 }
53 if msg_lower.contains("tls")
54 || msg_lower.contains("ssl")
55 || msg_lower.contains("certificate")
56 {
57 return Self::Tls(err.without_url().to_string());
58 }
59 Self::Other(err.without_url().to_string())
60 }
61}
62
63pub fn build_client() -> Result<reqwest::Client, RequestError> {
65 reqwest::Client::builder()
66 .redirect(reqwest::redirect::Policy::limited(10))
67 .build()
68 .map_err(|e| RequestError::Other(format!("Failed to build HTTP client: {e}")))
69}
70
71pub async fn execute_request(
73 client: &reqwest::Client,
74 method: &str,
75 url: &str,
76 headers: &HashMap<String, String>,
77 body: Option<&str>,
78 timeout_ms: u64,
79) -> Result<HttpResponse, RequestError> {
80 let method = method
81 .parse::<reqwest::Method>()
82 .map_err(|e| RequestError::Other(format!("Invalid HTTP method: {e}")))?;
83
84 let mut req = client.request(method, url);
85
86 for (key, val) in headers {
87 req = req.header(key.as_str(), val.as_str());
88 }
89
90 if let Some(body) = body {
91 req = req.body(body.to_string());
92 }
93
94 let built = req
96 .build()
97 .map_err(|e| RequestError::Other(format!("Failed to build request: {e}")))?;
98
99 let mut actual_request_headers: HashMap<String, String> = HashMap::new();
100 for (key, val) in built.headers() {
101 if let Ok(v) = val.to_str() {
102 actual_request_headers.insert(key.as_str().to_string(), v.to_string());
103 }
104 }
105
106 let start = Instant::now();
107 let timeout_duration = std::time::Duration::from_millis(timeout_ms);
108
109 let response = tokio::time::timeout(timeout_duration, client.execute(built))
110 .await
111 .map_err(|_| RequestError::Timeout {
112 elapsed_ms: elapsed_ms_saturating(&start),
113 limit_ms: timeout_ms,
114 })?
115 .map_err(|e| RequestError::classify(e, elapsed_ms_saturating(&start), timeout_ms))?;
116
117 let elapsed_ms = elapsed_ms_saturating(&start);
118
119 let status = response.status().as_u16();
120 let http_version = match response.version() {
121 reqwest::Version::HTTP_09 => "HTTP/0.9",
122 reqwest::Version::HTTP_10 => "HTTP/1.0",
123 reqwest::Version::HTTP_11 => "HTTP/1.1",
124 reqwest::Version::HTTP_2 => "HTTP/2",
125 reqwest::Version::HTTP_3 => "HTTP/3",
126 _ => "HTTP/?",
127 }
128 .to_string();
129
130 let mut resp_headers: HashMap<String, String> = HashMap::new();
132 for (key, val) in response.headers() {
133 if let Ok(v) = val.to_str() {
134 resp_headers.insert(key.as_str().to_string(), v.to_string());
135 }
136 }
137
138 let charset = detect_charset(&resp_headers);
140
141 let bytes = response
143 .bytes()
144 .await
145 .map_err(|e| RequestError::Other(format!("Failed to read response body: {e}")))?;
146 let size_bytes = bytes.len();
147
148 let body = decode_body(&bytes, charset.as_deref());
149
150 Ok(HttpResponse {
151 status,
152 http_version,
153 headers: resp_headers,
154 actual_request_headers,
155 body,
156 charset,
157 elapsed_ms,
158 size_bytes,
159 })
160}
161
162fn elapsed_ms_saturating(start: &Instant) -> u64 {
164 start.elapsed().as_millis().min(u64::MAX as u128) as u64
165}
166
167fn detect_charset(headers: &HashMap<String, String>) -> Option<String> {
169 let ct = headers
170 .iter()
171 .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
172 .map(|(_, v)| v)?;
173
174 ct.split(';').find_map(|part| {
175 let part = part.trim();
176 part.strip_prefix("charset=")
177 .map(|charset| charset.trim_matches('"').to_lowercase())
178 })
179}
180
181fn decode_body(bytes: &[u8], charset: Option<&str>) -> String {
183 let charset = charset.unwrap_or("utf-8");
184
185 match charset {
186 "utf-8" | "utf8" => String::from_utf8_lossy(bytes).into_owned(),
187 _ => {
188 let encoding =
189 encoding_rs::Encoding::for_label(charset.as_bytes()).unwrap_or(encoding_rs::UTF_8);
190 let (decoded, _, _) = encoding.decode(bytes);
191 decoded.into_owned()
192 }
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn detect_charset_from_content_type() {
202 let mut headers = HashMap::new();
203 headers.insert(
204 "content-type".to_string(),
205 "text/csv; charset=Shift_JIS".to_string(),
206 );
207 assert_eq!(detect_charset(&headers), Some("shift_jis".to_string()));
208 }
209
210 #[test]
211 fn detect_charset_missing() {
212 let headers = HashMap::new();
213 assert_eq!(detect_charset(&headers), None);
214 }
215
216 #[test]
217 fn detect_charset_no_charset_param() {
218 let mut headers = HashMap::new();
219 headers.insert("content-type".to_string(), "application/json".to_string());
220 assert_eq!(detect_charset(&headers), None);
221 }
222
223 #[test]
224 fn decode_utf8() {
225 let bytes = "こんにちは".as_bytes();
226 assert_eq!(decode_body(bytes, Some("utf-8")), "こんにちは");
227 }
228
229 #[test]
230 fn decode_shift_jis() {
231 let (encoded, _, _) = encoding_rs::SHIFT_JIS.encode("テスト");
232 let decoded = decode_body(&encoded, Some("shift_jis"));
233 assert_eq!(decoded, "テスト");
234 }
235
236 #[test]
237 fn request_error_timeout_display() {
238 let err = RequestError::Timeout {
239 elapsed_ms: 30019,
240 limit_ms: 30000,
241 };
242 assert_eq!(err.to_string(), "Timeout after 30019ms (limit: 30000ms)");
243 assert_eq!(err.error_key(), "timeout");
244 }
245
246 #[test]
247 fn request_error_connection_display() {
248 let err = RequestError::Connection("connection refused".to_string());
249 assert_eq!(err.to_string(), "Connection error: connection refused");
250 assert_eq!(err.error_key(), "connection");
251 }
252
253 #[test]
254 fn request_error_dns_display() {
255 let err = RequestError::Dns("failed to lookup address".to_string());
256 assert_eq!(
257 err.to_string(),
258 "DNS resolution failed: failed to lookup address"
259 );
260 assert_eq!(err.error_key(), "dns");
261 }
262
263 #[test]
264 fn request_error_tls_display() {
265 let err = RequestError::Tls("certificate verify failed".to_string());
266 assert_eq!(err.to_string(), "TLS error: certificate verify failed");
267 assert_eq!(err.error_key(), "tls");
268 }
269
270 #[test]
271 fn request_error_other_display() {
272 let err = RequestError::Other("something went wrong".to_string());
273 assert_eq!(err.to_string(), "Request error: something went wrong");
274 assert_eq!(err.error_key(), "other");
275 }
276
277 #[test]
278 fn elapsed_ms_saturating_normal() {
279 let start = Instant::now();
280 let ms = elapsed_ms_saturating(&start);
281 assert!(ms < 1000);
282 }
283}