Skip to main content

agent_effects_http/
lib.rs

1//! HTTP effects for [`agent-effects`](https://crates.io/crates/agent-effects).
2//!
3//! [`HttpEffect`] builds an effect's action from an HTTP request: it sends
4//! the effect's stable idempotency key in a header, and classifies every
5//! failure so the runtime knows whether the request may have applied.
6//!
7//! ```no_run
8//! use agent_effects::{EffectKind, Runtime};
9//! use agent_effects_http::HttpEffect;
10//! use agent_effects_memory::MemoryStore;
11//!
12//! #[derive(serde::Serialize)]
13//! struct Charge { amount: u64, currency: &'static str }
14//! #[derive(serde::Serialize, serde::Deserialize)]
15//! struct Payment { id: String }
16//!
17//! # async fn demo() -> Result<(), agent_effects::RuntimeError> {
18//! let client = reqwest::Client::new();
19//! let runtime = Runtime::new(MemoryStore::new());
20//! let outcome = runtime
21//!     .effect("payment.charge", "order-42")
22//!     .kind(EffectKind::IrreversibleWrite)
23//!     .remote_idempotency(true) // the provider honours Idempotency-Key
24//!     .run(
25//!         HttpEffect::post(&client, "https://api.example.com/v1/charges")
26//!             .json(&Charge { amount: 4200, currency: "eur" })
27//!             .idempotency_key_header()
28//!             .send_json::<Payment>(),
29//!     )
30//!     .await?;
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! The crate enables no reqwest features: TLS and the rest come from your
36//! own `reqwest` dependency and the [`reqwest::Client`] you pass in.
37//!
38//! # How failures are classified
39//!
40//! A failure is only definite when the request provably never reached the
41//! server, or the server's answer says it did not apply. Everything in
42//! between is ambiguous, and the runtime then verifies, re-runs only if
43//! safe, or escalates.
44//!
45//! With a declared no-redirect client, response statuses map as follows.
46//! Otherwise every failure response is ambiguous.
47//!
48//! | Outcome | Class |
49//! |---|---|
50//! | connect, DNS or TLS failure | `Ambiguous`; `Transient` (never sent) only with [`HttpEffect::client_follows_no_redirects`] |
51//! | invalid request (bad URL or header) | `Validation` (never sent) |
52//! | timeout or connection lost after sending | `Ambiguous` |
53//! | 2xx with an unreadable or unparseable body | `Ambiguous` (it applied, the answer was lost) |
54//! | 3xx (seen only when the client does not follow redirects) | `Ambiguous` |
55//! | any failing status after the client followed a redirect | `Ambiguous` (it answers for the redirect's target) |
56//! | 408, 425, 429, 503 (including `Retry-After`) | `Ambiguous` |
57//! | 400, 422 | `Validation` |
58//! | 401 | `Authentication` |
59//! | 403 | `Authorization` |
60//! | 409, 500, 502, 504, other 5xx | `Ambiguous` |
61//! | 501, other 4xx | `Permanent` |
62//!
63//! `Retry-After` is a minimum delay for an otherwise safe retry, never proof
64//! of non-application. Provider-specific response semantics must establish
65//! that a write was rejected before application before an override can
66//! classify it as `Transient` or `RateLimited`. Override per request with
67//! [`HttpEffect::classify_status`].
68//!
69//! A client that follows redirects (reqwest's default) can fail to connect
70//! to a redirect's target after the original request applied, and reqwest
71//! reports that exactly like a failure to reach the original server. So a
72//! connection failure only proves the request was never sent when the
73//! client does not follow redirects: build the client with
74//! `.redirect(reqwest::redirect::Policy::none())` and say so with
75//! [`HttpEffect::client_follows_no_redirects`].
76//!
77//! The same goes for statuses: after a redirect, the status is the
78//! target's, so a failing one says nothing about the original request and
79//! is ambiguous. reqwest keeps no redirect history: comparing URLs cannot
80//! detect a redirect back to the original URL. Consequently every failing
81//! response remains ambiguous unless the application declares a client that
82//! follows no redirects. The status table's definite failures and negative
83//! verification lookups require that declaration too.
84
85use 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
96/// The conventional idempotency header.
97pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key";
98
99/// At most this much of an error response's body is kept in the message.
100const ERROR_BODY_LIMIT: usize = 512;
101
102/// What a response status means.
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub enum StatusClass {
105    /// The request succeeded; read the body.
106    Success,
107    /// The request failed with this class.
108    Failure(FailureClass),
109}
110
111/// The default meaning of `status`; see the [crate docs](crate).
112pub fn classify_status(status: StatusCode, _headers: &HeaderMap) -> StatusClass {
113    let failure = match status.as_u16() {
114        200..=299 => return StatusClass::Success,
115        // A redirect the client did not follow: whether the request
116        // applied before it is unknown.
117        300..=399 => FailureClass::Ambiguous,
118        // A gateway or application can return these after a write. Neither
119        // the status nor Retry-After proves non-application.
120        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
132/// `Retry-After` as a delay: seconds, or an HTTP date.
133fn 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
142/// The class of a request that failed without a response, from a client
143/// that may follow redirects: everything but an invalid request is
144/// ambiguous, a connection failure included (see the [crate docs](crate)).
145pub 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        // Timed out or lost after sending, the response broke midway, or a
151        // redirect's target was unreachable after the request applied.
152        EffectFailure::ambiguous(message)
153    }
154}
155
156/// Like [`classify_error`], for a client that does not follow redirects: a
157/// connection failure (including a connect timeout) means nothing reached
158/// the server, so the request was never sent.
159pub 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
167/// The future an action or verification built here returns.
168type 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/// An HTTP request as an effect's action. Build it, then turn it into an
173/// action with [`Self::send_json`] or [`Self::send`].
174#[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    /// The caller declared that the client does not follow redirects.
186    no_redirects: bool,
187    /// A builder mistake, reported as a `Validation` failure when run.
188    invalid: Option<String>,
189}
190
191impl HttpEffect {
192    /// A request with `method` to `url`.
193    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    /// A `GET` request.
209    pub fn get(client: &Client, url: impl Into<String>) -> Self {
210        Self::new(client, Method::GET, url)
211    }
212
213    /// A `POST` request.
214    pub fn post(client: &Client, url: impl Into<String>) -> Self {
215        Self::new(client, Method::POST, url)
216    }
217
218    /// A `PUT` request.
219    pub fn put(client: &Client, url: impl Into<String>) -> Self {
220        Self::new(client, Method::PUT, url)
221    }
222
223    /// A `PATCH` request.
224    pub fn patch(client: &Client, url: impl Into<String>) -> Self {
225        Self::new(client, Method::PATCH, url)
226    }
227
228    /// A `DELETE` request.
229    pub fn delete(client: &Client, url: impl Into<String>) -> Self {
230        Self::new(client, Method::DELETE, url)
231    }
232
233    /// Adds a header. Credentials belong here or in the client, not in the
234    /// effect's input.
235    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    /// Sends `body` as JSON.
246    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    /// Sends `body` as is.
259    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
260        self.body = Some(body.into());
261        self
262    }
263
264    /// Sends the effect's idempotency key in header `name` on every attempt.
265    /// The key is the same across attempts, so a server that honours it
266    /// applies the request once. Pair with
267    /// [`EffectBuilder::remote_idempotency`](agent_effects::EffectBuilder::remote_idempotency).
268    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    /// Sends the idempotency key as `Idempotency-Key`.
277    pub fn idempotency_key_header(self) -> Self {
278        self.idempotency_header(IDEMPOTENCY_KEY)
279    }
280
281    /// Gives up on one request after `timeout`: an ambiguous failure, since
282    /// it may have been sent.
283    pub fn timeout(mut self, timeout: Duration) -> Self {
284        self.timeout = Some(timeout);
285        self
286    }
287
288    /// Declares that the client does not follow redirects: it was built
289    /// with `.redirect(reqwest::redirect::Policy::none())`. Only then does a
290    /// connection failure prove the request was never sent, so that it is
291    /// retried as `Transient`, even for an irreversible write. Without it,
292    /// a connection failure is ambiguous. This does not change the client:
293    /// declaring it for a client that does follow redirects can duplicate
294    /// effects.
295    pub fn client_follows_no_redirects(mut self) -> Self {
296        self.no_redirects = true;
297        self
298    }
299
300    /// Overrides how statuses are classified: return `Some` to decide,
301    /// `None` to fall back to [`classify_status`]. For APIs with their own
302    /// conventions, e.g. a 409 that means "already done". A failure after
303    /// a followed redirect is still ambiguous (see the [crate docs](crate)).
304    /// `Transient`, `RateLimited`, and definitive failure classes assert
305    /// that the operation did not apply: use only trusted provider semantics.
306    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    /// The action: sends the request and parses a successful response as
315    /// JSON `T`. An empty body reads as JSON `null`, e.g. for `T = ()`.
316    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    /// The action: sends the request and returns the successful response's
334    /// status and body as text.
335    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    /// A verification: a lookup whose 2xx response, parsed as JSON `T`,
350    /// confirms the effect, and whose 404 or 410 (only those) says it did not
351    /// apply when a no-redirect client is declared with
352    /// [`Self::client_follows_no_redirects`]. Any other status or error is a failed check, which the runtime
353    /// treats as inconclusive: a broken lookup must never be read as "not
354    /// applied". Pass it to `.verify(..)` if the lookup reads its own
355    /// writes, else to `.verify_eventually(settle, ..)`.
356    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                // Only "not found" proves the effect did not apply; every
366                // other failure is a check that could not tell. So is a
367                // "not found" for a redirect's target.
368                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    /// Sends one attempt; returns the status and body of a success.
387    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    /// Whether the client followed a redirect to reach `response`: its
397    /// status then answers for the target, not for this request.
398    fn redirected(&self, response: &reqwest::Response) -> bool {
399        reqwest::Url::parse(&self.url).map_or(true, |url| &url != response.url())
400    }
401
402    /// Sends the request once. Fails only if no response arrived.
403    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    /// The body of a successful response, or the classified failure.
432    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                // It applied; losing the body now makes the answer unknown.
444                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            // The request may have applied before the redirect.
452            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
479/// The start of an error response's body, reading no more of it than the
480/// message keeps: the body may be huge, or never end.
481async 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/// A successful response, as stored and replayed.
502#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
503pub struct HttpResponse {
504    /// The status code.
505    pub status: u16,
506    /// The body, as text (invalid UTF-8 replaced).
507    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}