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;