Skip to main content

hclient_core/
body.rs

1use bytes::Bytes;
2use std::fmt::Debug;
3use std::sync::Arc;
4
5/// Whether this body can be replayed — known **before** sending.
6///
7/// `reqwest::Request::try_clone() -> Option<Request>` answers the same
8/// question after the retry layer has already decided to retry, and so
9/// silently disables retries on streaming bodies.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum RetryKind {
12    /// Replays for free.
13    Free,
14    /// Replays by calling the factory.
15    ViaFactory,
16    /// Cannot be replayed.
17    Impossible,
18}
19
20/// `Send + Sync` bounds — a documented exception to the crate invariant
21/// "declare `Send`/`Sync` nowhere" (spec amendment-C2, sibling of C1 on
22/// [`crate::Error`]). Without them `RequestBody` would be `!Send`, so
23/// `http::Request<RequestBody>` would be `!Send`, so the future
24/// `Transport::execute` returns would be `!Send` for every backend —
25/// `tokio::spawn(client.get(u).send())` would never build. `Sync` is only
26/// needed here, for `Arc`: `Arc<T>: Send` requires `T: Send + Sync`, whereas
27/// `Box<T>: Send` (see [`RequestBody::Streaming`]) requires only `T: Send`.
28pub type RewindFactory = Arc<dyn Fn() -> RequestBody + Send + Sync>; // send-bound-exception: amendment-C2
29
30/// A request body with an explicit replay contract.
31#[derive(Default)]
32pub enum RequestBody {
33    #[default]
34    Empty,
35    Full(Bytes),
36    /// Replays by calling the factory.
37    ///
38    /// **Factory contract.** It must be pure: every call must produce a body
39    /// equivalent to the previous one (same content, same size). A factory
40    /// with hidden state that hands back a different body on each call is a
41    /// bug source that's obvious in hindsight but undocumented otherwise,
42    /// which is why `size_hint()` deliberately returns `None` for this
43    /// variant: guessing from the first call is dangerous if the contract
44    /// is violated.
45    ///
46    /// The factory is legally allowed to return `RequestBody::Streaming` —
47    /// that isn't a live lie, because `retry_kind()` and `rewind()` are
48    /// always recomputed from whatever object currently sits inside
49    /// `RequestBody`, not cached at the moment `Rewindable` was created.
50    /// **Invariant that matters for the retry layer: always ask
51    /// `retry_kind()` of the body you're currently holding, and never cache
52    /// it across a `rewind()`.**
53    Rewindable(RewindFactory),
54    /// A single-pass body. The concrete stream is set by the transport; in
55    /// v0.1 the core only needs to know it can't be replayed.
56    ///
57    /// `+ Send` — the same C2 exception as [`RewindFactory`]: `Box<T>: Send`
58    /// requires only `T: Send`, `Sync` isn't needed here.
59    Streaming(Box<dyn http_body::Body<Data = Bytes, Error = crate::Error> + Unpin + Send>), // send-bound-exception: amendment-C2
60}
61
62impl Debug for RequestBody {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            RequestBody::Empty => f.write_str("Empty"),
66            RequestBody::Full(b) => write!(f, "Full({} bytes)", b.len()),
67            RequestBody::Rewindable(_) => f.write_str("Rewindable(..)"),
68            RequestBody::Streaming(_) => f.write_str("Streaming(..)"),
69        }
70    }
71}
72
73impl RequestBody {
74    pub fn rewindable<F>(f: F) -> Self
75    where
76        F: Fn() -> RequestBody + Send + Sync + 'static, // send-bound-exception: amendment-C2
77    {
78        RequestBody::Rewindable(Arc::new(f))
79    }
80
81    pub fn retry_kind(&self) -> RetryKind {
82        match self {
83            RequestBody::Empty | RequestBody::Full(_) => RetryKind::Free,
84            RequestBody::Rewindable(_) => RetryKind::ViaFactory,
85            RequestBody::Streaming(_) => RetryKind::Impossible,
86        }
87    }
88
89    pub fn rewind(&self) -> Option<RequestBody> {
90        match self {
91            RequestBody::Empty => Some(RequestBody::Empty),
92            RequestBody::Full(b) => Some(RequestBody::Full(b.clone())),
93            RequestBody::Rewindable(f) => Some(f()),
94            RequestBody::Streaming(_) => None,
95        }
96    }
97
98    pub fn size_hint(&self) -> Option<u64> {
99        match self {
100            RequestBody::Empty => Some(0),
101            RequestBody::Full(b) => Some(b.len() as u64),
102            _ => None,
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use bytes::Bytes;
111    use std::pin::Pin;
112    use std::task::Context;
113    use std::task::Poll;
114
115    #[test]
116    fn replayability_is_knowable_before_sending() {
117        assert_eq!(RequestBody::Empty.retry_kind(), RetryKind::Free);
118        assert_eq!(
119            RequestBody::Full(Bytes::from_static(b"x")).retry_kind(),
120            RetryKind::Free
121        );
122    }
123
124    #[test]
125    fn rewindable_replays_through_factory() {
126        let b = RequestBody::rewindable(|| RequestBody::Full(Bytes::from_static(b"same")));
127        assert_eq!(b.retry_kind(), RetryKind::ViaFactory);
128        let again = b.rewind().expect("rewindable must rewind");
129        assert!(matches!(again, RequestBody::Full(ref x) if &x[..] == b"same"));
130    }
131
132    #[test]
133    fn full_rewind_preserves_the_payload() {
134        let b = RequestBody::Full(Bytes::from_static(b"abc"));
135        match b.rewind().expect("Full replays") {
136            RequestBody::Full(x) => assert_eq!(&x[..], b"abc"),
137            other => panic!("expected Full, got {other:?}"),
138        }
139    }
140
141    #[test]
142    fn a_factory_survives_repeated_replays() {
143        use std::sync::atomic::{AtomicUsize, Ordering};
144        let calls = Arc::new(AtomicUsize::new(0));
145        let c = calls.clone();
146        let b = RequestBody::rewindable(move || {
147            c.fetch_add(1, Ordering::SeqCst);
148            RequestBody::Full(Bytes::from_static(b"same"))
149        });
150        for _ in 0..3 {
151            let again = b.rewind().expect("rewindable replays");
152            assert!(matches!(again, RequestBody::Full(ref x) if &x[..] == b"same"));
153            assert_eq!(
154                b.retry_kind(),
155                RetryKind::ViaFactory,
156                "kind doesn't change across replays"
157            );
158        }
159        assert_eq!(calls.load(Ordering::SeqCst), 3);
160    }
161
162    /// The `Empty`/`Full` pair are the only variants whose size is known
163    /// ahead of time. `Rewindable` and `Streaming` are covered separately
164    /// (`rewindable_replays_through_factory`,
165    /// `streaming_is_honest_about_being_unreplayable`) and aren't included
166    /// here — the test's name shouldn't promise coverage it doesn't have.
167    #[test]
168    fn size_hint_is_known_for_empty_and_full_bodies() {
169        assert_eq!(RequestBody::Empty.size_hint(), Some(0));
170        assert_eq!(
171            RequestBody::Full(Bytes::from_static(b"abcd")).size_hint(),
172            Some(4)
173        );
174    }
175
176    /// A body with not a single byte in its buffer: `poll_frame` returns
177    /// `Ready(None)` immediately. Needed only to construct
178    /// `RequestBody::Streaming` in tests — the concrete transport supplies
179    /// its own implementation.
180    struct EmptyStream;
181    impl http_body::Body for EmptyStream {
182        type Data = Bytes;
183        type Error = crate::Error;
184        fn poll_frame(
185            self: Pin<&mut Self>,
186            _: &mut Context<'_>,
187        ) -> Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
188            Poll::Ready(None)
189        }
190    }
191
192    #[test]
193    fn streaming_is_honest_about_being_unreplayable() {
194        let b = RequestBody::Streaming(Box::new(EmptyStream));
195        assert_eq!(b.retry_kind(), RetryKind::Impossible);
196        assert!(b.rewind().is_none(), "must return None, not panic");
197        assert_eq!(b.size_hint(), None);
198    }
199
200    // `RequestBody: Send` and `http::Request<RequestBody>: Send`
201    // (amendment-C2) are asserted in `crates/hclient-core/tests/shape.rs`,
202    // for `error.rs`'s reason: a bare `fn assert_send<T: Send>() {}` inside
203    // `src` is what the `no-declared-send` guard's regex matches, and an
204    // assertion outside `src` needs no exception marker at all.
205}