1use reqwest::header::HeaderMap;
19use serde_json::{json, Map, Value};
20use std::time::Duration;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Retry {
25 Never,
27 Later,
29 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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Refusal {
47 pub status: u16,
48 pub code: Option<String>,
51 pub retry: Option<Retry>,
52 pub message: String,
54 pub details: Map<String, Value>,
56}
57
58fn 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 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 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 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 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 pub fn worth_retrying_here(&self) -> bool {
156 match self.code() {
157 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 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
229const MAX_RETRY_AFTER: Duration = Duration::from_secs(15);
231
232pub fn is_gateway_status(status: u16) -> bool {
235 matches!(status, 502 | 503 | 504 | 520..=526)
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum UploadFailure {
241 Cancelled,
243 Refused(Refusal),
245 Transport(String),
247 Integrity(String),
250 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 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 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 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
320pub 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
329pub 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 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 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}