1use std::io::Read;
2use std::sync::Arc;
3use std::time::Duration;
4
5use serde::de::DeserializeOwned;
6use serde::Serialize;
7
8use crate::error::{HttpError, MAX_ERROR_BODY_BYTES};
9use crate::retry::{parse_retry_after, RetryPolicy};
10use crate::sse::SseReader;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Method {
14 Get,
15 Post,
16 Put,
17 Patch,
18 Delete,
19 Head,
20}
21
22impl Method {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Method::Get => "GET",
26 Method::Post => "POST",
27 Method::Put => "PUT",
28 Method::Patch => "PATCH",
29 Method::Delete => "DELETE",
30 Method::Head => "HEAD",
31 }
32 }
33 pub fn is_idempotent(self) -> bool {
35 !matches!(self, Method::Post | Method::Patch)
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct RetryEvent {
42 pub url: String,
43 pub retry: u32,
45 pub reason: String,
46 pub delay: Duration,
47}
48
49type RetryObserver = Arc<dyn Fn(&RetryEvent) + Send + Sync>;
50
51#[derive(Clone)]
52pub struct ClientConfig {
53 pub user_agent: String,
54 pub connect_timeout: Option<Duration>,
56 pub request_timeout: Duration,
58 pub response_timeout: Option<Duration>,
61 pub recv_body_timeout: Option<Duration>,
64 pub stream_timeout: Option<Duration>,
66 pub max_redirects: u32,
68 pub max_redirects_will_error: bool,
72 pub max_response_bytes: usize,
73 pub retry: RetryPolicy,
74 pub on_retry: Option<RetryObserver>,
75}
76
77impl Default for ClientConfig {
78 fn default() -> Self {
79 Self {
80 user_agent: concat!("rightkit-http/", env!("CARGO_PKG_VERSION")).to_string(),
81 connect_timeout: Some(Duration::from_secs(10)),
82 request_timeout: Duration::from_secs(60),
83 response_timeout: Some(Duration::from_secs(60)),
84 recv_body_timeout: None,
85 stream_timeout: None,
86 max_redirects: 0,
87 max_redirects_will_error: true,
88 max_response_bytes: 32 * 1024 * 1024,
89 retry: RetryPolicy::default(),
90 on_retry: None,
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
96pub struct Request {
97 pub method: Method,
98 pub url: String,
99 pub headers: Vec<(String, String)>,
100 pub body: Vec<u8>,
101 pub timeout: Option<Duration>,
102 pub retry: Option<RetryPolicy>,
103 pub retry_unsafe_statuses: bool,
107}
108
109impl Request {
110 pub fn new(method: Method, url: impl Into<String>) -> Self {
111 Self {
112 method,
113 url: url.into(),
114 headers: Vec::new(),
115 body: Vec::new(),
116 timeout: None,
117 retry: None,
118 retry_unsafe_statuses: true,
119 }
120 }
121 pub fn get(url: impl Into<String>) -> Self {
122 Self::new(Method::Get, url)
123 }
124 pub fn post(url: impl Into<String>) -> Self {
125 Self::new(Method::Post, url)
126 }
127 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
128 self.headers.push((name.into(), value.into()));
129 self
130 }
131 pub fn bearer(self, token: &str) -> Self {
132 self.header("Authorization", format!("Bearer {token}"))
133 }
134 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
135 self.body = body.into();
136 self
137 }
138 pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self, HttpError> {
139 self.body = serde_json::to_vec(value).map_err(|e| HttpError::Json(e.to_string()))?;
140 self.headers
141 .push(("Content-Type".into(), "application/json".into()));
142 Ok(self)
143 }
144 pub fn form(mut self, pairs: &[(&str, &str)]) -> Self {
146 self.body = form_encode(pairs).into_bytes();
147 self.headers.push((
148 "Content-Type".into(),
149 "application/x-www-form-urlencoded".into(),
150 ));
151 self
152 }
153 pub fn timeout(mut self, d: Duration) -> Self {
154 self.timeout = Some(d);
155 self
156 }
157 pub fn retry(mut self, policy: RetryPolicy) -> Self {
158 self.retry = Some(policy);
159 self
160 }
161}
162
163pub fn percent_encode(input: &str) -> String {
165 let mut out = String::with_capacity(input.len());
166 for b in input.bytes() {
167 match b {
168 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
169 out.push(b as char)
170 }
171 b' ' => out.push('+'),
172 _ => out.push_str(&format!("%{b:02X}")),
173 }
174 }
175 out
176}
177
178pub fn percent_decode(input: &str) -> String {
179 let bytes = input.as_bytes();
180 let mut out = Vec::with_capacity(bytes.len());
181 let mut i = 0;
182 while i < bytes.len() {
183 match bytes[i] {
184 b'+' => out.push(b' '),
185 b'%' if i + 2 < bytes.len() => {
186 let hi = (bytes[i + 1] as char).to_digit(16);
187 let lo = (bytes[i + 2] as char).to_digit(16);
188 match (hi, lo) {
189 (Some(h), Some(l)) => {
190 out.push((h * 16 + l) as u8);
191 i += 2;
192 }
193 _ => out.push(b'%'),
194 }
195 }
196 b => out.push(b),
197 }
198 i += 1;
199 }
200 String::from_utf8_lossy(&out).into_owned()
201}
202
203pub fn form_encode(pairs: &[(&str, &str)]) -> String {
204 pairs
205 .iter()
206 .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
207 .collect::<Vec<_>>()
208 .join("&")
209}
210
211#[derive(Debug, Clone)]
212pub struct Response {
213 pub status: u16,
214 pub headers: Vec<(String, String)>,
216 pub body: Vec<u8>,
217 pub attempts: u32,
218}
219
220impl Response {
221 pub fn header(&self, name: &str) -> Option<&str> {
222 header_of(&self.headers, name)
223 }
224 pub fn text(&self) -> String {
225 String::from_utf8_lossy(&self.body).into_owned()
226 }
227 pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
228 serde_json::from_slice(&self.body).map_err(|e| HttpError::Json(e.to_string()))
229 }
230}
231
232pub struct StreamResponse {
233 pub status: u16,
234 pub headers: Vec<(String, String)>,
235 pub attempts: u32,
236 reader: Box<dyn Read + Send>,
237}
238
239impl StreamResponse {
240 pub fn header(&self, name: &str) -> Option<&str> {
241 header_of(&self.headers, name)
242 }
243 pub fn into_reader(self) -> Box<dyn Read + Send> {
244 self.reader
245 }
246 pub fn sse(self) -> SseReader<Box<dyn Read + Send>> {
248 SseReader::new(self.reader)
249 }
250}
251
252pub(crate) fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
253 headers
254 .iter()
255 .find(|(k, _)| k.eq_ignore_ascii_case(name))
256 .map(|(_, v)| v.as_str())
257}
258
259#[derive(Clone)]
260pub struct Client {
261 agent: ureq::Agent,
262 config: ClientConfig,
263}
264
265struct Raw {
266 status: u16,
267 headers: Vec<(String, String)>,
268 body: ureq::Body,
269 attempts: u32,
270}
271
272impl Client {
273 pub fn new(config: ClientConfig) -> Self {
274 let agent: ureq::Agent = ureq::Agent::config_builder()
275 .http_status_as_error(false)
276 .max_redirects(config.max_redirects)
277 .max_redirects_will_error(config.max_redirects_will_error)
278 .user_agent(config.user_agent.clone())
279 .timeout_connect(config.connect_timeout)
280 .build()
281 .into();
282 Self { agent, config }
283 }
284
285 pub fn config(&self) -> &ClientConfig {
286 &self.config
287 }
288
289 pub fn send(&self, req: &Request) -> Result<Response, HttpError> {
291 let resp = self.send_any(req)?;
292 if (200..300).contains(&resp.status) {
293 Ok(resp)
294 } else {
295 Err(status_error(
296 resp.status,
297 &resp.headers,
298 resp.body,
299 resp.attempts,
300 ))
301 }
302 }
303
304 pub fn send_any(&self, req: &Request) -> Result<Response, HttpError> {
306 let timeout = req.timeout.unwrap_or(self.config.request_timeout);
307 let mut raw = self.execute(req, Some(timeout))?;
308 let mut reader = raw.body.as_reader();
309 let body = read_limited(&mut reader, self.config.max_response_bytes)?;
310 Ok(Response {
311 status: raw.status,
312 headers: raw.headers,
313 body,
314 attempts: raw.attempts,
315 })
316 }
317
318 pub fn stream(&self, req: &Request) -> Result<StreamResponse, HttpError> {
321 let mut raw = self.execute(req, self.config.stream_timeout)?;
322 if !(200..300).contains(&raw.status) {
323 let mut reader = raw.body.as_reader();
324 let body = read_limited(&mut reader, MAX_ERROR_BODY_BYTES).unwrap_or_default();
325 return Err(status_error(raw.status, &raw.headers, body, raw.attempts));
326 }
327 Ok(StreamResponse {
328 status: raw.status,
329 headers: raw.headers,
330 attempts: raw.attempts,
331 reader: Box::new(raw.body.into_reader()),
332 })
333 }
334
335 pub fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, HttpError> {
336 self.send(&Request::get(url).header("Accept", "application/json"))?
337 .json()
338 }
339
340 fn execute(&self, req: &Request, total: Option<Duration>) -> Result<Raw, HttpError> {
341 let policy = req
342 .retry
343 .clone()
344 .unwrap_or_else(|| self.config.retry.clone());
345 let max = policy.max_attempts.max(1);
346 let mut attempt = 0u32;
347 loop {
348 attempt += 1;
349 let outcome = self.once(req, total);
350 let (reason, retry_after) = match outcome {
351 Ok(raw) => {
352 let retryable = policy.is_retryable_status(raw.status)
353 && (req.method.is_idempotent() || req.retry_unsafe_statuses);
354 if !retryable || attempt >= max {
355 return Ok(Raw {
356 attempts: attempt,
357 ..raw
358 });
359 }
360 let ra = header_of(&raw.headers, "retry-after").and_then(parse_retry_after);
361 let mut body = raw.body;
363 let mut reader = body.as_reader();
364 let _ = read_limited(&mut reader, MAX_ERROR_BODY_BYTES);
365 (format!("HTTP {}", raw.status), ra)
366 }
367 Err(err) => {
368 let retryable = match &err {
369 HttpError::Transport { connect_phase, .. } => {
370 *connect_phase || req.method.is_idempotent()
371 }
372 HttpError::Timeout(_) => req.method.is_idempotent(),
373 _ => false,
374 };
375 if !retryable || attempt >= max {
376 return Err(err);
377 }
378 (err.to_string(), None)
379 }
380 };
381 let delay = policy.delay(attempt, retry_after);
382 if let Some(obs) = &self.config.on_retry {
383 obs(&RetryEvent {
384 url: req.url.clone(),
385 retry: attempt,
386 reason,
387 delay,
388 });
389 }
390 std::thread::sleep(delay);
391 }
392 }
393
394 fn once(&self, req: &Request, total: Option<Duration>) -> Result<Raw, HttpError> {
395 let mut builder = ureq::http::Request::builder()
396 .method(req.method.as_str())
397 .uri(&req.url);
398 for (k, v) in &req.headers {
399 builder = builder.header(k.as_str(), v.as_str());
400 }
401 let request = builder
402 .body(req.body.clone())
403 .map_err(|e| HttpError::InvalidRequest(e.to_string()))?;
404 let configured = self
405 .agent
406 .configure_request(request)
407 .timeout_global(total)
408 .timeout_recv_response(self.config.response_timeout)
409 .timeout_recv_body(self.config.recv_body_timeout)
410 .build();
411 let response = self.agent.run(configured).map_err(map_ureq_error)?;
412 let status = response.status().as_u16();
413 let headers = response
414 .headers()
415 .iter()
416 .map(|(k, v)| {
417 (
418 k.as_str().to_ascii_lowercase(),
419 String::from_utf8_lossy(v.as_bytes()).into_owned(),
420 )
421 })
422 .collect();
423 Ok(Raw {
424 status,
425 headers,
426 body: response.into_body(),
427 attempts: 1,
428 })
429 }
430}
431
432pub(crate) fn status_error(
433 status: u16,
434 headers: &[(String, String)],
435 body: Vec<u8>,
436 attempts: u32,
437) -> HttpError {
438 let mut body = body;
439 body.truncate(MAX_ERROR_BODY_BYTES);
440 HttpError::Status {
441 status,
442 body: String::from_utf8_lossy(&body).into_owned(),
443 retry_after: header_of(headers, "retry-after").and_then(parse_retry_after),
444 attempts,
445 }
446}
447
448fn read_limited(reader: &mut dyn Read, limit: usize) -> Result<Vec<u8>, HttpError> {
449 let mut out = Vec::new();
450 reader
451 .take(limit as u64 + 1)
452 .read_to_end(&mut out)
453 .map_err(|e| match e.kind() {
454 std::io::ErrorKind::TimedOut => HttpError::Timeout(e.to_string()),
455 _ => map_ureq_error(e.into()),
458 })?;
459 if out.len() > limit {
460 return Err(HttpError::BodyTooLarge { limit });
461 }
462 Ok(out)
463}
464
465fn map_ureq_error(err: ureq::Error) -> HttpError {
466 use ureq::Error as E;
467 match err {
468 E::Timeout(t) => HttpError::Timeout(format!("{t:?}")),
469 E::TooManyRedirects => HttpError::Transport {
470 message: "too many redirects".into(),
471 connect_phase: false,
472 },
473 E::HostNotFound => HttpError::Transport {
474 message: "host not found".into(),
475 connect_phase: true,
476 },
477 E::ConnectionFailed => HttpError::Transport {
478 message: "connection failed".into(),
479 connect_phase: true,
480 },
481 E::Io(io)
482 if matches!(
483 io.kind(),
484 std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
485 ) =>
486 {
487 HttpError::Transport {
488 message: io.to_string(),
489 connect_phase: true,
490 }
491 }
492 E::Io(io) => HttpError::Transport {
493 message: io.to_string(),
494 connect_phase: false,
495 },
496 E::BadUri(m) => HttpError::InvalidRequest(m),
497 E::Http(e) => HttpError::InvalidRequest(e.to_string()),
498 other => HttpError::Transport {
499 message: other.to_string(),
500 connect_phase: false,
501 },
502 }
503}