From e04dde7f169584eab770fe08296370be1d2c8211 Mon Sep 17 00:00:00 2001 From: Tanmay Bajaj Date: Wed, 28 Oct 2026 14:34:54 +0530 Subject: [PATCH] fix: return 400 instead of 500 for corrupt document containers Deterministic parse failures on corrupt uploads (truncated OLE files raising olefile.olefile.OleFileError, truncated OOXML packages raising zipfile.BadZipFile, invalid spreadsheets raising unstructured.errors.UnprocessableEntityError) were falling through the OSError/ValueError handlers to the app-level catch-all and returning 500. Since olefile's OleFileError subclasses OSError, the new clause must run before the OSError handler. These are client-side problems: 400 with a descriptive detail, so callers can classify them as bad input rather than transient server errors and retry. Co-Authored-By: Claude Code --- prepline_general/api/general.py | 21 +++++++++++++++++++++ pyproject.toml | 2 ++ test_general/api/test_app.py | 28 ++++++++++++++++++++++++++++ uv.lock | 2 ++ 4 files changed, 53 insertions(+) diff --git a/prepline_general/api/general.py b/prepline_general/api/general.py index 04142dcc..aa581177 100644 --- a/prepline_general/api/general.py +++ b/prepline_general/api/general.py @@ -7,6 +7,7 @@ import mimetypes import os import secrets +import zipfile from base64 import b64encode from concurrent.futures import ThreadPoolExecutor from functools import partial @@ -26,10 +27,12 @@ status, ) from fastapi.responses import PlainTextResponse, StreamingResponse +from olefile.olefile import OleFileError # note: olefile <0.48 does not re-export it at top level from pypdf import PageObject, PdfReader, PdfWriter from pypdf.errors import FileNotDecryptedError, PdfReadError from starlette.datastructures import Headers from starlette.types import Send +from unstructured.errors import UnprocessableEntityError from prepline_general.api import __version__ as api_version from prepline_general.api.filetypes import get_validated_mimetype @@ -397,6 +400,24 @@ def pipeline_api( else: elements = partition(**partition_kwargs) # type: ignore # pyright: ignore[reportGeneralTypeIssues] + except OleFileError as e: + # OleFileError subclasses OSError, so it must be caught before the OSError + # handler below. Corrupt or truncated OLE files (.doc, .ppt, .msg) are a + # client-side problem and should not surface as a 500. + raise HTTPException( + status_code=400, + detail=f"File does not appear to be a valid OLE file: {e}", + ) + except zipfile.BadZipFile as e: + raise HTTPException( + status_code=400, + detail=f"File does not appear to be a valid OOXML file: {e}", + ) + except UnprocessableEntityError as e: + raise HTTPException( + status_code=400, + detail=str(e), + ) except OSError as e: if isinstance(e.args[0], str) and ( "chipper-fast-fine-tuning is not a local folder" in e.args[0] diff --git a/pyproject.toml b/pyproject.toml index 0668cd7e..706325f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "psutil >=7.0.0, <8.0.0", "pypdf >=6.0.0, <7.0.0", "requests >=2.32.0, <3.0.0", + # imported directly for OleFileError; also a transitive dep of unstructured + "olefile >=0.47, <1.0.0", ] [dependency-groups] diff --git a/test_general/api/test_app.py b/test_general/api/test_app.py index ac9cb6fb..999e6e4e 100644 --- a/test_general/api/test_app.py +++ b/test_general/api/test_app.py @@ -110,6 +110,34 @@ def test_general_api(example_filename, content_type): assert len(dfs) > 0 +@pytest.mark.parametrize( + "example_filename, content_type", + [ + ("fake.doc", "application/msword"), + ( + "fake-power-point.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ], +) +def test_truncated_binary_documents_return_400(example_filename, content_type): + """ + A truncated binary document is a client-side problem and must return 400, + not the catch-all 500. Truncating a valid fixture mid-file corrupts the + OLE/ZIP container without changing its detected file type. + """ + client = TestClient(app) + test_file_path = Path("sample-docs") / example_filename + with open(test_file_path, "rb") as f: + data = f.read() + response = client.post( + MAIN_API_ROUTE, + files=[("files", (example_filename, io.BytesIO(data[: len(data) // 2]), content_type))], + ) + + assert response.status_code == 400 + + def test_metadata_fields_removed(): """ Verify that responses do not include coordinates unless requested diff --git a/uv.lock b/uv.lock index 576451b6..7e09f575 100644 --- a/uv.lock +++ b/uv.lock @@ -2201,6 +2201,7 @@ source = { editable = "." } dependencies = [ { name = "backoff" }, { name = "fastapi" }, + { name = "olefile" }, { name = "pandas" }, { name = "psutil" }, { name = "pypdf" }, @@ -2237,6 +2238,7 @@ test = [ requires-dist = [ { name = "backoff", specifier = ">=2.2.1,<3.0.0" }, { name = "fastapi", specifier = ">=0.128.4,<1.0.0" }, + { name = "olefile", specifier = ">=0.47,<1.0.0" }, { name = "pandas", specifier = ">=3.0.0,<4.0.0" }, { name = "psutil", specifier = ">=7.0.0,<8.0.0" }, { name = "pypdf", specifier = ">=6.0.0,<7.0.0" },