|
1 | 1 | """Tests for the exponential backoff retry logic in APIDeploymentsClient.""" |
2 | 2 |
|
3 | 3 | import io |
| 4 | +import json |
| 5 | +import threading |
| 6 | +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
4 | 7 | from unittest.mock import MagicMock, patch |
5 | 8 |
|
6 | 9 | import pytest |
@@ -743,3 +746,63 @@ def test_200_pending_sets_pending_true(self, mock_post, tmp_path): |
743 | 746 | result = c.structure_file([str(test_file)]) |
744 | 747 | assert result["pending"] is True |
745 | 748 | 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