1use std::future::Future;
71use std::pin::Pin;
72use std::sync::Arc;
73use std::time::{Duration, SystemTime};
74
75use agent_effects::{EffectContext, EffectFailure, FailureClass, Verification};
76use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
77use reqwest::{Client, Method, StatusCode};
78use serde::de::DeserializeOwned;
79use serde::{Deserialize, Serialize};
80
81pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key";
83
84const ERROR_BODY_LIMIT: usize = 512;
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum StatusClass {
90 Success,
92 Failure(FailureClass),
94}
95
96pub fn classify_status(status: StatusCode, headers: &HeaderMap) -> StatusClass {
98 let failure = match status.as_u16() {
99 200..=299 => return StatusClass::Success,
100 300..=399 => FailureClass::Ambiguous,
103 408 | 425 => FailureClass::Transient,
104 429 => FailureClass::RateLimited {
105 retry_after: retry_after(headers),
106 },
107 503 => match retry_after(headers) {
108 Some(after) => FailureClass::RateLimited {
109 retry_after: Some(after),
110 },
111 None => FailureClass::Transient,
112 },
113 400 | 422 => FailureClass::Validation,
114 401 => FailureClass::Authentication,
115 403 => FailureClass::Authorization,
116 409 | 500 | 502 | 504 => FailureClass::Ambiguous,
117 501 => FailureClass::Permanent,
118 500..=599 => FailureClass::Ambiguous,
119 _ => FailureClass::Permanent,
120 };
121 StatusClass::Failure(failure)
122}
123
124fn retry_after(headers: &HeaderMap) -> Option<Duration> {
126 let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
127 if let Ok(seconds) = value.parse::<u64>() {
128 return Some(Duration::from_secs(seconds));
129 }
130 let at = httpdate::parse_http_date(value).ok()?;
131 Some(at.duration_since(SystemTime::now()).unwrap_or_default())
132}
133
134pub fn classify_error(error: &reqwest::Error) -> EffectFailure {
138 let message = error.to_string();
139 if error.is_builder() {
140 EffectFailure::validation(message)
141 } else {
142 EffectFailure::ambiguous(message)
145 }
146}
147
148pub fn classify_error_without_redirects(error: &reqwest::Error) -> EffectFailure {
152 if error.is_connect() {
153 EffectFailure::ambiguous(error.to_string()).request_sent(false)
154 } else {
155 classify_error(error)
156 }
157}
158
159type Call<T> = Pin<Box<dyn Future<Output = Result<T, EffectFailure>> + Send>>;
161
162type StatusOverride = Arc<dyn Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync>;
163
164#[derive(Clone)]
167#[must_use]
168pub struct HttpEffect {
169 client: Client,
170 method: Method,
171 url: String,
172 headers: HeaderMap,
173 body: Option<Vec<u8>>,
174 idempotency_header: Option<HeaderName>,
175 timeout: Option<Duration>,
176 classify: Option<StatusOverride>,
177 no_redirects: bool,
179 invalid: Option<String>,
181}
182
183impl HttpEffect {
184 pub fn new(client: &Client, method: Method, url: impl Into<String>) -> Self {
186 Self {
187 client: client.clone(),
188 method,
189 url: url.into(),
190 headers: HeaderMap::new(),
191 body: None,
192 idempotency_header: None,
193 timeout: None,
194 classify: None,
195 no_redirects: false,
196 invalid: None,
197 }
198 }
199
200 pub fn get(client: &Client, url: impl Into<String>) -> Self {
202 Self::new(client, Method::GET, url)
203 }
204
205 pub fn post(client: &Client, url: impl Into<String>) -> Self {
207 Self::new(client, Method::POST, url)
208 }
209
210 pub fn put(client: &Client, url: impl Into<String>) -> Self {
212 Self::new(client, Method::PUT, url)
213 }
214
215 pub fn patch(client: &Client, url: impl Into<String>) -> Self {
217 Self::new(client, Method::PATCH, url)
218 }
219
220 pub fn delete(client: &Client, url: impl Into<String>) -> Self {
222 Self::new(client, Method::DELETE, url)
223 }
224
225 pub fn header(mut self, name: &str, value: &str) -> Self {
228 match (HeaderName::try_from(name), HeaderValue::try_from(value)) {
229 (Ok(name), Ok(value)) => {
230 self.headers.append(name, value);
231 }
232 _ => self.invalid = Some(format!("invalid header `{name}`")),
233 }
234 self
235 }
236
237 pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
239 match serde_json::to_vec(body) {
240 Ok(bytes) => {
241 self.body = Some(bytes);
242 self.headers
243 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
244 }
245 Err(e) => self.invalid = Some(format!("body is not JSON-serializable: {e}")),
246 }
247 self
248 }
249
250 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
252 self.body = Some(body.into());
253 self
254 }
255
256 pub fn idempotency_header(mut self, name: &str) -> Self {
261 match HeaderName::try_from(name) {
262 Ok(name) => self.idempotency_header = Some(name),
263 Err(_) => self.invalid = Some(format!("invalid header `{name}`")),
264 }
265 self
266 }
267
268 pub fn idempotency_key_header(self) -> Self {
270 self.idempotency_header(IDEMPOTENCY_KEY)
271 }
272
273 pub fn timeout(mut self, timeout: Duration) -> Self {
276 self.timeout = Some(timeout);
277 self
278 }
279
280 pub fn client_follows_no_redirects(mut self) -> Self {
288 self.no_redirects = true;
289 self
290 }
291
292 pub fn classify_status(
296 mut self,
297 classify: impl Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync + 'static,
298 ) -> Self {
299 self.classify = Some(Arc::new(classify));
300 self
301 }
302
303 pub fn send_json<T: DeserializeOwned + Send + 'static>(
306 self,
307 ) -> impl Fn(EffectContext) -> Call<T> + Send + Sync + 'static {
308 let request = Arc::new(self);
309 move |ctx| {
310 let request = Arc::clone(&request);
311 Box::pin(async move {
312 let body = request.execute(&ctx).await?;
313 parse_json(&body).map_err(|e| {
314 EffectFailure::ambiguous(format!(
315 "the request succeeded but its response could not be read: {e}"
316 ))
317 })
318 })
319 }
320 }
321
322 pub fn send(self) -> impl Fn(EffectContext) -> Call<HttpResponse> + Send + Sync + 'static {
325 let request = Arc::new(self);
326 move |ctx| {
327 let request = Arc::clone(&request);
328 Box::pin(async move {
329 let (status, body) = request.execute_with_status(&ctx).await?;
330 Ok(HttpResponse {
331 status,
332 body: String::from_utf8_lossy(&body).into_owned(),
333 })
334 })
335 }
336 }
337
338 pub fn verify_json<T: DeserializeOwned + Send + 'static>(
345 self,
346 ) -> impl Fn(EffectContext) -> Call<Verification<T>> + Send + Sync + 'static {
347 let request = Arc::new(self);
348 move |ctx| {
349 let request = Arc::clone(&request);
350 Box::pin(async move {
351 let response = request.send_once(&ctx).await?;
352 let status = response.status();
353 if matches!(status.as_u16(), 404 | 410) {
356 return Ok(Verification::NotApplied);
357 }
358 let body = request.success_body(response).await?;
359 parse_json(&body)
360 .map(Verification::Confirmed)
361 .map_err(|e| EffectFailure::ambiguous(format!("unreadable lookup: {e}")))
362 })
363 }
364 }
365
366 async fn execute(&self, ctx: &EffectContext) -> Result<Vec<u8>, EffectFailure> {
367 self.execute_with_status(ctx).await.map(|(_, body)| body)
368 }
369
370 async fn execute_with_status(
372 &self,
373 ctx: &EffectContext,
374 ) -> Result<(u16, Vec<u8>), EffectFailure> {
375 let response = self.send_once(ctx).await?;
376 let status = response.status().as_u16();
377 Ok((status, self.success_body(response).await?))
378 }
379
380 async fn send_once(&self, ctx: &EffectContext) -> Result<reqwest::Response, EffectFailure> {
382 if let Some(problem) = &self.invalid {
383 return Err(EffectFailure::validation(problem.clone()));
384 }
385 let mut request = self
386 .client
387 .request(self.method.clone(), &self.url)
388 .headers(self.headers.clone());
389 if let Some(name) = &self.idempotency_header {
390 let key = ctx.idempotency_key().to_string();
391 let value = HeaderValue::try_from(key).map_err(EffectFailure::validation)?;
392 request = request.header(name.clone(), value);
393 }
394 if let Some(body) = &self.body {
395 request = request.body(body.clone());
396 }
397 if let Some(timeout) = self.timeout {
398 request = request.timeout(timeout);
399 }
400 request.send().await.map_err(|e| {
401 if self.no_redirects {
402 classify_error_without_redirects(&e)
403 } else {
404 classify_error(&e)
405 }
406 })
407 }
408
409 async fn success_body(&self, response: reqwest::Response) -> Result<Vec<u8>, EffectFailure> {
411 let status = response.status();
412 let verdict = self
413 .classify
414 .as_ref()
415 .and_then(|classify| classify(status, response.headers()))
416 .unwrap_or_else(|| classify_status(status, response.headers()));
417 match verdict {
418 StatusClass::Success => {
419 let body = response.bytes().await.map_err(|e| {
421 EffectFailure::ambiguous(format!(
422 "HTTP {status}, but reading the response failed: {e}"
423 ))
424 })?;
425 Ok(body.to_vec())
426 }
427 StatusClass::Failure(class) => {
428 let body = response.bytes().await.unwrap_or_default();
429 let mut snippet = String::from_utf8_lossy(&body).into_owned();
430 if snippet.len() > ERROR_BODY_LIMIT {
431 let cut = (0..=ERROR_BODY_LIMIT)
432 .rev()
433 .find(|&i| snippet.is_char_boundary(i))
434 .unwrap_or(0);
435 snippet.truncate(cut);
436 snippet.push('…');
437 }
438 Err(EffectFailure::new(
439 class,
440 format!("HTTP {status}: {snippet}"),
441 ))
442 }
443 }
444 }
445}
446
447#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
449pub struct HttpResponse {
450 pub status: u16,
452 pub body: String,
454}
455
456fn parse_json<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
457 if body.iter().all(u8::is_ascii_whitespace) {
458 serde_json::from_slice(b"null")
459 } else {
460 serde_json::from_slice(body)
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn class(status: u16, headers: &[(&str, &str)]) -> StatusClass {
469 let mut map = HeaderMap::new();
470 for (name, value) in headers {
471 map.insert(
472 HeaderName::try_from(*name).unwrap(),
473 HeaderValue::try_from(*value).unwrap(),
474 );
475 }
476 classify_status(StatusCode::from_u16(status).unwrap(), &map)
477 }
478
479 #[test]
480 fn statuses_map_to_failure_classes() {
481 use FailureClass as F;
482 use StatusClass::{Failure, Success};
483 let table = [
484 (200, Success),
485 (201, Success),
486 (204, Success),
487 (301, Failure(F::Ambiguous)),
488 (303, Failure(F::Ambiguous)),
489 (308, Failure(F::Ambiguous)),
490 (400, Failure(F::Validation)),
491 (401, Failure(F::Authentication)),
492 (403, Failure(F::Authorization)),
493 (404, Failure(F::Permanent)),
494 (408, Failure(F::Transient)),
495 (409, Failure(F::Ambiguous)),
496 (418, Failure(F::Permanent)),
497 (422, Failure(F::Validation)),
498 (425, Failure(F::Transient)),
499 (429, Failure(F::RateLimited { retry_after: None })),
500 (500, Failure(F::Ambiguous)),
501 (501, Failure(F::Permanent)),
502 (502, Failure(F::Ambiguous)),
503 (503, Failure(F::Transient)),
504 (504, Failure(F::Ambiguous)),
505 (599, Failure(F::Ambiguous)),
506 ];
507 for (status, expected) in table {
508 assert_eq!(class(status, &[]), expected, "HTTP {status}");
509 }
510 }
511
512 #[test]
513 fn retry_after_is_honoured_in_seconds_and_as_a_date() {
514 let seven = Some(Duration::from_secs(7));
515 assert_eq!(
516 class(429, &[("retry-after", "7")]),
517 StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
518 );
519 assert_eq!(
520 class(503, &[("retry-after", "7")]),
521 StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
522 );
523 let in_a_minute = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60));
524 let StatusClass::Failure(FailureClass::RateLimited {
525 retry_after: Some(after),
526 }) = class(429, &[("retry-after", &in_a_minute)])
527 else {
528 panic!("expected a rate limit");
529 };
530 assert!(after > Duration::from_secs(55) && after <= Duration::from_secs(60));
531 }
532
533 #[test]
534 fn an_empty_body_reads_as_null() {
535 let unit: () = parse_json(b"").unwrap();
536 assert_eq!(unit, ());
537 let none: Option<u8> = parse_json(b" \n").unwrap();
538 assert_eq!(none, None);
539 }
540}