Skip to content

Commit da9db87

Browse files
UN-4010 [MISC] pin that a retried upload replays the whole body
The multipart body is built from open file handles and every attempt re-encodes them, so a retry that reached the wire with the handles at EOF would upload a body missing the file. Nothing exercised that: the retry tests stub the transport, where the encoding never runs. Add a fault-injected retry over a loopback server that answers 502 then 200 and asserts both attempts carry the same declared length with the file bytes still in them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
1 parent 3716ab0 commit da9db87

1 file changed

Lines changed: 63 additions & 0 deletions

File tree

‎tests/test_retry.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for the exponential backoff retry logic in APIDeploymentsClient."""
22

33
import io
4+
import json
5+
import threading
6+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
from unittest.mock import MagicMock, patch
58

69
import pytest
@@ -743,3 +746,63 @@ def test_200_pending_sets_pending_true(self, mock_post, tmp_path):
743746
result = c.structure_file([str(test_file)])
744747
assert result["pending"] is True
745748
assert result["status_code"] == 200
749+
750+
751+
def test_a_retried_upload_replays_the_whole_body(tmp_path):
752+
"""A retry has to put the same bytes on the wire as the first attempt.
753+
754+
The multipart body is built from open file handles, which the first
755+
attempt reads to EOF. Without a rewind between attempts the retry sends a
756+
body missing the file it is uploading -- and no test that stubs the
757+
transport can see it, because the encoding is what drops the bytes.
758+
"""
759+
content = b"PDFBYTES" * 512
760+
upload = tmp_path / "sample.pdf"
761+
upload.write_bytes(content)
762+
attempts = []
763+
764+
class Handler(BaseHTTPRequestHandler):
765+
protocol_version = "HTTP/1.1"
766+
767+
def log_message(self, *args):
768+
pass
769+
770+
def do_POST(self): # noqa: N802 -- the name BaseHTTPRequestHandler dispatches to
771+
declared = int(self.headers.get("Content-Length") or 0)
772+
body = self.rfile.read(declared)
773+
attempts.append((declared, len(body), content in body))
774+
first = len(attempts) == 1
775+
payload = json.dumps(
776+
{"message": "upstream unavailable"}
777+
if first
778+
else {"message": {"execution_status": "COMPLETED", "result": "ok"}}
779+
).encode()
780+
self.send_response(502 if first else 200)
781+
self.send_header("Content-Type", "application/json")
782+
self.send_header("Content-Length", str(len(payload)))
783+
self.end_headers()
784+
self.wfile.write(payload)
785+
786+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
787+
server.daemon_threads = True
788+
threading.Thread(target=server.serve_forever, daemon=True).start()
789+
c = APIDeploymentsClient(
790+
api_url=f"http://127.0.0.1:{server.server_address[1]}/deploy",
791+
api_key="test-key",
792+
logging_level="ERROR",
793+
max_retries=2,
794+
initial_delay=0.01,
795+
max_delay=0.02,
796+
)
797+
try:
798+
# Queuing mode: the synchronous mode deliberately does not retry an
799+
# upload, so this is the only path a second attempt can be reached on.
800+
c.structure_file([str(upload)], timeout=-1)
801+
finally:
802+
server.shutdown()
803+
server.server_close()
804+
805+
assert len(attempts) == 2, "the 502 was not retried, so nothing was replayed"
806+
declared, received, carried_file = attempts[0]
807+
assert carried_file
808+
assert attempts == [(declared, declared, True)] * 2

0 commit comments

Comments
 (0)