use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use agent_effects::{EffectContext, EffectFailure, FailureClass, Verification};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER};
use reqwest::{Client, Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key";
const ERROR_BODY_LIMIT: usize = 512;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusClass {
Success,
Failure(FailureClass),
}
pub fn classify_status(status: StatusCode, headers: &HeaderMap) -> StatusClass {
let failure = match status.as_u16() {
200..=299 => return StatusClass::Success,
300..=399 => FailureClass::Ambiguous,
408 | 425 => FailureClass::Transient,
429 => FailureClass::RateLimited {
retry_after: retry_after(headers),
},
503 => match retry_after(headers) {
Some(after) => FailureClass::RateLimited {
retry_after: Some(after),
},
None => FailureClass::Transient,
},
400 | 422 => FailureClass::Validation,
401 => FailureClass::Authentication,
403 => FailureClass::Authorization,
409 | 500 | 502 | 504 => FailureClass::Ambiguous,
501 => FailureClass::Permanent,
500..=599 => FailureClass::Ambiguous,
_ => FailureClass::Permanent,
};
StatusClass::Failure(failure)
}
fn retry_after(headers: &HeaderMap) -> Option<Duration> {
let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
if let Ok(seconds) = value.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
let at = httpdate::parse_http_date(value).ok()?;
Some(at.duration_since(SystemTime::now()).unwrap_or_default())
}
pub fn classify_error(error: &reqwest::Error) -> EffectFailure {
let message = error.to_string();
if error.is_builder() {
EffectFailure::validation(message)
} else {
EffectFailure::ambiguous(message)
}
}
pub fn classify_error_without_redirects(error: &reqwest::Error) -> EffectFailure {
if error.is_connect() {
EffectFailure::ambiguous(error.to_string()).request_sent(false)
} else {
classify_error(error)
}
}
type Call<T> = Pin<Box<dyn Future<Output = Result<T, EffectFailure>> + Send>>;
type StatusOverride = Arc<dyn Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync>;
#[derive(Clone)]
#[must_use]
pub struct HttpEffect {
client: Client,
method: Method,
url: String,
headers: HeaderMap,
body: Option<Vec<u8>>,
idempotency_header: Option<HeaderName>,
timeout: Option<Duration>,
classify: Option<StatusOverride>,
no_redirects: bool,
invalid: Option<String>,
}
impl HttpEffect {
pub fn new(client: &Client, method: Method, url: impl Into<String>) -> Self {
Self {
client: client.clone(),
method,
url: url.into(),
headers: HeaderMap::new(),
body: None,
idempotency_header: None,
timeout: None,
classify: None,
no_redirects: false,
invalid: None,
}
}
pub fn get(client: &Client, url: impl Into<String>) -> Self {
Self::new(client, Method::GET, url)
}
pub fn post(client: &Client, url: impl Into<String>) -> Self {
Self::new(client, Method::POST, url)
}
pub fn put(client: &Client, url: impl Into<String>) -> Self {
Self::new(client, Method::PUT, url)
}
pub fn patch(client: &Client, url: impl Into<String>) -> Self {
Self::new(client, Method::PATCH, url)
}
pub fn delete(client: &Client, url: impl Into<String>) -> Self {
Self::new(client, Method::DELETE, url)
}
pub fn header(mut self, name: &str, value: &str) -> Self {
match (HeaderName::try_from(name), HeaderValue::try_from(value)) {
(Ok(name), Ok(value)) => {
self.headers.append(name, value);
}
_ => self.invalid = Some(format!("invalid header `{name}`")),
}
self
}
pub fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Self {
match serde_json::to_vec(body) {
Ok(bytes) => {
self.body = Some(bytes);
self.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
}
Err(e) => self.invalid = Some(format!("body is not JSON-serializable: {e}")),
}
self
}
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = Some(body.into());
self
}
pub fn idempotency_header(mut self, name: &str) -> Self {
match HeaderName::try_from(name) {
Ok(name) => self.idempotency_header = Some(name),
Err(_) => self.invalid = Some(format!("invalid header `{name}`")),
}
self
}
pub fn idempotency_key_header(self) -> Self {
self.idempotency_header(IDEMPOTENCY_KEY)
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn client_follows_no_redirects(mut self) -> Self {
self.no_redirects = true;
self
}
pub fn classify_status(
mut self,
classify: impl Fn(StatusCode, &HeaderMap) -> Option<StatusClass> + Send + Sync + 'static,
) -> Self {
self.classify = Some(Arc::new(classify));
self
}
pub fn send_json<T: DeserializeOwned + Send + 'static>(
self,
) -> impl Fn(EffectContext) -> Call<T> + Send + Sync + 'static {
let request = Arc::new(self);
move |ctx| {
let request = Arc::clone(&request);
Box::pin(async move {
let body = request.execute(&ctx).await?;
parse_json(&body).map_err(|e| {
EffectFailure::ambiguous(format!(
"the request succeeded but its response could not be read: {e}"
))
})
})
}
}
pub fn send(self) -> impl Fn(EffectContext) -> Call<HttpResponse> + Send + Sync + 'static {
let request = Arc::new(self);
move |ctx| {
let request = Arc::clone(&request);
Box::pin(async move {
let (status, body) = request.execute_with_status(&ctx).await?;
Ok(HttpResponse {
status,
body: String::from_utf8_lossy(&body).into_owned(),
})
})
}
}
pub fn verify_json<T: DeserializeOwned + Send + 'static>(
self,
) -> impl Fn(EffectContext) -> Call<Verification<T>> + Send + Sync + 'static {
let request = Arc::new(self);
move |ctx| {
let request = Arc::clone(&request);
Box::pin(async move {
let response = request.send_once(&ctx).await?;
let status = response.status();
if matches!(status.as_u16(), 404 | 410) {
return Ok(Verification::NotApplied);
}
let body = request.success_body(response).await?;
parse_json(&body)
.map(Verification::Confirmed)
.map_err(|e| EffectFailure::ambiguous(format!("unreadable lookup: {e}")))
})
}
}
async fn execute(&self, ctx: &EffectContext) -> Result<Vec<u8>, EffectFailure> {
self.execute_with_status(ctx).await.map(|(_, body)| body)
}
async fn execute_with_status(
&self,
ctx: &EffectContext,
) -> Result<(u16, Vec<u8>), EffectFailure> {
let response = self.send_once(ctx).await?;
let status = response.status().as_u16();
Ok((status, self.success_body(response).await?))
}
async fn send_once(&self, ctx: &EffectContext) -> Result<reqwest::Response, EffectFailure> {
if let Some(problem) = &self.invalid {
return Err(EffectFailure::validation(problem.clone()));
}
let mut request = self
.client
.request(self.method.clone(), &self.url)
.headers(self.headers.clone());
if let Some(name) = &self.idempotency_header {
let key = ctx.idempotency_key().to_string();
let value = HeaderValue::try_from(key).map_err(EffectFailure::validation)?;
request = request.header(name.clone(), value);
}
if let Some(body) = &self.body {
request = request.body(body.clone());
}
if let Some(timeout) = self.timeout {
request = request.timeout(timeout);
}
request.send().await.map_err(|e| {
if self.no_redirects {
classify_error_without_redirects(&e)
} else {
classify_error(&e)
}
})
}
async fn success_body(&self, response: reqwest::Response) -> Result<Vec<u8>, EffectFailure> {
let status = response.status();
let verdict = self
.classify
.as_ref()
.and_then(|classify| classify(status, response.headers()))
.unwrap_or_else(|| classify_status(status, response.headers()));
match verdict {
StatusClass::Success => {
let body = response.bytes().await.map_err(|e| {
EffectFailure::ambiguous(format!(
"HTTP {status}, but reading the response failed: {e}"
))
})?;
Ok(body.to_vec())
}
StatusClass::Failure(class) => {
let body = response.bytes().await.unwrap_or_default();
let mut snippet = String::from_utf8_lossy(&body).into_owned();
if snippet.len() > ERROR_BODY_LIMIT {
let cut = (0..=ERROR_BODY_LIMIT)
.rev()
.find(|&i| snippet.is_char_boundary(i))
.unwrap_or(0);
snippet.truncate(cut);
snippet.push('…');
}
Err(EffectFailure::new(
class,
format!("HTTP {status}: {snippet}"),
))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpResponse {
pub status: u16,
pub body: String,
}
fn parse_json<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
if body.iter().all(u8::is_ascii_whitespace) {
serde_json::from_slice(b"null")
} else {
serde_json::from_slice(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn class(status: u16, headers: &[(&str, &str)]) -> StatusClass {
let mut map = HeaderMap::new();
for (name, value) in headers {
map.insert(
HeaderName::try_from(*name).unwrap(),
HeaderValue::try_from(*value).unwrap(),
);
}
classify_status(StatusCode::from_u16(status).unwrap(), &map)
}
#[test]
fn statuses_map_to_failure_classes() {
use FailureClass as F;
use StatusClass::{Failure, Success};
let table = [
(200, Success),
(201, Success),
(204, Success),
(301, Failure(F::Ambiguous)),
(303, Failure(F::Ambiguous)),
(308, Failure(F::Ambiguous)),
(400, Failure(F::Validation)),
(401, Failure(F::Authentication)),
(403, Failure(F::Authorization)),
(404, Failure(F::Permanent)),
(408, Failure(F::Transient)),
(409, Failure(F::Ambiguous)),
(418, Failure(F::Permanent)),
(422, Failure(F::Validation)),
(425, Failure(F::Transient)),
(429, Failure(F::RateLimited { retry_after: None })),
(500, Failure(F::Ambiguous)),
(501, Failure(F::Permanent)),
(502, Failure(F::Ambiguous)),
(503, Failure(F::Transient)),
(504, Failure(F::Ambiguous)),
(599, Failure(F::Ambiguous)),
];
for (status, expected) in table {
assert_eq!(class(status, &[]), expected, "HTTP {status}");
}
}
#[test]
fn retry_after_is_honoured_in_seconds_and_as_a_date() {
let seven = Some(Duration::from_secs(7));
assert_eq!(
class(429, &[("retry-after", "7")]),
StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
);
assert_eq!(
class(503, &[("retry-after", "7")]),
StatusClass::Failure(FailureClass::RateLimited { retry_after: seven })
);
let in_a_minute = httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(60));
let StatusClass::Failure(FailureClass::RateLimited {
retry_after: Some(after),
}) = class(429, &[("retry-after", &in_a_minute)])
else {
panic!("expected a rate limit");
};
assert!(after > Duration::from_secs(55) && after <= Duration::from_secs(60));
}
#[test]
fn an_empty_body_reads_as_null() {
let unit: () = parse_json(b"").unwrap();
assert_eq!(unit, ());
let none: Option<u8> = parse_json(b" \n").unwrap();
assert_eq!(none, None);
}
}