1use std::future::Future;
79use std::pin::Pin;
80use std::sync::Arc;
81use std::time::{Duration, SystemTime};
82
83use agent_effects::{EffectContext, EffectFailure, FailureClass, Verification};
84use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
85use reqwest::{Client, Method, StatusCode};
86use serde::de::DeserializeOwned;
87use serde::{Deserialize, Serialize};
88
89pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key";
91
92const ERROR_BODY_LIMIT: usize = 512;
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum StatusClass {
98 Success,
100 Failure(FailureClass),
102}
103
104pub fn classify_status(status: StatusCode, headers: &HeaderMap) -> StatusClass {
106 let failure = match status.as_u16() {
107 200..=299 => return StatusClass::Success,
108 300..=399 => FailureClass::Ambiguous,
111 408 | 425 => FailureClass::Transient,
112 429 => FailureClass::RateLimited {
113 retry_after: retry_after(headers),
114 },
115 503 => match retry_after(headers) {
116 Some(after) => FailureClass::RateLimited {
117 retry_after: Some(after),
118 },
119 None => FailureClass::Transient,
120 },
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(
305 mut self,
306 classify: impl Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync + 'static,
307 ) -> Self {
308 self.classify = Some(Arc::new(classify));
309 self
310 }
311
312 pub fn send_json<T: DeserializeOwned + Send + 'static>(
315 self,
316 ) -> impl Fn(EffectContext) -> Call<T> + Send + Sync + 'static {
317 let request = Arc::new(self);
318 move |ctx| {
319 let request = Arc::clone(&request);
320 Box::pin(async move {
321 let body = request.execute(&ctx).await?;
322 parse_json(&body).map_err(|e| {
323 EffectFailure::ambiguous(format!(
324 "the request succeeded but its response could not be read: {e}"
325 ))
326 })
327 })
328 }
329 }
330
331 pub fn send(self) -> impl Fn(EffectContext) -> Call<HttpResponse> + Send + Sync + 'static {
334 let request = Arc::new(self);
335 move |ctx| {
336 let request = Arc::clone(&request);
337 Box::pin(async move {
338 let (status, body) = request.execute_with_status(&ctx).await?;
339 Ok(HttpResponse {
340 status,
341 body: String::from_utf8_lossy(&body).into_owned(),
342 })
343 })
344 }
345 }
346
347 pub fn verify_json<T: DeserializeOwned + Send + 'static>(
354 self,
355 ) -> impl Fn(EffectContext) -> Call<Verification<T>> + Send + Sync + 'static {
356 let request = Arc::new(self);
357 move |ctx| {
358 let request = Arc::clone(&request);
359 Box::pin(async move {
360 let response = request.send_once(&ctx).await?;
361 let status = response.status();
362 if matches!(status.as_u16(), 404 | 410) && !request.redirected(&response) {
366 return Ok(Verification::NotApplied);
367 }
368 let body = request.success_body(response).await?;
369 parse_json(&body)
370 .map(Verification::Confirmed)
371 .map_err(|e| EffectFailure::ambiguous(format!("unreadable lookup: {e}")))
372 })
373 }
374 }
375
376 async fn execute(&self, ctx: &EffectContext) -> Result<Vec<u8>, EffectFailure> {
377 self.execute_with_status(ctx).await.map(|(_, body)| body)
378 }
379
380 async fn execute_with_status(
382 &self,
383 ctx: &EffectContext,
384 ) -> Result<(u16, Vec<u8>), EffectFailure> {
385 let response = self.send_once(ctx).await?;
386 let status = response.status().as_u16();
387 Ok((status, self.success_body(response).await?))
388 }
389
390 fn redirected(&self, response: &reqwest::Response) -> bool {
393 reqwest::Url::parse(&self.url).map_or(true, |url| &url != response.url())
394 }
395
396 async fn send_once(&self, ctx: &EffectContext) -> Result<reqwest::Response, EffectFailure> {
398 if let Some(problem) = &self.invalid {
399 return Err(EffectFailure::validation(problem.clone()));
400 }
401 let mut request = self
402 .client
403 .request(self.method.clone(), &self.url)
404 .headers(self.headers.clone());
405 if let Some(name) = &self.idempotency_header {
406 let key = ctx.idempotency_key().to_string();
407 let value = HeaderValue::try_from(key).map_err(EffectFailure::validation)?;
408 request = request.header(name.clone(), value);
409 }
410 if let Some(body) = &self.body {
411 request = request.body(body.clone());
412 }
413 if let Some(timeout) = self.timeout {
414 request = request.timeout(timeout);
415 }
416 request.send().await.map_err(|e| {
417 if self.no_redirects {
418 classify_error_without_redirects(&e)
419 } else {
420 classify_error(&e)
421 }
422 })
423 }
424
425 async fn success_body(&self, response: reqwest::Response) -> Result<Vec<u8>, EffectFailure> {
427 let status = response.status();
428 let verdict = self
429 .classify
430 .as_ref()
431 .and_then(|classify| classify(status, response.headers()))
432 .unwrap_or_else(|| classify_status(status, response.headers()));
433 let redirected = self.redirected(&response);
434 match verdict {
435 StatusClass::Success => {
436 let body = response.bytes().await.map_err(|e| {
438 EffectFailure::ambiguous(format!(
439 "HTTP {status}, but reading the response failed: {e}"
440 ))
441 })?;
442 Ok(body.to_vec())
443 }
444 StatusClass::Failure(_) if redirected => {
446 let snippet = error_snippet(response).await;
447 Err(EffectFailure::ambiguous(format!(
448 "HTTP {status} after following a redirect: {snippet}"
449 )))
450 }
451 StatusClass::Failure(class) => {
452 let snippet = error_snippet(response).await;
453 Err(EffectFailure::new(
454 class,
455 format!("HTTP {status}: {snippet}"),
456 ))
457 }
458 }
459 }
460}
461
462async fn error_snippet(mut response: reqwest::Response) -> String {
465 let mut body = Vec::new();
466 while body.len() <= ERROR_BODY_LIMIT {
467 match response.chunk().await {
468 Ok(Some(chunk)) => body.extend_from_slice(&chunk),
469 Ok(None) | Err(_) => break,
470 }
471 }
472 let mut snippet = String::from_utf8_lossy(&body).into_owned();
473 if snippet.len() > ERROR_BODY_LIMIT {
474 let cut = (0..=ERROR_BODY_LIMIT)
475 .rev()
476 .find(|&i| snippet.is_char_boundary(i))
477 .unwrap_or(0);
478 snippet.truncate(cut);
479 snippet.push('…');
480 }
481 snippet
482}
483
484#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
486pub struct HttpResponse {
487 pub status: u16,
489 pub body: String,
491}
492
493fn parse_json<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
494 if body.iter().all(u8::is_ascii_whitespace) {
495 serde_json::from_slice(b"null")
496 } else {
497 serde_json::from_slice(body)
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn class(status: u16, headers: &[(&str, &str)]) -> StatusClass {
506 let mut map = HeaderMap::new();
507 for (name, value) in headers {
508 map.insert(
509 HeaderName::try_from(*name).unwrap(),
510 HeaderValue::try_from(*value).unwrap(),
511 );
512 }
513 classify_status(StatusCode::from_u16(status).unwrap(), &map)
514 }
515
516 #[test]
517 fn statuses_map_to_failure_classes() {
518 use FailureClass as F;
519 use StatusClass::{Failure, Success};
520 let table = [
521 (200, Success),
522 (201, Success),
523 (204, Success),
524 (301, Failure(F::Ambiguous)),
525 (303, Failure(F::Ambiguous)),
526 (308, Failure(F::Ambiguous)),
527 (400, Failure(F::Validation)),
528 (401, Failure(F::Authentication)),
529 (403, Failure(F::Authorization)),
530 (404, Failure(F::Permanent)),
531 (408, Failure(F::Transient)),
532 (409, Failure(F::Ambiguous)),
533 (418, Failure(F::Permanent)),
534 (422, Failure(F::Validation)),
535 (425, Failure(F::Transient)),
536 (429, Failure(F::RateLimited { retry_after: None })),
537 (500, Failure(F::Ambiguous)),
538 (501, Failure(F::Permanent)),
539 (502, Failure(F::Ambiguous)),
540 (503, Failure(F::Transient)),
541 (504, Failure(F::Ambiguous)),
542 (599, Failure(F::Ambiguous)),
543 ];
544 for (status, expected) in table {
545 assert_eq!(class(status, &[]), expected, "HTTP {status}");
546 }
547 }
548
549 #[test]
550 fn retry_after_is_honoured_in_seconds_and_as_a_date() {
551 let seven = Some(Duration::from_secs(7));
552 assert_eq!(
553 class(429, &[("retry-after", "7")]),
554 StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
555 );
556 assert_eq!(
557 class(503, &[("retry-after", "7")]),
558 StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
559 );
560 let in_a_minute = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60));
561 let StatusClass::Failure(FailureClass::RateLimited {
562 retry_after: Some(after),
563 }) = class(429, &[("retry-after", &in_a_minute)])
564 else {
565 panic!("expected a rate limit");
566 };
567 assert!(after > Duration::from_secs(55) && after <= Duration::from_secs(60));
568 }
569
570 #[test]
571 fn an_empty_body_reads_as_null() {
572 let unit: () = parse_json(b"").unwrap();
573 assert_eq!(unit, ());
574 let none: Option<u8> = parse_json(b" \n").unwrap();
575 assert_eq!(none, None);
576 }
577}