Skip to content

Commit 4483521

Browse files
authored
fix: end multipart responses with the closing boundary (#592)
## What `multipart/mixed` responses now end with the closing boundary line that the MIME format requires. ``` Before (end of response): After (end of response): <last part data> <last part data> --3f9c...-- ``` ## Why Without the closing line, strict multipart parsers report the response as incomplete. If processing fails partway through, the closing line is still left out, so clients can tell a cut-off response from a complete one. ## Validation Tests parse the response with Python's `email` parser (no defects) for one part, several parts, and no parts, and cover an interrupted stream. They fail on `main`. Adds a CHANGELOG entry and bumps the version to 0.1.15. Refreshed against main after #590 and #591 merged. Targeted API tests: 43 passed; response compatibility tests: 4 passed. Changed-source lint passed.
1 parent 019b696 commit 4483521

4 files changed

Lines changed: 85 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.1.15
2+
3+
### Fixes
4+
5+
- **Terminate multipart responses with the closing boundary**: `multipart/mixed` responses ended after the last part without the closing `--boundary--` delimiter, so strict MIME parsers reported an incomplete message. The closing delimiter is now sent when the stream completes. A stream that fails partway still ends without it.
6+
17
## 0.1.14
28

39
### Fixes
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.14" # pragma: no cover
1+
__version__ = "0.1.15" # pragma: no cover

‎prepline_general/api/general.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from starlette.datastructures import Headers
3232
from starlette.types import Send
3333

34+
from prepline_general.api import __version__ as api_version
3435
from prepline_general.api.filetypes import get_validated_mimetype
3536
from prepline_general.api.models.form_params import GeneralFormParams
3637
from unstructured.documents.elements import Element
@@ -41,19 +42,18 @@
4142
elements_from_json,
4243
)
4344
from unstructured_inference.models.base import UnknownModelException
44-
from prepline_general.api import __version__ as api_version
4545

4646
app = FastAPI()
4747
router = APIRouter()
4848

4949

5050
def is_compatible_response_type(media_type: str, response_type: type) -> bool:
5151
"""True when `response_type` can be converted to `media_type` for HTTP Response."""
52-
return (
53-
False
54-
if media_type == "application/json" and response_type not in [dict, list]
55-
else False if media_type == "text/csv" and response_type != str else True
56-
)
52+
if media_type == "application/json":
53+
return response_type in [dict, list]
54+
if media_type == "text/csv":
55+
return response_type is str
56+
return True
5757

5858

5959
logger = logging.getLogger("unstructured_api")
@@ -606,7 +606,13 @@ async def stream_response(self, send: Send) -> None:
606606
{"type": "http.response.body", "body": self.build_part(chunk), "more_body": True}
607607
)
608608

609-
await send({"type": "http.response.body", "body": b"", "more_body": False})
609+
await send(
610+
{
611+
"type": "http.response.body",
612+
"body": self.boundary + b"--" + self.CRLF,
613+
"more_body": False,
614+
}
615+
)
610616

611617

612618
def ungz_file(file: UploadFile, gz_uncompressed_content_type: Optional[str] = None) -> UploadFile:
@@ -651,7 +657,7 @@ def general_partition(
651657
# cannot use annotated type here because of a bug described here:
652658
# https://github.com/tiangolo/fastapi/discussions/10280
653659
# The openapi metadata must be added separately in openapi.py file.
654-
# TODO: Check if the bug is fixed and change the declaration to use Annotated[List[UploadFile], File(...)]
660+
# TODO: Check whether FastAPI supports Annotated[List[UploadFile], File(...)].
655661
# For new parameters - add them in models/form_params.py
656662
files: List[UploadFile],
657663
form_params: GeneralFormParams = Depends(GeneralFormParams.as_form),
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import asyncio
2+
from email import policy
3+
from email.parser import BytesParser
4+
5+
import pytest
6+
7+
from prepline_general.api.general import MultipartMixedResponse
8+
9+
10+
async def collect_response(response):
11+
messages = []
12+
13+
async def send(message):
14+
messages.append(message)
15+
16+
await response.stream_response(send)
17+
return messages
18+
19+
20+
@pytest.mark.parametrize("parts", [["one"], ["one", "two"]])
21+
def test_normal_multipart_response_closes_boundary_and_preserves_parts(parts):
22+
response = MultipartMixedResponse(iter(parts), content_type="text/plain")
23+
messages = asyncio.run(collect_response(response))
24+
body = b"".join(message.get("body", b"") for message in messages)
25+
content_type = response.headers["content-type"].encode()
26+
parsed = BytesParser(policy=policy.default).parsebytes(
27+
b"Content-Type: " + content_type + b"\r\nMIME-Version: 1.0\r\n\r\n" + body
28+
)
29+
30+
assert parsed.defects == []
31+
assert [part.get_payload(decode=True).decode() for part in parsed.iter_parts()] == parts
32+
assert messages[-1] == {
33+
"type": "http.response.body",
34+
"body": response.boundary + b"--\r\n",
35+
"more_body": False,
36+
}
37+
assert all(message["more_body"] for message in messages[1:-1])
38+
39+
40+
def test_empty_iterator_emits_terminal_boundary():
41+
response = MultipartMixedResponse(iter([]))
42+
messages = asyncio.run(collect_response(response))
43+
44+
assert messages[-1]["body"] == response.boundary + b"--\r\n"
45+
assert messages[-1]["more_body"] is False
46+
47+
48+
def test_interrupted_stream_does_not_emit_terminal_boundary():
49+
messages = []
50+
51+
async def failing_parts():
52+
yield "one"
53+
raise RuntimeError("partition failed")
54+
55+
async def send(message):
56+
messages.append(message)
57+
58+
response = MultipartMixedResponse(failing_parts(), content_type="text/plain")
59+
with pytest.raises(RuntimeError, match="partition failed"):
60+
asyncio.run(response.stream_response(send))
61+
62+
assert len(messages) == 2
63+
assert messages[-1]["more_body"] is True
64+
assert not messages[-1]["body"].endswith(response.boundary + b"--\r\n")

0 commit comments

Comments
 (0)