Every call to the server fails the same way through client::send

Seven calls each spelled out send, describe the transport error, map a 401, and
refuse anything else as unexpected_status; two read the server's {"error"} words
the same way. send_raw (transport + a 401 whose meaning the caller names), send
(and anything but success is unexpected) and server_reason now hold those steps.
Each call keeps only what is its own: device_login's and fetch_identity's 401
wording, the release's 404 = none, sharing's 404 and refusal reason, upload's
retry split.

DRY pass #2, batch 2, F6 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:19:38 -04:00
co-authored by Claude Opus 5.5
parent 0101b05487
commit 4ac15d3557
+56 -98
View File
@@ -11,7 +11,7 @@
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use reqwest::{RequestBuilder, StatusCode}; use reqwest::{RequestBuilder, Response, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::compat::{self, Compatibility, ServerInfo}; use super::compat::{self, Compatibility, ServerInfo};
@@ -154,6 +154,46 @@ fn unexpected_status(base_url: &str, status: StatusCode) -> String {
) )
} }
/// Send `request`, failing the way every call to the server fails alike: a
/// transport error, described for a person, and a 401, as `unauthorized` (what a
/// rejected credential means depends on the call). Any other status is the
/// caller's to read; [`send`] is this plus "anything but success is unexpected".
async fn send_raw(
base_url: &str,
request: RequestBuilder,
unauthorized: &str,
) -> Result<Response, String> {
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
if response.status() == StatusCode::UNAUTHORIZED {
return Err(unauthorized.to_string());
}
Ok(response)
}
/// [`send_raw`], and anything but a success is [`unexpected_status`].
async fn send(
base_url: &str,
request: RequestBuilder,
unauthorized: &str,
) -> Result<Response, String> {
let response = send_raw(base_url, request, unauthorized).await?;
let status = response.status();
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
Ok(response)
}
/// The server's own words for a refusal, when it gave some (`{"error": "…"}`).
/// "file is too large (max 25 MB)" is what a person can act on; a status is not.
async fn server_reason(response: Response) -> Option<String> {
let body = response.json::<serde_json::Value>().await.ok()?;
body.get("error")?.as_str().map(String::from)
}
/// Ask a server who it is and whether we can sync with it. /// Ask a server who it is and whether we can sync with it.
/// ///
/// `Err` means we never got a usable answer (bad address, unreachable, not an /// `Err` means we never got a usable answer (bad address, unreachable, not an
@@ -211,18 +251,8 @@ pub async fn device_login(
"name": device_name, "name": device_name,
}); });
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body); let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
let response = request let refused = "That email and password didn't match an account on this server.";
.send() let response = send(base_url, request, refused).await?;
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err("That email and password didn't match an account on this server.".to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let parsed: DeviceLoginResponse = response let parsed: DeviceLoginResponse = response
.json() .json()
@@ -238,20 +268,9 @@ pub async fn device_login(
/// far from the thing that caused it. /// far from the thing that caused it.
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> { pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
let request = prepare(http()?.get(me_url(base_url)), Some(token)); let request = prepare(http()?.get(me_url(base_url)), Some(token));
let response = request let refused = "That token isn't valid on this server — it may have been revoked. \
.send() Issue a new one from the web app under Account → Linked devices.";
.await let response = send(base_url, request, refused).await?;
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
let message = "That token isn't valid on this server — it may have been revoked. \
Issue a new one from the web app under Account → Linked devices.";
return Err(message.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response response
.json() .json()
@@ -277,18 +296,7 @@ pub async fn fetch_changes(
format!("{base_url}/api/sync/changes?since={since}") format!("{base_url}/api/sync/changes?since={since}")
}; };
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)); let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = request let response = send(base_url, request, TOKEN_REJECTED).await?;
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response response
.json() .json()
@@ -308,18 +316,7 @@ pub async fn fetch_attachment(
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}"); let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)); let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = request let response = send(base_url, request, TOKEN_REJECTED).await?;
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response response
.bytes() .bytes()
@@ -378,11 +375,8 @@ pub async fn upload_attachment(
} }
// The server's own words, when it gave some: "file is too large (max 25 MB)" is // The server's own words, when it gave some: "file is too large (max 25 MB)" is
// what a person can act on, and a bare status code is not. // what a person can act on, and a bare status code is not.
let reason = response let reason = server_reason(response)
.json::<serde_json::Value>()
.await .await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); .unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
if status.is_client_error() && status != StatusCode::NOT_FOUND { if status.is_client_error() && status != StatusCode::NOT_FOUND {
Err(UploadError::Refused(reason)) Err(UploadError::Refused(reason))
@@ -406,18 +400,7 @@ pub async fn push_changes<T: Serialize>(
let body = serde_json::json!({ "changes": changes }); let body = serde_json::json!({ "changes": changes });
let url = format!("{base_url}/api/sync/push"); let url = format!("{base_url}/api/sync/push");
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body); let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
let response = request let response = send(base_url, request, TOKEN_REJECTED).await?;
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response response
.text() .text()
@@ -509,24 +492,14 @@ async fn read_reply<T: serde::de::DeserializeOwned>(
base_url: &str, base_url: &str,
request: RequestBuilder, request: RequestBuilder,
) -> Result<T, String> { ) -> Result<T, String> {
let response = request let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status(); let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if status == StatusCode::NOT_FOUND { if status == StatusCode::NOT_FOUND {
return Err(SHARE_NOT_FOUND.to_string()); return Err(SHARE_NOT_FOUND.to_string());
} }
if !status.is_success() { if !status.is_success() {
// The server's own words when it gave some ("choose someone to share with"). // The server's own words when it gave some ("choose someone to share with").
let reason = response let reason = server_reason(response).await;
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status))); return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
} }
response response
@@ -717,18 +690,13 @@ pub async fn fetch_client_release(
token: &str, token: &str,
) -> Result<Option<ClientRelease>, String> { ) -> Result<Option<ClientRelease>, String> {
let url = format!("{base_url}/api/client/android"); let url = format!("{base_url}/api/client/android");
let response = prepare(http()?.get(url), Some(token)) let request = prepare(http()?.get(url), Some(token));
.send() let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status(); let status = response.status();
if status == StatusCode::NOT_FOUND { if status == StatusCode::NOT_FOUND {
return Ok(None); return Ok(None);
} }
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() { if !status.is_success() {
return Err(unexpected_status(base_url, status)); return Err(unexpected_status(base_url, status));
} }
@@ -768,18 +736,8 @@ pub async fn download_client(
let path = release.url.trim_start_matches('/'); let path = release.url.trim_start_matches('/');
let url = format!("{base_url}/{path}"); let url = format!("{base_url}/{path}");
let mut response = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token)) let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
.send() let mut response = send(base_url, request, TOKEN_REJECTED).await?;
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let status = response.status();
if status == StatusCode::UNAUTHORIZED {
return Err(TOKEN_REJECTED.to_string());
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
let partial = dest.with_extension("part"); let partial = dest.with_extension("part");
if let Some(parent) = partial.parent() { if let Some(parent) = partial.parent() {