Skip to main content

anodizer_core/
http.rs

1//! HTTP client helpers shared by every stage that talks to a remote.
2//!
3//! All anodizer HTTP traffic should go through `blocking_client(...)` so that
4//! the `User-Agent`, default-roots, and timeout policy stay consistent across
5//! publishers, announcers, and the release backends.
6
7use std::time::Duration;
8
9use anyhow::{Context as _, Result};
10
11/// Canonical user-agent string sent with every anodizer HTTP request.
12///
13/// Versioning the UA matters for upstream services that rate-limit or
14/// fingerprint by client identity (Discourse, Reddit, GitHub, etc.).
15pub const USER_AGENT: &str = concat!("anodizer/", env!("CARGO_PKG_VERSION"));
16
17/// Build a blocking `reqwest::Client` configured with the canonical UA,
18/// the requested per-request timeout, and the platform's built-in roots.
19pub fn blocking_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
20    reqwest::blocking::Client::builder()
21        .user_agent(USER_AGENT)
22        .timeout(timeout)
23        .build()
24        .context("build blocking HTTP client")
25}
26
27/// Async equivalent of `blocking_client`.
28pub fn async_client(timeout: Duration) -> Result<reqwest::Client> {
29    reqwest::Client::builder()
30        .user_agent(USER_AGENT)
31        .timeout(timeout)
32        .build()
33        .context("build async HTTP client")
34}
35
36/// Format an HTTP body-read failure as a descriptive placeholder string.
37///
38/// Used by [`body_of`] / [`body_of_blocking`]: a transport-level
39/// read error becomes `"could not read response body: <err>"` rather than
40/// silently truncating to `""`. Exposed as a free function so unit tests can
41/// pin the exact wording without standing up a fault-injecting HTTP server.
42pub fn body_read_error_message<E: std::fmt::Display>(err: E) -> String {
43    format!("could not read response body: {err}")
44}
45
46/// Read an HTTP response body to a `String`, returning a descriptive
47/// placeholder on read failure.
48///
49/// Reads and scrubs an HTTP response body for error reporting after
50/// commit `8b77358`: a transport-level read error becomes
51/// `"could not read response body: <err>"` rather than silently truncating
52/// to an empty string. Callers typically pass the resulting text into a
53/// larger error context (e.g. `"GitHub API returned 422: {body}"`), so the
54/// placeholder still surfaces a usable diagnostic instead of a confusing
55/// empty payload.
56///
57/// Use this when the body will be interpolated into a downstream error
58/// message; use `resp.text().await?` directly when the caller will
59/// propagate the read failure as its own error rather than substituting
60/// a placeholder.
61pub async fn body_of(resp: reqwest::Response) -> String {
62    match resp.text().await {
63        Ok(s) => s,
64        Err(err) => body_read_error_message(err),
65    }
66}
67
68/// Blocking analogue of [`body_of`].
69///
70/// Use this when the body will be interpolated into a downstream error
71/// message; use `resp.text()?` directly when the caller will propagate
72/// the read failure as its own error rather than substituting a
73/// placeholder.
74pub fn body_of_blocking(resp: reqwest::blocking::Response) -> String {
75    match resp.text() {
76        Ok(s) => s,
77        Err(err) => body_read_error_message(err),
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_body_read_error_message_uses_descriptive_prefix() {
87        // Pin the exact wording: callers may parse / match on this string,
88        // and the error-body contract requires the
89        // `"could not read response body: "` prefix verbatim.
90        let formatted = body_read_error_message("connection reset by peer");
91        assert_eq!(
92            formatted,
93            "could not read response body: connection reset by peer"
94        );
95    }
96
97    #[test]
98    fn test_body_read_error_message_with_io_error() {
99        let io_err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stream ended early");
100        let formatted = body_read_error_message(io_err);
101        assert!(
102            formatted.starts_with("could not read response body: "),
103            "format must keep the descriptive prefix: {formatted}"
104        );
105        assert!(
106            formatted.contains("stream ended early"),
107            "format must include the underlying error: {formatted}"
108        );
109    }
110}