1use std::future::Future;
86use std::pin::Pin;
87use std::sync::Arc;
88use std::time::{Duration, SystemTime};
89
90use agent_effects::{EffectContext, EffectFailure, FailureClass, Verification};
91use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
92use reqwest::{Client, Method, StatusCode};
93use serde::de::DeserializeOwned;
94use serde::{Deserialize, Serialize};
95
96pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key";
98
99const ERROR_BODY_LIMIT: usize = 512;
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum StatusClass {
105 Success,
107 Failure(FailureClass),
109}
110
111pub fn classify_status(status: StatusCode, _headers: &HeaderMap) -> StatusClass {
113 let failure = match status.as_u16() {
114 200..=299 => return StatusClass::Success,
115 300..=399 => FailureClass::Ambiguous,
118 408 | 425 | 429 | 503 => FailureClass::Ambiguous,
121 400 | 422 => FailureClass::Validation,
122 401 => FailureClass::Authentication,
123 403 => FailureClass::Authorization,
124 409 | 500 | 502 | 504 => FailureClass::Ambiguous,
125 501 => FailureClass::Permanent,
126 500..=599 => FailureClass::Ambiguous,
127 _ => FailureClass::Permanent,
128 };
129 StatusClass::Failure(failure)
130}
131
132fn retry_after(headers: &HeaderMap) -> Option<Duration> {
134 let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
135 if let Ok(seconds) = value.parse::<u64>() {
136 return Some(Duration::from_secs(seconds));
137 }
138 let at = httpdate::parse_http_date(value).ok()?;
139 Some(at.duration_since(SystemTime::now()).unwrap_or_default())
140}
141
142pub fn classify_error(error: &reqwest::Error) -> EffectFailure {
146 let message = error.to_string();
147 if error.is_builder() {
148 EffectFailure::validation(message)
149 } else {
150 EffectFailure::ambiguous(message)
153 }
154}
155
156pub fn classify_error_without_redirects(error: &reqwest::Error) -> EffectFailure {
160 if error.is_connect() {
161 EffectFailure::ambiguous(error.to_string()).request_sent(false)
162 } else {
163 classify_error(error)
164 }
165}
166
167type Call<T> = Pin<Box<dyn Future<Output = Result<T, EffectFailure>> + Send>>;
169
170type StatusOverride = Arc<dyn Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync>;
171
172#[derive(Clone)]
175#[must_use]
176pub struct HttpEffect {
177 client: Client,
178 method: Method,
179 url: String,
180 headers: HeaderMap,
181 body: Option<Vec<u8>>,
182 idempotency_header: Option<HeaderName>,
183 timeout: Option<Duration>,
184 classify: Option<StatusOverride>,
185 no_redirects: bool,
187 invalid: Option<String>,
189}
190
191impl HttpEffect {
192 pub fn new(client: &Client, method: Method, url: impl Into<String>) -> Self {
194 Self {
195 client: client.clone(),
196 method,
197 url: url.into(),
198 headers: HeaderMap::new(),
199 body: None,
200 idempotency_header: None,
201 timeout: None,
202 classify: None,
203 no_redirects: false,
204 invalid: None,
205 }
206 }
207
208 pub fn get(client: &Client, url: impl Into<String>) -> Self {
210 Self::new(client, Method::GET, url)
211 }
212
213 pub fn post(client: &Client, url: impl Into<String>) -> Self {
215 Self::new(client, Method::POST, url)
216 }
217
218 pub fn put(client: &Client, url: impl Into<String>) -> Self {
220 Self::new(client, Method::PUT, url)
221 }
222
223 pub fn patch(client: &Client, url: impl Into<String>) -> Self {
225 Self::new(client, Method::PATCH, url)
226 }
227
228 pub fn delete(client: &Client, url: impl Into<String>) -> Self {
230 Self::new(client, Method::DELETE, url)
231 }
232
233 pub fn header(mut self, name: &str, value: &str) -> Self {
236 match (HeaderName::try_from(name), HeaderValue::try_from(value)) {
237 (Ok(name), Ok(value)) => {
238 self.headers.append(name, value);
239 }
240 _ => self.invalid = Some(format!("invalid header `{name}`")),
241 }
242 self
243 }
244
245 pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
247 match serde_json::to_vec(body) {
248 Ok(bytes) => {
249 self.body = Some(bytes);
250 self.headers
251 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
252 }
253 Err(e) => self.invalid = Some(format!("body is not JSON-serializable: {e}")),
254 }
255 self
256 }
257
258 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
260 self.body = Some(body.into());
261 self
262 }
263
264 pub fn idempotency_header(mut self, name: &str) -> Self {
269 match HeaderName::try_from(name) {
270 Ok(name) => self.idempotency_header = Some(name),
271 Err(_) => self.invalid = Some(format!("invalid header `{name}`")),
272 }
273 self
274 }
275
276 pub fn idempotency_key_header(self) -> Self {
278 self.idempotency_header(IDEMPOTENCY_KEY)
279 }
280
281 pub fn timeout(mut self, timeout: Duration) -> Self {
284 self.timeout = Some(timeout);
285 self
286 }
287
288 pub fn client_follows_no_redirects(mut self) -> Self {
296 self.no_redirects = true;
297 self
298 }
299
300 pub fn classify_status(
307 mut self,
308 classify: impl Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync + 'static,
309 ) -> Self {
310 self.classify = Some(Arc::new(classify));
311 self
312 }
313
314 pub fn send_json<T: DeserializeOwned + Send + 'static>(
317 self,
318 ) -> impl Fn(EffectContext) -> Call<T> + Send + Sync + 'static {
319 let request = Arc::new(self);
320 move |ctx| {
321 let request = Arc::clone(&request);
322 Box::pin(async move {
323 let body = request.execute(&ctx).await?;
324 parse_json(&body).map_err(|e| {
325 EffectFailure::ambiguous(format!(
326 "the request succeeded but its response could not be read: {e}"
327 ))
328 })
329 })
330 }
331 }
332
333 pub fn send(self) -> impl Fn(EffectContext) -> Call<HttpResponse> + Send + Sync + 'static {
336 let request = Arc::new(self);
337 move |ctx| {
338 let request = Arc::clone(&request);
339 Box::pin(async move {
340 let (status, body) = request.execute_with_status(&ctx).await?;
341 Ok(HttpResponse {
342 status,
343 body: String::from_utf8_lossy(&body).into_owned(),
344 })
345 })
346 }
347 }
348
349 pub fn verify_json<T: DeserializeOwned + Send + 'static>(
357 self,
358 ) -> impl Fn(EffectContext) -> Call<Verification<T>> + Send + Sync + 'static {
359 let request = Arc::new(self);
360 move |ctx| {
361 let request = Arc::clone(&request);
362 Box::pin(async move {
363 let response = request.send_once(&ctx).await?;
364 let status = response.status();
365 if matches!(status.as_u16(), 404 | 410)
369 && request.no_redirects
370 && !request.redirected(&response)
371 {
372 return Ok(Verification::NotApplied);
373 }
374 let body = request.success_body(response).await?;
375 parse_json(&body)
376 .map(Verification::Confirmed)
377 .map_err(|e| EffectFailure::ambiguous(format!("unreadable lookup: {e}")))
378 })
379 }
380 }
381
382 async fn execute(&self, ctx: &EffectContext) -> Result<Vec<u8>, EffectFailure> {
383 self.execute_with_status(ctx).await.map(|(_, body)| body)
384 }
385
386 async fn execute_with_status(
388 &self,
389 ctx: &EffectContext,
390 ) -> Result<(u16, Vec<u8>), EffectFailure> {
391 let response = self.send_once(ctx).await?;
392 let status = response.status().as_u16();
393 Ok((status, self.success_body(response).await?))
394 }
395
396 fn redirected(&self, response: &reqwest::Response) -> bool {
399 reqwest::Url::parse(&self.url).map_or(true, |url| &url != response.url())
400 }
401
402 async fn send_once(&self, ctx: &EffectContext) -> Result<reqwest::Response, EffectFailure> {
404 if let Some(problem) = &self.invalid {
405 return Err(EffectFailure::validation(problem.clone()));
406 }
407 let mut request = self
408 .client
409 .request(self.method.clone(), &self.url)
410 .headers(self.headers.clone());
411 if let Some(name) = &self.idempotency_header {
412 let key = ctx.idempotency_key().to_string();
413 let value = HeaderValue::try_from(key).map_err(EffectFailure::validation)?;
414 request = request.header(name.clone(), value);
415 }
416 if let Some(body) = &self.body {
417 request = request.body(body.clone());
418 }
419 if let Some(timeout) = self.timeout {
420 request = request.timeout(timeout);
421 }
422 request.send().await.map_err(|e| {
423 if self.no_redirects {
424 classify_error_without_redirects(&e)
425 } else {
426 classify_error(&e)
427 }
428 })
429 }
430
431 async fn success_body(&self, response: reqwest::Response) -> Result<Vec<u8>, EffectFailure> {
433 let status = response.status();
434 let retry_delay = retry_after(response.headers());
435 let verdict = self
436 .classify
437 .as_ref()
438 .and_then(|classify| classify(status, response.headers()))
439 .unwrap_or_else(|| classify_status(status, response.headers()));
440 let redirected = !self.no_redirects || self.redirected(&response);
441 match verdict {
442 StatusClass::Success => {
443 let body = response.bytes().await.map_err(|e| {
445 EffectFailure::ambiguous(format!(
446 "HTTP {status}, but reading the response failed: {e}"
447 ))
448 })?;
449 Ok(body.to_vec())
450 }
451 StatusClass::Failure(_) if redirected => {
453 let snippet = error_snippet(response).await;
454 Err(with_delay(
455 EffectFailure::ambiguous(format!(
456 "HTTP {status} after following or possibly following a redirect: {snippet}"
457 )),
458 retry_delay,
459 ))
460 }
461 StatusClass::Failure(class) => {
462 let snippet = error_snippet(response).await;
463 Err(with_delay(
464 EffectFailure::new(class, format!("HTTP {status}: {snippet}")),
465 retry_delay,
466 ))
467 }
468 }
469 }
470}
471
472fn with_delay(failure: EffectFailure, delay: Option<Duration>) -> EffectFailure {
473 match delay {
474 Some(delay) => failure.with_retry_after(delay),
475 None => failure,
476 }
477}
478
479async fn error_snippet(mut response: reqwest::Response) -> String {
482 let mut body = Vec::new();
483 while body.len() <= ERROR_BODY_LIMIT {
484 match response.chunk().await {
485 Ok(Some(chunk)) => body.extend_from_slice(&chunk),
486 Ok(None) | Err(_) => break,
487 }
488 }
489 let mut snippet = String::from_utf8_lossy(&body).into_owned();
490 if snippet.len() > ERROR_BODY_LIMIT {
491 let cut = (0..=ERROR_BODY_LIMIT)
492 .rev()
493 .find(|&i| snippet.is_char_boundary(i))
494 .unwrap_or(0);
495 snippet.truncate(cut);
496 snippet.push('…');
497 }
498 snippet
499}
500
501#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
503pub struct HttpResponse {
504 pub status: u16,
506 pub body: String,
508}
509
510fn parse_json<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
511 if body.iter().all(u8::is_ascii_whitespace) {
512 serde_json::from_slice(b"null")
513 } else {
514 serde_json::from_slice(body)
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn class(status: u16, headers: &[(&str, &str)]) -> StatusClass {
523 let mut map = HeaderMap::new();
524 for (name, value) in headers {
525 map.insert(
526 HeaderName::try_from(*name).unwrap(),
527 HeaderValue::try_from(*value).unwrap(),
528 );
529 }
530 classify_status(StatusCode::from_u16(status).unwrap(), &map)
531 }
532
533 #[test]
534 fn statuses_map_to_failure_classes() {
535 use FailureClass as F;
536 use StatusClass::{Failure, Success};
537 let table = [
538 (200, Success),
539 (201, Success),
540 (204, Success),
541 (301, Failure(F::Ambiguous)),
542 (303, Failure(F::Ambiguous)),
543 (308, Failure(F::Ambiguous)),
544 (400, Failure(F::Validation)),
545 (401, Failure(F::Authentication)),
546 (403, Failure(F::Authorization)),
547 (404, Failure(F::Permanent)),
548 (408, Failure(F::Ambiguous)),
549 (409, Failure(F::Ambiguous)),
550 (418, Failure(F::Permanent)),
551 (422, Failure(F::Validation)),
552 (425, Failure(F::Ambiguous)),
553 (429, Failure(F::Ambiguous)),
554 (500, Failure(F::Ambiguous)),
555 (501, Failure(F::Permanent)),
556 (502, Failure(F::Ambiguous)),
557 (503, Failure(F::Ambiguous)),
558 (504, Failure(F::Ambiguous)),
559 (599, Failure(F::Ambiguous)),
560 ];
561 for (status, expected) in table {
562 assert_eq!(class(status, &[]), expected, "HTTP {status}");
563 }
564 }
565
566 #[test]
567 fn retry_after_is_honoured_in_seconds_and_as_a_date() {
568 for status in [408, 425, 429, 500, 502, 503, 504] {
569 assert_eq!(
570 class(status, &[("retry-after", "7")]),
571 StatusClass::Failure(FailureClass::Ambiguous)
572 );
573 }
574 let headers = |value: &str| {
575 let mut map = HeaderMap::new();
576 map.insert(RETRY_AFTER, HeaderValue::try_from(value).unwrap());
577 map
578 };
579 assert_eq!(retry_after(&headers("7")), Some(Duration::from_secs(7)));
580 let in_a_minute = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60));
581 let after = retry_after(&headers(&in_a_minute)).unwrap();
582 assert!(after > Duration::from_secs(55) && after <= Duration::from_secs(60));
583 assert_eq!(retry_after(&headers("not a date")), None);
584 assert_eq!(retry_after(&headers("-1")), None);
585 assert_eq!(
586 retry_after(&headers("Thu, 01 Jan 1970 00:00:00 GMT")),
587 Some(Duration::ZERO)
588 );
589 }
590
591 #[test]
592 fn an_empty_body_reads_as_null() {
593 let unit: () = parse_json(b"").unwrap();
594 assert_eq!(unit, ());
595 let none: Option<u8> = parse_json(b" \n").unwrap();
596 assert_eq!(none, None);
597 }
598}