Store: a file type Paperless does not consume is uploaded six times and ends as a bare "HTTP 400 #14
Labels
No labels
bug
documentation
enhancement
performance
security
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo-paperless#14
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo-paperless at
973969bb7, running_explainagainst the error bodies Django REST Framework produces and reading Paperless-ngx'sPostDocumentSerializer.validate_document.When Paperless refuses a file because of its type, the plugin neither says why nor stops offering it. The reason is dropped from the message, and the refusal is treated as a failure to retry, so the whole file is uploaded again on every attempt.
1. The reason is dropped
src/postulo_paperless/store.py:88-95(_explain)._explainreads only a top-leveldetailkey:detail = body.get("detail", "") if isinstance(body, dict) else "". Django REST Framework usesdetailfor authentication and permission errors. A validation refusal is a mapping of field name to a list of messages, and Paperless's refusal of a file type is one of those:validate_documentraisesserializers.ValidationError(_("File type %(type)s not supported") % ...), which reaches the client as{"document": ["File type ... not supported"]}. Run against the snapshot,_explainanswers'HTTP 400'for that body, for a body with several field errors, and for a list body.2. The refusal is retried instead of declined
src/postulo_paperless/store.py:149-157(Archive.post_document) and:311-334(PaperlessStore.put), which has no path that returnsNone.putreturnNoneto say not for me (plugins/base.py:784-787in the core), whichsend_copyrecords as not accepted with no further attempt (documents/archiving.py:155-160). This plugin raises instead, so the core retries on the growing wait. Postulo accepts uploads ofpdf,doc,docx,odt,rtf,txt,png,jpgandjpeg(documents/models.py:458-460), up to 20 MB (documents/forms.py:26); a Paperless without Tika consumes no office formats. A.docxreference is therefore sent in full six times over about two and a half hours, each attempt preceded by the look-ups that create the document type and correspondent, and ends as failed.3. The test passes on a body Paperless does not send
Where:
tests/test_paperless.py:68-69and:318-321.What happens:
test_a_refusal_is_explainedpasses because the fake answershttpx.Response(400, json={"detail": self.reject_with}). Paperless puts that sentence underdocument, in a list.Fix: make
_explainflatten field errors: for a mapping withoutdetail, join each key's messages; accept a list body; keep the 120-character cut for anything long. Inpost_document, treat a 400 whose errors are on thedocumentfield as a refusal of the file and haveputreturnNone, so the copy is not accepted once. Change the fake to answer{"document": [...]}.Done when
test_a_refusal_is_explainedintests/test_paperless.pyuses a fake that answers{"document": ["File type ... not supported"]}; with that body it fails today.tests/test_paperless.pyshows that such a refusal makesputreturnNone, and, throughsend_pending(), that the copy ends asCopyStatus.DECLINEDafter one upload._explainnames the field and message for a 400 on another field.From the 2026-10-16 security review: since this issue rewrites
_explain, please fold one more change into it._explain(store.py:88-95) copies the server'sdetailuncapped, orresponse.text[:120], with newlines kept:The core caps the copy's
last_errorat 500 (archiving.py:134) and the page renders it escaped, butarchiving.py:188-190also sends the exception tologger.exception(...), and the console formatter does not fold newlines (core/logs.py:146-152). So whatever answers as the Paperless server can write lines of its choosing into the operator's log beside real entries, against the core's own rule that "a log line is one place a newline from a stranger must never land" (core/logs.py:88-90). Informational: the server is one the account holder configured.Fix, for every branch of the new
_explain, including the DRF field errors this issue adds:detail = " ".join(str(detail).split())[:120].Done when a test whose answer carries a
detailwith a newline (and one with 1 000 characters) gets a one-line message of at most 120 characters of detail.Fixed by pull request #18.