Log why the server refused a write, in its own words

A queued sidecar fails to upload with 403 on a credential that pushes the
catalog to the same library root in the same pass. `map_status` reduces every
non-success to a typed error, which is right for the application and leaves
nothing to work from: a read-only share, a file access control rule and a lock
all arrive as `PermissionDenied`.

Sabre says which in the response body. It is now logged on any failed PUT —
the URL, the status, and the first line naming the exception or message,
capped at 300 characters because an error page can be a whole document. Only
on failure; a success has no body worth reading.

Also adds a `put_probe` example that makes the same request from a stored
session and prints the reason, for diagnosing this from a desktop rather than
from a tablet's logcat. It needs a session on the machine it runs on, which is
why the log line above exists as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:08:07 +02:00
co-authored by Claude Opus 5
parent 09f6cf8c0f
commit 71554714e7
2 changed files with 116 additions and 1 deletions
+30 -1
View File
@@ -335,7 +335,36 @@ impl RemoteBackend for NextcloudBackend {
}
let resp = req.body(body).send().await.map_err(map_send_error)?;
map_status(resp.status(), path.as_str())?;
// A refused write is the one status whose *body* matters. Sabre names
// the exception class and the rule that refused — a read-only share, a
// file access control rule, a lock — and `map_status` reduces all of
// them to one typed error. That is right for the application and
// useless for working out which of them it is, so the reason is logged
// before it is discarded.
//
// Only on failure, and only the first line: a success has no body
// worth reading and an error page can be a whole document.
if !resp.status().is_success() {
let status = resp.status();
let url = self.url_for(path);
// `text()` consumes the response, which is why this branch returns
// rather than falling through to read the headers below.
let body = resp.text().await.unwrap_or_default();
let reason = body
.lines()
.map(str::trim)
.find(|l| l.contains("message") || l.contains("exception"))
.unwrap_or_else(|| body.trim())
.chars()
.take(300)
.collect::<String>();
log::warn!("PUT {url} -> {status}: {reason}");
map_status(status, path.as_str())?;
// `map_status` returns `Err` for every non-success, so this is
// unreachable; stated rather than left to inference.
unreachable!("a non-success status always maps to an error");
}
resp.headers()
.get(reqwest::header::ETAG)