Two bugs I only found by running my Rust sync daemon against real infrastructure
I built notionless, a small Rust daemon that mirrors a Notion database into Paperless-ngx (a self-hosted document archive) as Markdown, so notes end up in the same full-text-searchable place as everything else you own.
cargo build, cargo test, and cargo clippy were all green the whole time. Two real bugs only showed up once I pointed it at my actual, internet-facing Paperless instance, and both taught me something about reqwest and API design that unit tests couldn't have caught.
Bug 1: reqwest silently drops the Authorization header on a scheme-change redirect
Paperless-ngx paginates results with a next field containing the full URL for the next page. Behind a reverse proxy without X-Forwarded-Proto set, Paperless believes its own scheme is http://, even though the instance is actually served over https://. My client followed that next URL as-is:
let response = client
.get(&url) // url came straight from Paperless' `next` field
.header("Authorization", format!("Token {}", token))
.send()
.await?
.json::<PaperlessResponse>()
.await?;
First page: fine. Second page onward: a 401, and a JSON decode error, because the 401 body didn't match my expected response struct. The proxy was redirecting http:// to https:// transparently, and reqwest follows redirects by default, but a scheme change counts as a different origin, so it strips the Authorization header on the hop. The request that reached Paperless carried no token at all.
The fix wasn't "always upgrade to https", since that breaks the equally common case of Paperless running on a plain http:// LAN address with no proxy in front of it at all. Instead, I only take the path and query from next and rebuild the URL against the configured base, never trusting the scheme Paperless reports back:
fn normalize_next_url(next: &str, base: &str) -> String {
let path_and_query = match next.find("://") {
Some(scheme_end) => {
let after_scheme = &next[scheme_end + 3..];
match after_scheme.find('/') {
Some(path_start) => &after_scheme[path_start..],
None => "/",
}
}
None => next,
};
format!("{}{}", base, path_and_query)
}
A LAN instance stays http, a proxied instance stays https, and the token survives every page.
Bug 2: "no error" isn't the same as "it worked"
Paperless processes uploads asynchronously: POST /api/documents/post_document/ returns a 200 with a task ID immediately, before the document is actually validated or stored. Whether it was accepted, rejected as a duplicate, or failed outright only shows up later in a separate task-status endpoint.
My first version treated the 200 as success and moved on, so uploads that were silently rejected (e.g. because the exact same file already existed) just vanished with no error and no log line. The fix was to poll the task endpoint until it resolves, and to make the resulting enum do the explaining instead of a Result<(), E> that can't distinguish "succeeded" from "the server said no, but the HTTP request itself didn't fail":
enum TaskResult {
Success,
Failure(String), // Paperless' plain-text reason, e.g. a duplicate message
Timeout,
}
That, in turn, surfaced a second-order problem: Paperless detects duplicates by hashing the raw file bytes, so re-syncing a page whose Paperless document already existed (e.g. from before I started using this tool) would get rejected forever, every cycle, since the content was already there under an unlinked document. Now, on a duplicate rejection, the daemon parses the existing document's ID out of Paperless' error message and links it to the Notion page instead of re-uploading - a five-minute bug turned into a small feature.
Repo: https://github.com/Script-hpp/notionless (Rust, MIT licensed, Dockerfile included).
Happy to talk through any of the design choices in the comments. This article was drafted by Claude (Anthropic's coding assistant) based on my description of the bugs and fixes; I reviewed it and verified every technical claim against my own live Paperless/Notion instance and the project's source.
Comments
No comments yet. Start the discussion.