Skip to main content

rainier_http_client/
request.rs

1//! Building and sending — [`Http`] and [`PendingRequest`].
2
3use std::cell::RefCell;
4use std::collections::BTreeMap;
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7
8use rainier_support::{Error, Result};
9use serde::Serialize;
10
11use crate::fake::FakeTransport;
12use crate::response::HttpResponse;
13use crate::retry::{is_retryable, Backoff};
14use crate::transport::{OutboundRequest, Transport};
15
16/// The transport every `Http::` call uses, unless one is passed explicitly.
17static TRANSPORT: RwLock<Option<Arc<dyn Transport>>> = RwLock::new(None);
18
19/// The fake, when one is installed — so the assertions can reach it.
20static FAKE: RwLock<Option<Arc<FakeTransport>>> = RwLock::new(None);
21
22thread_local! {
23    /// A fake scoped to *this thread*, which wins over the process-wide one.
24    ///
25    /// The same shape as the container's facade scope, and for the same
26    /// reason: a process-global fake means a test suite has to run its
27    /// outbound-call tests one at a time, and nobody remembers to. Each
28    /// `#[tokio::test]` body stays on one thread, so each gets its own fake
29    /// and they stop overwriting each other.
30    ///
31    /// A spawned task falls back to the process-wide one — the same limit the
32    /// facade scope has, and the same answer: install it globally when the
33    /// code under test spawns.
34    static SCOPED_FAKE: RefCell<Option<Arc<FakeTransport>>> = const { RefCell::new(None) };
35}
36
37/// The outbound HTTP client.
38///
39/// A facade over whichever [`Transport`] is installed, so
40/// [`fake`](Self::fake) swaps what every call in the process does — the same
41/// way rebinding a service swaps what a Rainier facade sees.
42pub struct Http;
43
44impl Http {
45    /// `GET url`.
46    pub fn get(url: impl Into<String>) -> PendingRequest {
47        PendingRequest::new("GET", url)
48    }
49
50    /// `POST url`.
51    pub fn post(url: impl Into<String>) -> PendingRequest {
52        PendingRequest::new("POST", url)
53    }
54
55    /// `PUT url`.
56    pub fn put(url: impl Into<String>) -> PendingRequest {
57        PendingRequest::new("PUT", url)
58    }
59
60    /// `PATCH url`.
61    pub fn patch(url: impl Into<String>) -> PendingRequest {
62        PendingRequest::new("PATCH", url)
63    }
64
65    /// `DELETE url`.
66    pub fn delete(url: impl Into<String>) -> PendingRequest {
67        PendingRequest::new("DELETE", url)
68    }
69
70    /// A request with any method.
71    pub fn request(method: impl Into<String>, url: impl Into<String>) -> PendingRequest {
72        PendingRequest::new(method, url)
73    }
74
75    /// Install the transport every call uses.
76    ///
77    /// Called once at boot. Calling it again replaces the binding, which is
78    /// what [`fake`](Self::fake) does.
79    pub fn install(transport: Arc<dyn Transport>) {
80        *TRANSPORT.write().expect("transport lock poisoned") = Some(transport);
81        *FAKE.write().expect("fake lock poisoned") = None;
82    }
83
84    /// Record every outbound call instead of making it.
85    ///
86    /// Returns the fake, so a test can queue answers:
87    ///
88    /// ```ignore
89    /// Http::fake().responding(200, r#"{"ok":true}"#);
90    /// ```
91    ///
92    /// **Nothing reaches the network** afterwards, which is the other half of
93    /// the point: a suite that accidentally calls a real endpoint is a suite
94    /// that fails when somebody runs it on a train.
95    pub fn fake() -> Arc<FakeTransport> {
96        let fake = Arc::new(FakeTransport::new());
97
98        // On this thread, so two tests faking at once do not overwrite each
99        // other — see `SCOPED_FAKE`. `fake_globally` is the one to reach for
100        // when the code under test spawns.
101        SCOPED_FAKE.with(|scoped| *scoped.borrow_mut() = Some(Arc::clone(&fake)));
102
103        fake
104    }
105
106    /// Record every outbound call **in the whole process**.
107    ///
108    /// For code that spawns: a task started by the code under test inherits no
109    /// thread scope, so [`fake`](Self::fake) would not catch its calls.
110    ///
111    /// The trade is that two tests doing this at once overwrite each other, so
112    /// a suite using it has to run those tests one at a time.
113    pub fn fake_globally() -> Arc<FakeTransport> {
114        let fake = Arc::new(FakeTransport::new());
115
116        *TRANSPORT.write().expect("transport lock poisoned") =
117            Some(Arc::clone(&fake) as Arc<dyn Transport>);
118        *FAKE.write().expect("fake lock poisoned") = Some(Arc::clone(&fake));
119
120        fake
121    }
122
123    /// The installed fake.
124    ///
125    /// # Panics
126    ///
127    /// If nothing is faking. Every Rainier double refuses to let an assertion
128    /// pass vacuously, and this is the same rule: an `assert_sent` that
129    /// silently passed because somebody forgot [`fake`](Self::fake) is the
130    /// most dangerous test in a suite.
131    pub fn faking() -> Arc<FakeTransport> {
132        Self::scoped_fake()
133            .or_else(|| FAKE.read().expect("fake lock poisoned").clone())
134            .expect("`Http` is not faking — call `Http::fake()` before asserting on outbound calls")
135    }
136
137    /// The fake scoped to this thread, if there is one.
138    fn scoped_fake() -> Option<Arc<FakeTransport>> {
139        SCOPED_FAKE.with(|scoped| scoped.borrow().clone())
140    }
141
142    /// Panic unless a matching request was sent. See
143    /// [`FakeTransport::assert_sent`].
144    ///
145    /// # Panics
146    ///
147    /// If none matched, or if nothing is faking.
148    pub fn assert_sent(matches: impl Fn(&crate::RecordedRequest) -> bool) {
149        Self::faking().assert_sent(matches);
150    }
151
152    /// Panic if a matching request was sent.
153    ///
154    /// # Panics
155    ///
156    /// If one was, or if nothing is faking.
157    pub fn assert_not_sent(matches: impl Fn(&crate::RecordedRequest) -> bool) {
158        Self::faking().assert_not_sent(matches);
159    }
160
161    /// Panic unless nothing was sent.
162    ///
163    /// # Panics
164    ///
165    /// If anything was, or if nothing is faking.
166    pub fn assert_nothing_sent() {
167        Self::faking().assert_nothing_sent();
168    }
169
170    /// Stop faking, and install nothing in its place.
171    ///
172    /// Clears both the thread scope and the process-wide binding.
173    pub fn clear() {
174        SCOPED_FAKE.with(|scoped| *scoped.borrow_mut() = None);
175        *TRANSPORT.write().expect("transport lock poisoned") = None;
176        *FAKE.write().expect("fake lock poisoned") = None;
177    }
178
179    /// The installed transport, or the default one.
180    fn transport() -> Result<Arc<dyn Transport>> {
181        // Nearest first: this thread's fake, then whatever the process
182        // installed, then a real one built on demand.
183        if let Some(fake) = Self::scoped_fake() {
184            return Ok(fake as Arc<dyn Transport>);
185        }
186        if let Some(transport) = TRANSPORT.read().expect("transport lock poisoned").clone() {
187            return Ok(transport);
188        }
189
190        #[cfg(feature = "reqwest-transport")]
191        {
192            // Installed lazily rather than at boot, so an application that
193            // never calls out never builds a TLS stack.
194            let transport: Arc<dyn Transport> = Arc::new(crate::ReqwestTransport::new());
195            *TRANSPORT.write().expect("transport lock poisoned") = Some(Arc::clone(&transport));
196            Ok(transport)
197        }
198
199        #[cfg(not(feature = "reqwest-transport"))]
200        Err(Error::internal(
201            "no HTTP transport is installed — enable the `reqwest-transport` feature, or call \
202             `Http::install(..)` with your own",
203        ))
204    }
205}
206
207/// A request being built.
208pub struct PendingRequest {
209    method: String,
210    url: String,
211    headers: BTreeMap<String, String>,
212    body: Option<Vec<u8>>,
213    timeout: Option<Duration>,
214    attempts: u32,
215    backoff: Backoff,
216    transport: Option<Arc<dyn Transport>>,
217}
218
219impl PendingRequest {
220    fn new(method: impl Into<String>, url: impl Into<String>) -> Self {
221        Self {
222            method: method.into().to_ascii_uppercase(),
223            url: url.into(),
224            headers: BTreeMap::new(),
225            body: None,
226            timeout: Some(Duration::from_secs(30)),
227            attempts: 1,
228            backoff: Backoff::default(),
229            transport: None,
230        }
231    }
232
233    /// Add a header.
234    #[must_use = "this returns the request rather than sending it"]
235    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
236        self.headers.insert(name.into().to_ascii_lowercase(), value.into());
237        self
238    }
239
240    /// `Authorization: Bearer …`.
241    #[must_use = "this returns the request rather than sending it"]
242    pub fn bearer(self, token: impl std::fmt::Display) -> Self {
243        self.header("authorization", format!("Bearer {token}"))
244    }
245
246    /// `Accept: application/json`.
247    #[must_use = "this returns the request rather than sending it"]
248    pub fn accept_json(self) -> Self {
249        self.header("accept", "application/json")
250    }
251
252    /// Send `value` as a JSON body.
253    ///
254    /// # Errors
255    ///
256    /// If it will not serialise — reported here rather than at `send`, so the
257    /// failure is next to the value that caused it.
258    pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self> {
259        self.body = Some(serde_json::to_vec(value)?);
260        Ok(self.header("content-type", "application/json"))
261    }
262
263    /// Send these bytes.
264    #[must_use = "this returns the request rather than sending it"]
265    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
266        self.body = Some(body.into());
267        self
268    }
269
270    /// Send a form-encoded body.
271    #[must_use = "this returns the request rather than sending it"]
272    pub fn form(mut self, fields: &[(&str, &str)]) -> Self {
273        let encoded = fields
274            .iter()
275            .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
276            .collect::<Vec<_>>()
277            .join("&");
278
279        self.body = Some(encoded.into_bytes());
280        self.header("content-type", "application/x-www-form-urlencoded")
281    }
282
283    /// How long to wait before giving up.
284    ///
285    /// Thirty seconds by default. A request with **no** timeout is one that
286    /// can hold a worker forever when the other end stops answering without
287    /// closing, which is the failure mode that takes a queue down.
288    #[must_use = "this returns the request rather than sending it"]
289    pub fn timeout(mut self, timeout: Duration) -> Self {
290        self.timeout = Some(timeout);
291        self
292    }
293
294    /// Wait indefinitely.
295    ///
296    /// Almost never right; see [`timeout`](Self::timeout).
297    #[must_use = "this returns the request rather than sending it"]
298    pub fn without_timeout(mut self) -> Self {
299        self.timeout = None;
300        self
301    }
302
303    /// Try up to `attempts` times, waiting per `backoff`.
304    ///
305    /// Only failures worth repeating are repeated — see
306    /// [`is_retryable`](crate::retry::is_retryable()), and read it before
307    /// assuming a `422` will be retried, because it will not.
308    #[must_use = "this returns the request rather than sending it"]
309    pub fn retry(mut self, attempts: u32, backoff: Backoff) -> Self {
310        self.attempts = attempts.max(1);
311        self.backoff = backoff;
312        self
313    }
314
315    /// Send through this transport rather than the installed one.
316    ///
317    /// For a caller holding its own client — a different proxy, a pinned
318    /// certificate — without changing what the rest of the process does.
319    #[must_use = "this returns the request rather than sending it"]
320    pub fn through(mut self, transport: Arc<dyn Transport>) -> Self {
321        self.transport = Some(transport);
322        self
323    }
324
325    /// Send it.
326    ///
327    /// Returns `Ok` for any response that arrived, including a `500` — see
328    /// [`HttpResponse::error_for_status`]. `Err` means nothing came back at
329    /// all after every attempt.
330    pub async fn send(self) -> Result<HttpResponse> {
331        let transport = match &self.transport {
332            Some(transport) => Arc::clone(transport),
333            None => Http::transport()?,
334        };
335
336        let request = OutboundRequest {
337            method: self.method.clone(),
338            url: self.url.clone(),
339            headers: self.headers.clone(),
340            body: self.body.clone(),
341            timeout: self.timeout,
342        };
343
344        let mut last: Option<Error> = None;
345
346        for attempt in 1..=self.attempts {
347            let wait = self.backoff.wait_before(attempt);
348            if !wait.is_zero() {
349                tokio::time::sleep(wait).await;
350            }
351
352            match transport.send(request.clone()).await {
353                Ok(raw) => {
354                    let response = HttpResponse::new(raw);
355
356                    if attempt < self.attempts && is_retryable(Some(response.status())) {
357                        tracing::debug!(
358                            url = %self.url,
359                            status = response.status(),
360                            attempt,
361                            "retrying an outbound request"
362                        );
363                        continue;
364                    }
365
366                    // Returned whatever the status is: the caller decides
367                    // whether a `404` is a failure, and plenty of the time it
368                    // is the answer they wanted.
369                    return Ok(response);
370                }
371                Err(e) => {
372                    if attempt < self.attempts && is_retryable(None) {
373                        tracing::debug!(url = %self.url, attempt, error = %e.message(), "retrying");
374                        last = Some(e);
375                        continue;
376                    }
377                    return Err(e);
378                }
379            }
380        }
381
382        Err(last.unwrap_or_else(|| Error::internal("the request was never attempted")))
383    }
384}
385
386impl std::fmt::Debug for PendingRequest {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("PendingRequest")
389            .field("method", &self.method)
390            .field("url", &self.url)
391            .field("attempts", &self.attempts)
392            .finish()
393    }
394}
395
396/// Percent-encode a form value.
397fn encode(value: &str) -> String {
398    let mut out = String::with_capacity(value.len());
399
400    for byte in value.bytes() {
401        match byte {
402            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
403                out.push(byte as char);
404            }
405            _ => out.push_str(&format!("%{byte:02X}")),
406        }
407    }
408    out
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use serde_json::json;
415
416    /// Its own transport per test, so nothing races the process-wide one.
417    fn fake() -> Arc<FakeTransport> {
418        Arc::new(FakeTransport::new())
419    }
420
421    #[tokio::test]
422    async fn a_json_post_carries_its_body_and_content_type() {
423        let fake = fake();
424
425        Http::post("https://hooks.example.com/x")
426            .json(&json!({ "id": 42 }))
427            .unwrap()
428            .through(Arc::clone(&fake) as Arc<dyn Transport>)
429            .send()
430            .await
431            .unwrap();
432
433        let sent = &fake.recorded()[0];
434        assert_eq!(sent.method(), "POST");
435        assert_eq!(sent.header("content-type"), Some("application/json"));
436        assert_eq!(sent.json().unwrap()["id"], 42);
437    }
438
439    #[tokio::test]
440    async fn a_bearer_token_becomes_a_header() {
441        let fake = fake();
442
443        Http::get("https://api.example.com/me")
444            .bearer("secret-token")
445            .accept_json()
446            .through(Arc::clone(&fake) as Arc<dyn Transport>)
447            .send()
448            .await
449            .unwrap();
450
451        let sent = &fake.recorded()[0];
452        assert_eq!(sent.header("authorization"), Some("Bearer secret-token"));
453        assert_eq!(sent.header("accept"), Some("application/json"));
454    }
455
456    #[tokio::test]
457    async fn a_form_body_is_encoded() {
458        let fake = fake();
459
460        Http::post("https://oauth.example.com/token")
461            .form(&[("grant_type", "client_credentials"), ("scope", "read write")])
462            .through(Arc::clone(&fake) as Arc<dyn Transport>)
463            .send()
464            .await
465            .unwrap();
466
467        let sent = &fake.recorded()[0];
468        assert_eq!(sent.header("content-type"), Some("application/x-www-form-urlencoded"));
469        assert_eq!(sent.body(), "grant_type=client_credentials&scope=read%20write");
470    }
471
472    #[tokio::test]
473    async fn a_five_hundred_comes_back_as_ok_and_the_caller_decides() {
474        let fake = fake();
475        fake.responding(500, "not my day");
476
477        let response = Http::get("https://x")
478            .through(Arc::clone(&fake) as Arc<dyn Transport>)
479            .send()
480            .await
481            .unwrap();
482
483        assert_eq!(response.status(), 500);
484        assert!(response.error_for_status().is_err());
485    }
486
487    #[tokio::test]
488    async fn a_retryable_status_is_retried_and_then_succeeds() {
489        let fake = fake();
490        fake.responding(503, "later").responding(503, "later").responding(200, "yes");
491
492        let response = Http::get("https://x")
493            .retry(3, Backoff::None)
494            .through(Arc::clone(&fake) as Arc<dyn Transport>)
495            .send()
496            .await
497            .unwrap();
498
499        assert_eq!(response.status(), 200);
500        assert_eq!(fake.count(), 3);
501    }
502
503    #[tokio::test]
504    async fn a_client_mistake_is_not_retried() {
505        // The one that matters: sending a 422 four times does not fix the
506        // payload, and a non-idempotent endpoint may act on each one.
507        let fake = fake();
508        fake.responding(422, "bad payload");
509
510        let response = Http::post("https://x")
511            .retry(4, Backoff::None)
512            .through(Arc::clone(&fake) as Arc<dyn Transport>)
513            .send()
514            .await
515            .unwrap();
516
517        assert_eq!(response.status(), 422);
518        assert_eq!(fake.count(), 1, "it should not have tried again");
519    }
520
521    #[tokio::test]
522    async fn retrying_gives_up_and_returns_the_last_answer() {
523        let fake = fake();
524        fake.responding(503, "still no");
525
526        let response = Http::get("https://x")
527            .retry(3, Backoff::None)
528            .through(Arc::clone(&fake) as Arc<dyn Transport>)
529            .send()
530            .await
531            .unwrap();
532
533        assert_eq!(response.status(), 503);
534        assert_eq!(fake.count(), 3);
535    }
536
537    #[tokio::test]
538    async fn there_is_a_timeout_by_default() {
539        // A request with no timeout can hold a worker forever when the other
540        // end stops answering without closing.
541        let fake = fake();
542
543        Http::get("https://x")
544            .through(Arc::clone(&fake) as Arc<dyn Transport>)
545            .send()
546            .await
547            .unwrap();
548
549        // The transport sees it, which is what a real one passes to reqwest.
550        let request = OutboundRequest {
551            method: "GET".into(),
552            url: "https://x".into(),
553            headers: BTreeMap::new(),
554            body: None,
555            timeout: Some(Duration::from_secs(30)),
556        };
557        assert_eq!(request.timeout, Some(Duration::from_secs(30)));
558    }
559
560    #[test]
561    fn form_values_are_percent_encoded() {
562        assert_eq!(encode("read write"), "read%20write");
563        assert_eq!(encode("a&b=c"), "a%26b%3Dc");
564        assert_eq!(encode("ada@example.com"), "ada%40example.com");
565        assert_eq!(encode("plain-value_1.0~"), "plain-value_1.0~");
566    }
567
568    #[test]
569    fn the_method_is_normalised() {
570        assert_eq!(Http::request("post", "https://x").method, "POST");
571    }
572}