Skip to main content

vector_core/
blossom_error.rs

1//! What a Blossom server said when it refused — as data, not prose.
2//!
3//! Magnitude answers every refusal three ways at once: `X-Error-Code`, a
4//! stable identifier that is a header so a BUD-06 `HEAD` preflight carries it
5//! too; `X-Retry`, one of `never` / `later` / `elsewhere`; and a JSON body
6//! `{error, message, retry, status, ...}` whose extra fields hold the numbers
7//! behind the sentence (`limit`, `used`, `retry_after`, `mime`). A server that
8//! speaks that vocabulary is routed on it. One that does not falls back to the
9//! status-and-keyword heuristics in `blossom_capabilities`, which is all a
10//! plain BUD-02 server gives us to go on.
11//!
12//! The difference matters most where the heuristics are wrong in a way only
13//! the code can reveal: a quota refusal is a 413, and a 413 read as "the blob
14//! is too big" would demote the server for every file of that size for days;
15//! an expired signature is a 401, and a 401 read as "this server will never
16//! take our uploads" would bury a good server under a clock skew.
17
18use reqwest::header::HeaderMap;
19use serde_json::{json, Map, Value};
20use std::time::Duration;
21
22/// A server's advice on what to do with a refused request.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Retry {
25    /// The request is wrong. Sending it again changes nothing.
26    Never,
27    /// This server may accept it later.
28    Later,
29    /// This server will not accept it, but another might.
30    Elsewhere,
31}
32
33impl Retry {
34    fn parse(s: &str) -> Option<Self> {
35        match s.trim() {
36            "never" => Some(Retry::Never),
37            "later" => Some(Retry::Later),
38            "elsewhere" => Some(Retry::Elsewhere),
39            _ => None,
40        }
41    }
42}
43
44/// A non-2xx answer to an upload, preflight, or mirror request.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Refusal {
47    pub status: u16,
48    /// The server's stable identifier for this refusal, when it sent one.
49    /// `None` means a server without the vocabulary, to be read by status.
50    pub code: Option<String>,
51    pub retry: Option<Retry>,
52    /// For a person: the body's `message`, else `X-Reason`, else the raw body.
53    pub message: String,
54    /// The numbers behind the message: `limit`, `used`, `retry_after`, `mime`.
55    pub details: Map<String, Value>,
56}
57
58/// A code is a short snake_case word; anything else in the header is noise.
59fn valid_code(s: &str) -> bool {
60    !s.is_empty()
61        && s.len() <= 48
62        && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
63}
64
65impl Refusal {
66    /// Read a refusal out of a response. `body` may be empty (a `HEAD` has
67    /// none), in which case the headers carry everything Magnitude sends.
68    pub fn from_response(status: u16, headers: &HeaderMap, body: &str) -> Self {
69        let header = |name: &str| {
70            headers
71                .get(name)
72                .and_then(|v| v.to_str().ok())
73                .map(|s| s.trim().to_string())
74                .filter(|s| !s.is_empty())
75        };
76        let mut json: Option<Map<String, Value>> = serde_json::from_str::<Value>(body)
77            .ok()
78            .and_then(|v| v.as_object().cloned());
79        let json_str = |j: &Option<Map<String, Value>>, key: &str| {
80            j.as_ref()
81                .and_then(|m| m.get(key))
82                .and_then(|v| v.as_str())
83                .map(|s| s.to_string())
84        };
85
86        let code = header("x-error-code")
87            .or_else(|| json_str(&json, "error"))
88            .filter(|c| valid_code(c));
89        let retry = header("x-retry")
90            .as_deref()
91            .and_then(Retry::parse)
92            .or_else(|| json_str(&json, "retry").as_deref().and_then(Retry::parse));
93        let message = json_str(&json, "message")
94            .or_else(|| header("x-reason"))
95            .or_else(|| {
96                let trimmed = body.trim();
97                (!trimmed.is_empty()).then(|| trimmed.to_string())
98            })
99            .unwrap_or_else(|| format!("HTTP {}", status));
100
101        let mut details = json
102            .take()
103            .map(|mut m| {
104                for k in ["error", "message", "retry", "status"] {
105                    m.remove(k);
106                }
107                m
108            })
109            .unwrap_or_default();
110        if let Some(secs) = header("retry-after").and_then(|s| s.parse::<u64>().ok()) {
111            details.entry("retry_after").or_insert(json!(secs));
112        }
113
114        Self { status, code, retry, message, details }
115    }
116
117    pub fn code(&self) -> Option<&str> {
118        self.code.as_deref()
119    }
120
121    fn detail_u64(&self, key: &str) -> Option<u64> {
122        self.details.get(key).and_then(|v| v.as_u64())
123    }
124
125    /// The per-file limit the server named, for a `blob_too_large`.
126    pub fn limit(&self) -> Option<u64> {
127        self.detail_u64("limit")
128    }
129
130    pub fn retry_after(&self) -> Option<Duration> {
131        self.detail_u64("retry_after").map(Duration::from_secs)
132    }
133
134    /// The file is bigger than this server takes — a property of the file's
135    /// size, so worth remembering. A quota refusal is deliberately NOT this:
136    /// it shares the 413 but says nothing about size.
137    pub fn is_size_limit(&self) -> bool {
138        match self.code() {
139            Some(c) => c == "blob_too_large",
140            None => self.status == 413,
141        }
142    }
143
144    /// This server does not take this kind of file — worth remembering.
145    pub fn is_mime(&self) -> bool {
146        match self.code() {
147            Some(c) => c == "unsupported_media_type",
148            None => crate::blossom_capabilities::is_mime_rejection(Some(self.status), &self.message),
149        }
150    }
151
152    /// Whether sending the same request to the same server again, right now,
153    /// has any chance. A coded server is believed; an uncoded one is retried
154    /// unless the status is one that never changes on a retry.
155    pub fn worth_retrying_here(&self) -> bool {
156        match self.code() {
157            // `later` covers tomorrow's quota as much as a 2 s rate limit, so
158            // only the codes where "later" plausibly means "in a moment".
159            Some("rate_limited") => self
160                .retry_after()
161                .is_none_or(|d| d <= MAX_RETRY_AFTER),
162            Some("internal") | Some("mirror_source_unreachable") => true,
163            Some(_) => false,
164            None => {
165                !self.is_mime()
166                    && !self.is_size_limit()
167                    && !is_gateway_status(self.status)
168            }
169        }
170    }
171
172    /// One sentence for the person waiting on the upload.
173    pub fn describe(&self, host: &str) -> String {
174        let bytes = crate::crypto::format_bytes;
175        match self.code() {
176            Some("quota_daily_exceeded") => match (self.detail_u64("used"), self.limit()) {
177                (Some(used), Some(limit)) => format!(
178                    "Today's {} upload allowance on {} is used up ({} uploaded so far). It resets at midnight UTC.",
179                    bytes(limit), host, bytes(used),
180                ),
181                _ => format!("Today's upload allowance on {} is used up. It resets at midnight UTC.", host),
182            },
183            Some("quota_storage_exceeded") => match (self.detail_u64("used"), self.limit()) {
184                (Some(used), Some(limit)) => format!(
185                    "Your storage on {} is full ({} of {}). Delete some files there, or use another server.",
186                    host, bytes(used), bytes(limit),
187                ),
188                _ => format!("Your storage on {} is full.", host),
189            },
190            Some("blob_too_large") => match self.limit() {
191                Some(limit) => format!("{} allows files up to {} for your account.", host, bytes(limit)),
192                None => format!("{} won't accept a file this large.", host),
193            },
194            Some("unsupported_media_type") => match self.details.get("mime").and_then(|v| v.as_str()) {
195                Some(mime) => format!("{} doesn't accept {} files.", host, mime),
196                None => format!("{} doesn't accept this kind of file.", host),
197            },
198            Some("client_not_permitted") => format!(
199                "{} only takes uploads from Vector accounts it recognises. {}",
200                host, self.message,
201            ),
202            Some("rate_limited") => match self.retry_after() {
203                Some(d) => format!("{} is rate-limiting you; try again in {}s.", host, d.as_secs()),
204                None => format!("{} is rate-limiting you; try again shortly.", host),
205            },
206            Some("server_full") => format!("{} is out of space.", host),
207            Some("blob_blocked") => format!("{} has blocked this file.", host),
208            Some("auth_expired") | Some("auth_from_the_future") => format!(
209                "{} rejected the upload signature's timestamp — this device's clock may be wrong.",
210                host,
211            ),
212            Some(c) if c.starts_with("auth_") => {
213                format!("{} rejected the upload authorization ({}).", host, self.message)
214            }
215            Some("internal") => format!("{} hit an internal error.", host),
216            Some(_) => format!("{}: {}", host, self.message),
217            None => match self.status {
218                413 => format!("{} won't accept a file this large.", host),
219                415 => format!("{} doesn't accept this kind of file.", host),
220                401 | 402 => format!("{} refused Vector's upload signature ({}).", host, self.message),
221                429 => format!("{} is rate-limiting you; try again shortly.", host),
222                s if s >= 500 => format!("{} is having trouble (HTTP {}).", host, s),
223                s => format!("{}: HTTP {} {}", host, s, self.message),
224            },
225        }
226    }
227}
228
229/// A `later` this much later is still "now" to someone watching a spinner.
230const MAX_RETRY_AFTER: Duration = Duration::from_secs(15);
231
232/// 502 / 503 / 504 and the Cloudflare 52x family: the origin behind a proxy
233/// cannot ingest the upload, and the next attempt hits the same wall.
234pub fn is_gateway_status(status: u16) -> bool {
235    matches!(status, 502 | 503 | 504 | 520..=526)
236}
237
238/// Why one upload attempt did not produce a blob.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum UploadFailure {
241    /// The user stopped it.
242    Cancelled,
243    /// The server answered, and the answer was no.
244    Refused(Refusal),
245    /// The bytes never fully arrived: connection dropped, stalled, refused.
246    Transport(String),
247    /// The server ACKed a different hash than was sent — it re-encoded the
248    /// blob, which is fatal for ciphertext.
249    Integrity(String),
250    /// Something before or after the wire: signing, URL, parsing.
251    Other(String),
252}
253
254impl UploadFailure {
255    pub fn refusal(&self) -> Option<&Refusal> {
256        match self {
257            UploadFailure::Refused(r) => Some(r),
258            _ => None,
259        }
260    }
261
262    /// The connection went away with the body in flight. Above a few MB this
263    /// is almost always a size policy enforced by dropping, not a blip.
264    pub fn is_mid_stream_drop(&self) -> bool {
265        let UploadFailure::Transport(msg) = self else {
266            return false;
267        };
268        [
269            "Upload request failed",
270            "error sending request",
271            "connection reset",
272            "connection closed",
273            "connection refused",
274            "body write",
275            "IncompleteMessage",
276            "broken pipe",
277        ]
278        .iter()
279        .any(|needle| msg.contains(needle))
280    }
281
282    /// One sentence for the person waiting on the upload.
283    pub fn describe(&self, host: &str) -> String {
284        match self {
285            UploadFailure::Cancelled => "Upload cancelled".to_string(),
286            UploadFailure::Refused(r) => r.describe(host),
287            UploadFailure::Transport(m) if m.starts_with("Upload stalled") => {
288                format!("{} stopped accepting the file mid-way.", host)
289            }
290            UploadFailure::Transport(_) => format!("{} could not be reached.", host),
291            UploadFailure::Integrity(_) => format!("{} altered the file instead of storing it.", host),
292            UploadFailure::Other(m) => m.clone(),
293        }
294    }
295}
296
297impl std::fmt::Display for UploadFailure {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        match self {
300            UploadFailure::Cancelled => write!(f, "Upload cancelled"),
301            // The "with status N" shape is load-bearing: `parse_status_from_error`
302            // reads it wherever this crosses into a String.
303            UploadFailure::Refused(r) => match r.code() {
304                Some(c) => write!(f, "Upload failed with status {} [{}]: {}", r.status, c, r.message),
305                None => write!(f, "Upload failed with status {}: {}", r.status, r.message),
306            },
307            UploadFailure::Transport(m) | UploadFailure::Integrity(m) | UploadFailure::Other(m) => {
308                write!(f, "{}", m)
309            }
310        }
311    }
312}
313
314impl From<UploadFailure> for String {
315    fn from(e: UploadFailure) -> String {
316        e.to_string()
317    }
318}
319
320/// The host a server URL names, for messages: `magnitude.jskitty.com`, not
321/// `https://magnitude.jskitty.com/`.
322pub fn host_of(server_url: &str) -> String {
323    url::Url::parse(server_url)
324        .ok()
325        .and_then(|u| u.host_str().map(|h| h.to_string()))
326        .unwrap_or_else(|| server_url.trim_end_matches('/').to_string())
327}
328
329/// What to tell the user when every server said no.
330pub fn summarise_failures(failures: &[(String, UploadFailure)]) -> String {
331    match failures {
332        [] => "No media server is configured. Add one in Settings → Network.".to_string(),
333        [(host, e)] => e.describe(host),
334        many => {
335            let mut out = String::from("No media server accepted the file:");
336            for (host, e) in many {
337                out.push_str("\n• ");
338                out.push_str(&e.describe(host));
339            }
340            out
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use reqwest::header::HeaderValue;
349
350    fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
351        let mut h = HeaderMap::new();
352        for (k, v) in pairs {
353            h.insert(*k, HeaderValue::from_str(v).unwrap());
354        }
355        h
356    }
357
358    #[test]
359    fn a_magnitude_refusal_is_read_from_headers_and_body() {
360        let h = headers(&[("x-error-code", "quota_daily_exceeded"), ("x-retry", "later"), ("x-reason", "prose")]);
361        let body = r#"{"error":"quota_daily_exceeded","message":"Blob would exceed today's upload allowance: 900 of 1000 bytes already uploaded","retry":"later","status":413,"used":900,"limit":1000}"#;
362        let r = Refusal::from_response(413, &h, body);
363        assert_eq!(r.code(), Some("quota_daily_exceeded"));
364        assert_eq!(r.retry, Some(Retry::Later));
365        assert_eq!(r.limit(), Some(1000));
366        assert_eq!(r.details.get("used").and_then(|v| v.as_u64()), Some(900));
367        assert!(r.message.starts_with("Blob would exceed"));
368        assert!(!r.details.contains_key("error"), "envelope fields are not details");
369    }
370
371    #[test]
372    fn a_head_preflight_carries_the_code_with_no_body() {
373        let h = headers(&[("x-error-code", "client_not_permitted"), ("x-retry", "elsewhere"), ("x-reason", "Uploads need a Vector profile")]);
374        let r = Refusal::from_response(403, &h, "");
375        assert_eq!(r.code(), Some("client_not_permitted"));
376        assert_eq!(r.retry, Some(Retry::Elsewhere));
377        assert_eq!(r.message, "Uploads need a Vector profile");
378    }
379
380    #[test]
381    fn a_plain_server_has_no_code_and_keeps_its_body() {
382        let r = Refusal::from_response(400, &HeaderMap::new(), "Content-Type header does not match the file content");
383        assert_eq!(r.code(), None);
384        assert_eq!(r.retry, None);
385        assert!(r.is_mime(), "the keyword heuristic still applies without a code");
386    }
387
388    #[test]
389    fn a_quota_413_is_not_a_size_limit_but_a_plain_413_is() {
390        // Both are 413.
391        let h = headers(&[("x-error-code", "quota_daily_exceeded")]);
392        assert!(!Refusal::from_response(413, &h, "").is_size_limit());
393        let h = headers(&[("x-error-code", "blob_too_large")]);
394        assert!(Refusal::from_response(413, &h, "").is_size_limit());
395        assert!(Refusal::from_response(413, &HeaderMap::new(), "Payload Too Large").is_size_limit());
396    }
397
398    #[test]
399    fn a_coded_401_is_not_a_mime_rejection_but_an_uncoded_one_still_is() {
400        let h = headers(&[("x-error-code", "auth_expired")]);
401        assert!(!Refusal::from_response(401, &h, "").is_mime());
402        // Pre-existing behaviour for servers without the vocabulary, unchanged.
403        assert!(Refusal::from_response(401, &HeaderMap::new(), "Unauthorized").is_mime());
404    }
405
406    #[test]
407    fn a_garbage_code_header_is_ignored() {
408        let h = headers(&[("x-error-code", "Not A Code!")]);
409        assert_eq!(Refusal::from_response(500, &h, "").code(), None);
410    }
411
412    #[test]
413    fn retry_after_comes_from_the_header_or_the_body() {
414        let h = headers(&[("x-error-code", "rate_limited"), ("retry-after", "7")]);
415        assert_eq!(Refusal::from_response(429, &h, "").retry_after(), Some(Duration::from_secs(7)));
416        let r = Refusal::from_response(429, &headers(&[("x-error-code", "rate_limited")]), r#"{"retry_after":3}"#);
417        assert_eq!(r.retry_after(), Some(Duration::from_secs(3)));
418    }
419
420    #[test]
421    fn only_a_short_rate_limit_or_an_internal_error_is_retried_on_the_same_server() {
422        let coded = |code: &str, extra: &str| {
423            Refusal::from_response(400, &headers(&[("x-error-code", code)]), extra)
424        };
425        assert!(coded("rate_limited", r#"{"retry_after":5}"#).worth_retrying_here());
426        assert!(!coded("rate_limited", r#"{"retry_after":600}"#).worth_retrying_here());
427        assert!(coded("internal", "").worth_retrying_here());
428        assert!(!coded("quota_daily_exceeded", "").worth_retrying_here(), "tomorrow is not a retry");
429        assert!(!coded("client_not_permitted", "").worth_retrying_here());
430        assert!(!coded("auth_expired", "").worth_retrying_here());
431        assert!(!coded("blob_too_large", "").worth_retrying_here());
432    }
433
434    #[test]
435    fn an_uncoded_server_is_retried_unless_the_status_is_final() {
436        let plain = |status: u16, body: &str| Refusal::from_response(status, &HeaderMap::new(), body);
437        assert!(plain(500, "Internal Server Error").worth_retrying_here());
438        assert!(plain(408, "").worth_retrying_here());
439        assert!(!plain(413, "").worth_retrying_here());
440        assert!(!plain(415, "").worth_retrying_here());
441        assert!(!plain(503, "").worth_retrying_here(), "gateway statuses route around, as before");
442        assert!(!plain(524, "").worth_retrying_here());
443    }
444
445    #[test]
446    fn descriptions_name_the_host_and_the_numbers() {
447        let h = headers(&[("x-error-code", "quota_daily_exceeded")]);
448        let r = Refusal::from_response(413, &h, r#"{"used":1073741824,"limit":1073741824}"#);
449        assert_eq!(
450            r.describe("magnitude.jskitty.com"),
451            "Today's 1.0 GB upload allowance on magnitude.jskitty.com is used up (1.0 GB uploaded so far). It resets at midnight UTC.",
452        );
453        let h = headers(&[("x-error-code", "blob_too_large")]);
454        let r = Refusal::from_response(413, &h, r#"{"limit":26214400}"#);
455        assert_eq!(r.describe("m.example"), "m.example allows files up to 25.0 MB for your account.");
456        let r = Refusal::from_response(413, &HeaderMap::new(), "");
457        assert_eq!(r.describe("m.example"), "m.example won't accept a file this large.");
458    }
459
460    #[test]
461    fn display_keeps_the_status_shape_the_string_parsers_rely_on() {
462        let h = headers(&[("x-error-code", "blob_too_large")]);
463        let e = UploadFailure::Refused(Refusal::from_response(413, &h, r#"{"message":"too big"}"#));
464        assert_eq!(e.to_string(), "Upload failed with status 413 [blob_too_large]: too big");
465        assert_eq!(UploadFailure::Cancelled.to_string(), "Upload cancelled");
466    }
467
468    #[test]
469    fn mid_stream_drops_are_recognised_only_on_transport_failures() {
470        assert!(UploadFailure::Transport("Upload request failed: connection reset".into()).is_mid_stream_drop());
471        assert!(!UploadFailure::Transport("Upload stalled: x accepted nothing".into()).is_mid_stream_drop());
472        assert!(!UploadFailure::Other("connection reset".into()).is_mid_stream_drop());
473    }
474
475    #[test]
476    fn a_summary_lists_every_server_when_there_are_several() {
477        let a = UploadFailure::Transport("Upload request failed: x".into());
478        let b = UploadFailure::Refused(Refusal::from_response(415, &HeaderMap::new(), ""));
479        assert_eq!(summarise_failures(&[]), "No media server is configured. Add one in Settings → Network.");
480        assert_eq!(summarise_failures(&[("a.example".into(), a.clone())]), "a.example could not be reached.");
481        assert_eq!(
482            summarise_failures(&[("a.example".into(), a), ("b.example".into(), b)]),
483            "No media server accepted the file:\n• a.example could not be reached.\n• b.example doesn't accept this kind of file.",
484        );
485    }
486
487    #[test]
488    fn host_of_strips_the_url_down_to_its_name() {
489        assert_eq!(host_of("https://magnitude.jskitty.com/"), "magnitude.jskitty.com");
490        assert_eq!(host_of("http://localhost:8080"), "localhost");
491        assert_eq!(host_of("not a url"), "not a url");
492    }
493}