Skip to content

Commit 27dd806

Browse files
fix: follow redirects, refuse a blank execution id, translate InvalidURL
A 3xx was returned as if it were the answer, which a poll loop reads as a finished execution with no status; the previous transport followed redirects on both verbs. A status endpoint carrying no execution id now fails instead of polling for a blank one. InvalidURL joins the translation table, and the docstring names the two httpx families that stay outside it. Adds the multi-file upload comparison the parity suite never had, and lets the drift gate see a file the generator newly creates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
1 parent 54f09f4 commit 27dd806

4 files changed

Lines changed: 139 additions & 25 deletions

File tree

.github/workflows/test.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@ jobs:
5959
- name: Regenerate from the committed spec
6060
run: ./tools/gen_sdk.sh
6161

62+
# `git add -N` first: a diff alone cannot see a file the generator has
63+
# newly created, which is exactly what a spec growing an endpoint does.
6264
- name: Fail if the committed SDK is not what the spec generates
63-
run: git diff --exit-code -- src/unstract/api_deployments/sdk_docstudio
65+
run: |
66+
git add -N -- src/unstract/api_deployments/sdk_docstudio
67+
git diff --exit-code -- src/unstract/api_deployments/sdk_docstudio

src/unstract/api_deployments/client.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
ConnectionError,
2525
ConnectTimeout,
2626
ContentDecodingError,
27+
InvalidURL,
2728
MissingSchema,
2829
ProxyError,
2930
ReadTimeout,
@@ -52,9 +53,11 @@ def _translate_transport_errors(fn, *args, **kwargs):
5253
5354
Callers document and catch the ``requests`` classes, and the retry policy
5455
keys off them too, so the class chosen here decides whether a failure is
55-
retried. Every branch is ordered before the base class it derives from, and
56-
``RequestError`` is the catch-all that keeps a novel failure from escaping
57-
untranslated.
56+
retried. Every branch is ordered before the base class it derives from.
57+
``RequestError`` is the catch-all for the transport subtree, which is where
58+
a novel failure appears. httpx puts three families outside it: ``InvalidURL``,
59+
translated here because ``requests`` raised its own, and ``StreamError`` and
60+
``CookieConflict``, which propagate as themselves.
5861
"""
5962
try:
6063
return fn(*args, **kwargs)
@@ -82,13 +85,25 @@ def _translate_transport_errors(fn, *args, **kwargs):
8285
raise TooManyRedirects(str(e)) from e
8386
except httpx.DecodingError as e:
8487
raise ContentDecodingError(str(e)) from e
88+
except httpx.InvalidURL as e:
89+
raise InvalidURL(str(e)) from e
8590
except httpx.RequestError as e:
8691
raise ConnectionError(str(e)) from e
8792

8893

8994
def _query_value(url: str, key: str) -> str:
90-
"""Read one query parameter out of a URL, absolute or relative."""
91-
return parse_qs(urlparse(url).query).get(key, [""])[0]
95+
"""Read one required query parameter out of a URL, absolute or relative.
96+
97+
Empty is not a usable value here: it polls for an execution the service
98+
cannot identify and reports whatever it makes of a blank id.
99+
"""
100+
value = parse_qs(urlparse(url).query).get(key, [""])[0]
101+
if not value:
102+
raise APIDeploymentsClientException(
103+
f"No {key} in {url!r}. The status endpoint the service returned "
104+
"carries it; pass that endpoint unmodified."
105+
)
106+
return value
92107

93108

94109
class APIDeploymentsClientException(Exception):
@@ -266,6 +281,10 @@ def _transport(self):
266281
verify_ssl=self.verify,
267282
timeout=httpx.Timeout(self.transport_timeout),
268283
raise_on_unexpected_status=False,
284+
# The previous transport followed redirects. Without this a 30x
285+
# from a load balancer is read as a terminal result with no
286+
# status, which a poll loop reports as a finished-and-empty job.
287+
follow_redirects=True,
269288
)
270289
return self._transport_client
271290

tests/test_compat.py

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import inspect
1414
import io
1515
import json
16+
import re
1617
import socket
1718
import threading
1819
from pathlib import Path
@@ -38,6 +39,7 @@
3839
_EXECUTE_SEND_ONLY,
3940
_STATUS_SEND_ONLY,
4041
APIDeploymentsClient,
42+
APIDeploymentsClientException,
4143
)
4244

4345
BASELINE_VERSION = "1.5.3"
@@ -623,15 +625,15 @@ def test_execute_url_matches_the_deployment_url(api_url):
623625
assert args[1] == api_url == mock_requests.post.call_args[0][0]
624626

625627

626-
def _wire_heads(*calls):
627-
"""Run each call against a loopback server and return its request headers.
628+
def _wire_requests(*calls, reply=b'{"status":"COMPLETED","message":{}}'):
629+
"""Run each call against a loopback server and return the raw requests.
628630
629631
Below the client, the transport adds headers of its own -- and drops none
630632
of them into any object the client can be asked for. A socket is the only
631633
place both clients can be compared on what they actually send. One server
632634
serves every call, so the ``Host`` header is the same for all of them.
633635
"""
634-
heads = []
636+
raw = []
635637
server = socket.socket()
636638
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
637639
server.bind(("127.0.0.1", 0))
@@ -646,8 +648,17 @@ def serve():
646648
if not chunk:
647649
break
648650
data += chunk
649-
heads.append(data.split(b"\r\n\r\n")[0])
650-
body = b'{"status":"COMPLETED","message":[]}'
651+
# The body has to be drained too: a client whose upload is never
652+
# read can block on the socket instead of returning.
653+
head, _, rest = data.partition(b"\r\n\r\n")
654+
declared = _header_value(head, "content-length")
655+
while declared and len(rest) < int(declared):
656+
chunk = conn.recv(65536)
657+
if not chunk:
658+
break
659+
rest += chunk
660+
raw.append(head + b"\r\n\r\n" + rest)
661+
body = reply
651662
conn.sendall(
652663
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
653664
b"Content-Length: %d\r\n\r\n%s" % (len(body), body)
@@ -664,15 +675,51 @@ def serve():
664675
thread.join(timeout=10)
665676
server.close()
666677

667-
return [
668-
{
669-
name.lower(): value.strip()
670-
for name, _, value in (
671-
line.partition(":") for line in head.decode().split("\r\n")[1:]
678+
return raw
679+
680+
681+
def _headers(head: bytes) -> dict[str, str]:
682+
return {
683+
name.lower(): value.strip()
684+
for name, _, value in (
685+
line.partition(":") for line in head.decode().split("\r\n")[1:]
686+
)
687+
}
688+
689+
690+
def _header_value(head: bytes, name: str) -> str:
691+
return _headers(head).get(name, "")
692+
693+
694+
def _wire_heads(*calls):
695+
"""The request headers each call put on the wire."""
696+
return [_headers(raw.split(b"\r\n\r\n")[0]) for raw in _wire_requests(*calls)]
697+
698+
699+
def _multipart_parts(raw: bytes) -> list[tuple[str, str, bytes]]:
700+
"""``(field, filename, content)`` for every part of a multipart request.
701+
702+
The boundary itself is deliberately not compared: it is random per request
703+
in both clients, so only what it delimits can be.
704+
"""
705+
head, _, body = raw.partition(b"\r\n\r\n")
706+
boundary = _header_value(head, "content-type").partition("boundary=")[2]
707+
parts = []
708+
for chunk in body.split(b"--" + boundary.encode()):
709+
headers, _, content = chunk.partition(b"\r\n\r\n")
710+
disposition = headers.decode("utf-8", errors="replace")
711+
if "content-disposition" not in disposition.lower():
712+
continue
713+
field = re.search(r'name="([^"]*)"', disposition)
714+
filename = re.search(r'filename="([^"]*)"', disposition)
715+
parts.append(
716+
(
717+
field.group(1) if field else "",
718+
filename.group(1) if filename else "",
719+
content.removesuffix(b"\r\n"),
672720
)
673-
}
674-
for head in heads
675-
]
721+
)
722+
return parts
676723

677724

678725
def test_wire_headers_match_the_released_client():
@@ -697,6 +744,50 @@ def test_wire_headers_match_the_released_client():
697744
assert ours["user-agent"].startswith("python-httpx/")
698745

699746

747+
def test_a_multi_file_upload_matches_the_released_client(tmp_path):
748+
"""The method takes a list, and the second file is where a transport swap
749+
diverges: one part written, one dropped, or two parts sharing a name the
750+
server then reads as one."""
751+
paths = []
752+
for name, content in (("first.txt", b"one"), ("second.txt", b"two")):
753+
path = tmp_path / name
754+
path.write_bytes(content)
755+
paths.append(str(path))
756+
757+
ours, theirs = _wire_requests(
758+
lambda url: _client(api_url=url, api_timeout=300).structure_file(paths),
759+
lambda url: _baseline_client(api_url=url, api_timeout=300).structure_file(
760+
paths
761+
),
762+
)
763+
764+
# Sorted: the two clients order the fields differently, which no multipart
765+
# parser reads as meaning. The order of the files among themselves is the
766+
# part that carries meaning, and it is pinned below.
767+
assert sorted(_multipart_parts(ours)) == sorted(_multipart_parts(theirs))
768+
uploaded = [part for part in _multipart_parts(ours) if part[0] == "files"]
769+
assert [(filename, content) for _, filename, content in uploaded] == [
770+
("first.txt", b"one"),
771+
("second.txt", b"two"),
772+
]
773+
774+
775+
def test_redirects_are_followed():
776+
"""The released client followed them on both verbs.
777+
778+
Not following one turns a load balancer's 307 into a body the poll loop
779+
reads as a finished execution with no status.
780+
"""
781+
assert _client()._transport.get_httpx_client().follow_redirects is True
782+
783+
784+
def test_a_status_endpoint_without_an_execution_id_is_refused():
785+
"""Polling with a blank id asks the service about an execution nobody has;
786+
what it answers is not this execution's state."""
787+
with pytest.raises(APIDeploymentsClientException):
788+
_client().check_execution_status("/deployment/api/testorg/testapi/")
789+
790+
700791
def test_the_transport_is_untimed_by_default():
701792
"""A connection that stalls forever is what the released client did.
702793

tests/test_retry.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ def test_503_after_exhaustion_sets_pending_true(
488488
mock_request.return_value = _mock_response(
489489
503, json_data={"status": "", "error": "Service Unavailable", "message": ""}
490490
)
491-
result = client.check_execution_status("/api/v1/status/123")
491+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
492492
assert result["pending"] is True
493493
assert result["status_code"] == 503
494494

@@ -497,7 +497,7 @@ def test_200_with_pending_status_sets_pending_true(self, mock_request, client):
497497
mock_request.return_value = _mock_response(
498498
200, json_data={"status": "EXECUTING", "error": "", "message": ""}
499499
)
500-
result = client.check_execution_status("/api/v1/status/123")
500+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
501501
assert result["pending"] is True
502502
assert result["status_code"] == 200
503503

@@ -511,7 +511,7 @@ def test_200_with_completed_status_sets_pending_false(self, mock_request, client
511511
"message": '{"result": "data"}',
512512
},
513513
)
514-
result = client.check_execution_status("/api/v1/status/123")
514+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
515515
assert result["pending"] is False
516516

517517
@patch("unstract.api_deployments.client.APIDeploymentsClient._send")
@@ -526,7 +526,7 @@ def test_422_with_executing_status_sets_pending_true(self, mock_request, client)
526526
mock_request.return_value = _mock_response(
527527
422, json_data={"status": "EXECUTING", "error": "", "message": ""}
528528
)
529-
result = client.check_execution_status("/api/v1/status/123")
529+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
530530
assert result["pending"] is True
531531
assert result["status_code"] == 422
532532

@@ -537,7 +537,7 @@ def test_422_with_pending_status_sets_pending_true(self, mock_request, client):
537537
mock_request.return_value = _mock_response(
538538
422, json_data={"status": "PENDING", "error": "", "message": ""}
539539
)
540-
result = client.check_execution_status("/api/v1/status/123")
540+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
541541
assert result["pending"] is True
542542
assert result["status_code"] == 422
543543

@@ -546,7 +546,7 @@ def test_400_does_not_set_pending(self, mock_request, client):
546546
mock_request.return_value = _mock_response(
547547
400, json_data={"status": "", "error": "Bad request", "message": ""}
548548
)
549-
result = client.check_execution_status("/api/v1/status/123")
549+
result = client.check_execution_status("/api/v1/status/?execution_id=123")
550550
assert result["pending"] is False
551551

552552

0 commit comments

Comments
 (0)