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`] to mirror upstream GoReleaser's
39/// `internal/client/github.go::bodyOf` (commit `8b77358`): a transport-level
40/// read error becomes `"could not read response body: <err>"` rather than
41/// silently truncating to `""`. Exposed as a free function so unit tests can
42/// pin the exact wording without standing up a fault-injecting HTTP server.
43pub fn body_read_error_message<E: std::fmt::Display>(err: E) -> String {
44 format!("could not read response body: {err}")
45}
46
47/// Read an HTTP response body to a `String`, returning a descriptive
48/// placeholder on read failure.
49///
50/// Mirrors GoReleaser's `internal/client/github.go::bodyOf` after upstream
51/// commit `8b77358`: a transport-level read error becomes
52/// `"could not read response body: <err>"` rather than silently truncating
53/// to an empty string. Callers typically pass the resulting text into a
54/// larger error context (e.g. `"GitHub API returned 422: {body}"`), so the
55/// placeholder still surfaces a usable diagnostic instead of a confusing
56/// empty payload.
57///
58/// Use this when the body will be interpolated into a downstream error
59/// message; use `resp.text().await?` directly when the caller will
60/// propagate the read failure as its own error rather than substituting
61/// a placeholder.
62pub async fn body_of(resp: reqwest::Response) -> String {
63 match resp.text().await {
64 Ok(s) => s,
65 Err(err) => body_read_error_message(err),
66 }
67}
68
69/// Blocking analogue of [`body_of`].
70///
71/// Use this when the body will be interpolated into a downstream error
72/// message; use `resp.text()?` directly when the caller will propagate
73/// the read failure as its own error rather than substituting a
74/// placeholder.
75pub fn body_of_blocking(resp: reqwest::blocking::Response) -> String {
76 match resp.text() {
77 Ok(s) => s,
78 Err(err) => body_read_error_message(err),
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn test_body_read_error_message_uses_descriptive_prefix() {
88 // Pin the exact wording: callers may parse / match on this string,
89 // and parity with upstream GoReleaser's `bodyOf` requires the
90 // `"could not read response body: "` prefix verbatim.
91 let formatted = body_read_error_message("connection reset by peer");
92 assert_eq!(
93 formatted,
94 "could not read response body: connection reset by peer"
95 );
96 }
97
98 #[test]
99 fn test_body_read_error_message_with_io_error() {
100 let io_err = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stream ended early");
101 let formatted = body_read_error_message(io_err);
102 assert!(
103 formatted.starts_with("could not read response body: "),
104 "format must keep the descriptive prefix: {formatted}"
105 );
106 assert!(
107 formatted.contains("stream ended early"),
108 "format must include the underlying error: {formatted}"
109 );
110 }
111}