aede_core/
http.rs

1//! The one place this program reaches the network.
2//!
3//! Behind the `fetch` feature, so that a build without it has no way to make a
4//! request at all — a property the compiler enforces rather than a promise a
5//! README makes.
6//!
7//! It throttles requests, identifies the application, and returns bounded JSON
8//! or image bytes. Endpoint construction and response interpretation live in
9//! service modules such as [`crate::musicbrainz`], which have no socket and can
10//! be tested without contacting a service.
11//!
12//! **Waiting its turn is not politeness, it is the contract.** MusicBrainz
13//! allows one request per second per address and answers `503` to everything
14//! once that is exceeded — not just to the request that went over. A client
15//! without a throttle does not run slightly too fast; it stops working, and it
16//! does so for every other program on the same address.
17
18use crate::json::Json;
19use std::time::{Duration, Instant};
20
21/// How long to wait for one answer before giving up on it.
22const TIMEOUT: Duration = Duration::from_secs(25);
23
24/// The largest body this client will read into memory.
25///
26/// Cover art may be much larger than the JSON metadata. A ceiling turns a wrong
27/// address — a redirect gone astray, a service handing back something else
28/// entirely — into a refusal rather than a machine filling its memory.
29const MAX_BODY: u64 = 32 * 1024 * 1024;
30
31/// What can go wrong, kept apart because the answers differ.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Error {
34    /// HTTP 503: the service is busy or limiting requests. Callers may apply
35    /// bounded backoff and must stop if it persists. HTTP 429 stays a separate
36    /// [`Error::Status`] so it cannot enter that retry path blindly.
37    RateLimited,
38    /// The service answered, with something other than success.
39    Status(u16),
40    /// Nothing came back: no route, no name, a timeout.
41    Network(String),
42    /// Something came back, and it was not the JSON that was asked for.
43    NotJson(String),
44    /// The response exceeded the explicit in-memory body limit, in bytes.
45    BodyTooLarge(u64),
46}
47
48impl std::fmt::Display for Error {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Error::RateLimited => write!(
52                f,
53                "the service is busy or limiting requests; nothing was lost, try later"
54            ),
55            Error::Status(code) => write!(f, "the service answered {code}"),
56            Error::Network(detail) => write!(f, "could not reach the service: {detail}"),
57            Error::NotJson(detail) => write!(f, "the answer was not readable: {detail}"),
58            Error::BodyTooLarge(limit) => write!(
59                f,
60                "the answer exceeded the {} MiB download limit, so it was not kept",
61                limit / (1024 * 1024)
62            ),
63        }
64    }
65}
66
67impl std::error::Error for Error {}
68
69/// A client that waits its turn and says who it is.
70pub struct Client {
71    agent: ureq::Agent,
72    user_agent: String,
73    interval: Duration,
74    last: Option<Instant>,
75}
76
77impl Client {
78    /// A client for one service.
79    ///
80    /// `user_agent` is not decoration: MusicBrainz **requires** a descriptive
81    /// one carrying a way to contact whoever wrote the program, and throttles
82    /// anonymous or generic callers as a single shared pool — so a library
83    /// that sent `ureq/3.4` would be sharing a rate limit with every other
84    /// program that could not be bothered either. Build it with
85    /// [`Client::identify`].
86    pub fn new(user_agent: impl Into<String>, interval: Duration) -> Client {
87        let config = ureq::Agent::config_builder()
88            .timeout_global(Some(TIMEOUT))
89            .build();
90        Client {
91            agent: config.into(),
92            user_agent: user_agent.into(),
93            interval,
94            last: None,
95        }
96    }
97
98    /// The `User-Agent` MusicBrainz asks for: `name/version ( contact )`.
99    pub fn identify(name: &str, version: &str, contact: &str) -> String {
100        format!("{name}/{version} ( {contact} )")
101    }
102
103    /// Fetches a URL and parses the answer as JSON.
104    ///
105    /// Blocks for as long as the rate requires before sending. That is the
106    /// whole point: a caller that could forget to wait is a caller that will.
107    /// Bodies over 32 MiB and JSON that is not UTF-8 are refused.
108    pub fn get_json(&mut self, url: &str) -> Result<Json, Error> {
109        self.wait_turn();
110        let mut response = self
111            .agent
112            .get(url)
113            .header("User-Agent", &self.user_agent)
114            .header("Accept", "application/json")
115            .call()
116            .map_err(from_ureq)?;
117        read_json_body(response.body_mut())
118    }
119
120    /// Fetches a URL and hands back the body as it came.
121    ///
122    /// The one thing this client downloads that is not an answer to a question:
123    /// an image. It waits its turn like everything else.
124    ///
125    /// At most 32 MiB are accepted. The response reader has an explicit limit
126    /// rather than relying on a dependency default; one extra byte detects
127    /// oversized bodies even when the server omits their length.
128    pub fn get_bytes(&mut self, url: &str) -> Result<Vec<u8>, Error> {
129        self.wait_turn();
130        let mut response = self
131            .agent
132            .get(url)
133            .header("User-Agent", &self.user_agent)
134            .call()
135            .map_err(from_ureq)?;
136        read_bytes_body(response.body_mut())
137    }
138
139    /// Sleeps until the next request is allowed.
140    fn wait_turn(&mut self) {
141        if let Some(last) = self.last {
142            let since = last.elapsed();
143            if since < self.interval {
144                std::thread::sleep(self.interval - since);
145            }
146        }
147        self.last = Some(Instant::now());
148    }
149
150    /// The minimum delay between the starts of two requests on this client.
151    ///
152    /// Exposed so a command can tell the user that asking about six hundred
153    /// artists will take ten minutes *before* it starts, rather than leaving
154    /// them to work it out from a progress line.
155    pub fn interval(&self) -> Duration {
156        self.interval
157    }
158}
159
160fn read_json_body(body: &mut ureq::Body) -> Result<Json, Error> {
161    // Reading bytes avoids a lossy text conversion changing an external claim.
162    let text =
163        String::from_utf8(read_bytes_body(body)?).map_err(|e| Error::NotJson(e.to_string()))?;
164    crate::json::parse(&text).map_err(|e| Error::NotJson(e.to_string()))
165}
166
167fn read_bytes_body(body: &mut ureq::Body) -> Result<Vec<u8>, Error> {
168    let reader = body.with_config().limit(MAX_BODY + 1).reader();
169    read_limited(reader, MAX_BODY)
170}
171
172fn read_limited(reader: impl std::io::Read, limit: u64) -> Result<Vec<u8>, Error> {
173    // ureq's body limit applies before decompression. Bound the decoded bytes
174    // too, before allocating them, so a small compressed answer cannot expand
175    // past the same memory budget.
176    use std::io::Read;
177    let mut reader = reader.take(limit + 1);
178    let mut bytes = Vec::new();
179    reader
180        .read_to_end(&mut bytes)
181        .map_err(|e| match ureq::Error::from(e) {
182            ureq::Error::BodyExceedsLimit(_) => Error::BodyTooLarge(limit),
183            other => Error::Network(other.to_string()),
184        })?;
185    if bytes.len() as u64 > limit {
186        return Err(Error::BodyTooLarge(limit));
187    }
188    Ok(bytes)
189}
190
191/// Preserves HTTP status meaning while hiding the dependency's error types.
192fn from_ureq(error: ureq::Error) -> Error {
193    match error {
194        // 503 is what MusicBrainz answers when the rate is exceeded, and it
195        // answers it to everything until the rate drops — so it has to be told
196        // apart from an ordinary failure, which a retry could survive.
197        ureq::Error::StatusCode(503) => Error::RateLimited,
198        ureq::Error::StatusCode(code) => Error::Status(code),
199        other => Error::Network(other.to_string()),
200    }
201}
202
203#[cfg(test)]
204#[path = "http_tests.rs"]
205mod tests;