Skip to content

Commit 3e9717e

Browse files
fix: give PlatformAPIClient the contract APIDeploymentsClient already has
Six findings from the review on this PR. Five were real contract gaps and one was a missed export; all are pinned by tests that fail when the fix is reverted. **The body is read as JSON, not through the generated model.** This is the one that mattered. `sync_detailed` reaches `_parse_response`, which does `PlatformKeyError.from_dict(response.json())` on a 401 with no guard: a gateway answering 401 with HTML raises `JSONDecodeError`, and a DRF-shaped `{"detail": ...}` raises `KeyError: 'message'` -- both out of the generated parser, before this facade sees the response. So a rejected key crashed instead of being reported. The request is now issued from the generated `_get_kwargs` and the body read through `APIDeploymentsClient._read_body`, which is exactly why that helper exists. This is the same defect class as the `_error_text` fix in the previous commit. I fixed the half where reporting a refusal crashed and missed the half where building the model crashed first. **Transport failures are translated.** The class called `sync_detailed` directly, so an unreachable host raised raw `httpx.ConnectError` -- contradicting the module docstring this PR added, which promises the `requests` exception types callers catch. It now goes through `_send`, like every deployment-key request. **The credential is read per request.** `AuthenticatedClient` bakes its auth header on first use, so a key assigned after the transport was built kept sending the old one. `_send` sets the header per call. **`close`, `__enter__` and `__exit__`** -- pooled connections had nothing to release them, and the CLI builds one client per job. **Re-exported from the package root**, so it is reachable as `unstract.api_deployments.PlatformAPIClient` rather than only from the private module. Also corrected the `_error_text` comment from the previous commit: it described the platform facade raising AttributeError, which is no longer a path that exists now that both facades hand it an httpx response. 438 tests pass, up from 430. Five mutations killed: unguarded `response.json()`, dropping the transport translation, capturing the key with the transport, neutering `close`, and removing the re-export. The generated tree is untouched, so `sdk-drift` is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7U4ggFchu91zKYcRxFRNZ
1 parent db20a25 commit 3e9717e

3 files changed

Lines changed: 174 additions & 30 deletions

File tree

‎src/unstract/api_deployments/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
__version__ = "1.6.0"
22

33
from .client import APIDeploymentsClient as APIDeploymentsClient
4+
from .client import PlatformAPIClient as PlatformAPIClient
45

56

67
def get_sdk_version():

‎src/unstract/api_deployments/client.py‎

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,10 @@ def _error_text(body: Any, response) -> str:
176176
value = body.get(key)
177177
if isinstance(value, str) and value:
178178
return value
179-
# `.text` on an httpx response, `.content` on the generated `Response`
180-
# wrapper -- which is an attrs class, not an httpx one, and carries only
181-
# bytes. Without this the platform facade raises AttributeError while
182-
# reporting a refusal, turning a 401 into a crash.
179+
# Both facades hand this an httpx response, which has `.text`. The
180+
# `.content` fallback is for the generated `Response` wrapper -- an attrs
181+
# class carrying only bytes -- so passing one here reports the reason
182+
# instead of raising AttributeError on the way to reporting it.
183183
text = getattr(response, "text", None)
184184
if text is None:
185185
text = (getattr(response, "content", b"") or b"").decode("utf-8", "replace")
@@ -953,7 +953,11 @@ class PlatformAPIClient:
953953
a platform key and address the account. Folding them together would mean a
954954
class whose required `api_url` is meaningless for half its methods.
955955
956-
Both credentials are HTTP bearer, so the generated transport is shared.
956+
Everything else about the contract is deliberately the same as that class:
957+
the request is issued through `_send`, so transport failures arrive as the
958+
`requests` exception types callers already catch; the body is read as JSON
959+
rather than through the generated response model; the credential is read per
960+
request; and the pooled connections are released by `close`.
957961
"""
958962

959963
def __init__(
@@ -969,8 +973,8 @@ def __init__(
969973
Args:
970974
base_url (str): Scheme and host of the Unstract deployment, e.g.
971975
``https://us-central.unstract.com``. A path is ignored: these
972-
operations carry their own, and the generated transport joins
973-
them onto the origin.
976+
operations carry their own, and the generated builders join them
977+
onto the origin.
974978
api_key (str | None): Platform API key. Falls back to
975979
``$UNSTRACT_PLATFORM_KEY``, matching how `APIDeploymentsClient`
976980
falls back for the deployment key.
@@ -1014,6 +1018,9 @@ def _transport(self) -> AuthenticatedClient:
10141018
Same reasoning as `APIDeploymentsClient._transport`: two threads racing
10151019
the first call would each build a pool and one would be dropped while
10161020
still holding its sockets.
1021+
1022+
The token given here is not what authenticates a request -- `_send`
1023+
sets the header per call -- but `AuthenticatedClient` requires one.
10171024
"""
10181025
if self._transport_client is None:
10191026
with self._transport_lock:
@@ -1028,29 +1035,62 @@ def _transport(self) -> AuthenticatedClient:
10281035
)
10291036
return self._transport_client
10301037

1031-
def _parsed_or_raise(self, response, what: str) -> Any:
1032-
"""The parsed body of a 2xx, or an exception naming why it was refused.
1038+
def close(self) -> None:
1039+
"""Release the pooled connections this client holds.
1040+
1041+
As on `APIDeploymentsClient`: the transport is kept between calls so
1042+
connections are reused, nothing else releases its sockets, and a client
1043+
built per job would otherwise accumulate pools. Safe to call twice.
1044+
"""
1045+
with self._transport_lock:
1046+
transport, self._transport_client = self._transport_client, None
1047+
if transport is not None:
1048+
transport.get_httpx_client().close()
1049+
1050+
def __enter__(self) -> "PlatformAPIClient":
1051+
return self
1052+
1053+
def __exit__(self, exc_type, exc_value, traceback) -> None:
1054+
self.close()
1055+
1056+
def _send(self, method: str, url: str, **kwargs) -> httpx.Response:
1057+
"""Issue one request, translating transport failures on the way out.
1058+
1059+
The credential is read per request rather than captured with the
1060+
transport, so assigning ``api_key`` takes effect on the next call --
1061+
`AuthenticatedClient` bakes its own header on first use, which would
1062+
otherwise pin whatever key the client was built with.
1063+
"""
1064+
kwargs["headers"] = {
1065+
**(kwargs.get("headers") or {}),
1066+
"Authorization": f"Bearer {self.api_key}",
1067+
}
1068+
return _translate_transport_errors(
1069+
self._transport.get_httpx_client().request, method, url, **kwargs
1070+
)
1071+
1072+
def _read_or_raise(self, response: httpx.Response, what: str) -> Any:
1073+
"""The JSON body of a 2xx, or an exception naming why it was refused.
10331074
1034-
`raise_on_unexpected_status` is off on the shared transport, so a
1035-
refusal arrives as an ordinary response. Without this every caller would
1036-
have to re-derive that check, and a 401 would read as an empty result.
1075+
The body is read directly rather than through the generated response
1076+
model, for the reason `APIDeploymentsClient._read_body` gives: a model
1077+
is built only for the statuses the spec declares, and its `from_dict`
1078+
indexes required keys with no default. A gateway answering 401 with HTML,
1079+
or the API answering with a DRF-shaped ``{"detail": ...}``, would raise
1080+
`JSONDecodeError` or `KeyError` out of the generated parser -- before
1081+
this facade ever sees the response -- instead of reporting a refusal.
10371082
"""
1083+
body = APIDeploymentsClient._read_body(response)
10381084
if not 200 <= response.status_code < 300:
1039-
# `parsed` is a generated model, and `_error_text` reads mappings;
1040-
# handed the model it would fall through to the raw body and drop
1041-
# the reason the endpoint actually sent.
1042-
body = response.parsed
1043-
if hasattr(body, "to_dict"):
1044-
body = body.to_dict()
10451085
raise APIDeploymentsClientException(
10461086
f"{what} failed with {response.status_code}: "
10471087
f"{_error_text(body, response)}"
10481088
)
1049-
if response.parsed is None:
1089+
if body is None:
10501090
raise APIDeploymentsClientException(
1051-
f"{what} returned {response.status_code} with no readable body."
1091+
f"{what} returned {response.status_code} with a body that is not JSON."
10521092
)
1053-
return response.parsed
1093+
return body
10541094

10551095
def whoami(self) -> dict:
10561096
"""The organisation this key belongs to, and the key's own scope.
@@ -1064,8 +1104,9 @@ def whoami(self) -> dict:
10641104
and ``key_name``.
10651105
"""
10661106
self.logger.debug("Resolving identity via /unstract/whoami/")
1067-
response = whoami.sync_detailed(client=self._transport)
1068-
return self._parsed_or_raise(response, "whoami").to_dict()
1107+
request_kwargs = whoami._get_kwargs()
1108+
response = self._send(**request_kwargs)
1109+
return self._read_or_raise(response, "whoami")
10691110

10701111
def list_deployments(
10711112
self,
@@ -1099,14 +1140,14 @@ def list_deployments(
10991140
dict: ``count``, ``next``, ``previous`` and ``results``.
11001141
"""
11011142
self.logger.debug("Listing deployments for organisation: " + org_id)
1102-
response = list_deployments.sync_detailed(
1143+
request_kwargs = list_deployments._get_kwargs(
11031144
org_id,
1104-
client=self._transport,
11051145
api_name=api_name,
11061146
search=search,
11071147
ordering=ordering,
11081148
page=page,
11091149
page_size=page_size,
11101150
workflow=workflow,
11111151
)
1112-
return self._parsed_or_raise(response, "list_deployments").to_dict()
1152+
response = self._send(**request_kwargs)
1153+
return self._read_or_raise(response, "list_deployments")

‎tests/test_compat.py‎

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2025,9 +2025,14 @@ def test_whoami_returns_the_four_fields_the_spec_declares():
20252025
result = _platform_client().whoami()
20262026

20272027
assert result == identity
2028-
url = transport.request.call_args.kwargs["url"]
2028+
# Issued through `_send`, which passes method and url positionally the way
2029+
# the sibling client does.
2030+
method, url = transport.request.call_args.args[:2]
2031+
assert method == "get", method
20292032
# No organisation segment: putting one there would defeat the point.
20302033
assert url.endswith("/api/v1/unstract/whoami/"), url
2034+
sent = transport.request.call_args.kwargs["headers"]["Authorization"]
2035+
assert sent == "Bearer pk-test", sent
20312036

20322037

20332038
def test_list_deployments_sends_the_organisation_and_reads_the_page():
@@ -2060,9 +2065,9 @@ def test_list_deployments_sends_the_organisation_and_reads_the_page():
20602065

20612066
assert result["count"] == 1
20622067
assert result["results"][0]["api_name"] == "invoice-parser"
2063-
called = transport.request.call_args.kwargs
2064-
assert "/org-a/" in called["url"], called["url"]
2065-
assert called["params"]["api_name"] == "invoice-parser"
2068+
url = transport.request.call_args.args[1]
2069+
assert "/org-a/" in url, url
2070+
assert transport.request.call_args.kwargs["params"]["api_name"] == "invoice-parser"
20662071

20672072

20682073
def test_a_missing_platform_key_is_refused_at_construction():
@@ -2091,3 +2096,100 @@ def test_a_path_on_the_base_url_is_discarded():
20912096
base_url="https://example.unstract.com/deployment/api/x/y/"
20922097
)
20932098
assert client.base_url == "https://example.unstract.com"
2099+
2100+
2101+
# The findings below were raised on PR #29 and each fix is pinned here, so a
2102+
# regression to the pre-review behaviour fails rather than passing quietly.
2103+
2104+
2105+
@pytest.mark.parametrize(
2106+
("label", "body_text"),
2107+
[
2108+
("gateway_html", "<html><body>401 Unauthorized</body></html>"),
2109+
("drf_shaped", '{"detail": "Invalid token."}'),
2110+
("empty", ""),
2111+
],
2112+
)
2113+
def test_an_error_body_the_model_cannot_parse_is_still_reported(label, body_text):
2114+
"""The generated `_parse_response` builds `PlatformKeyError.from_dict(...)`
2115+
on a 401 with no guard: HTML raises `JSONDecodeError` and a DRF-shaped body
2116+
raises `KeyError: 'message'`, both out of the parser and before the facade
2117+
sees the response. Reading the body directly is what keeps a refused key a
2118+
reported refusal rather than a crash.
2119+
"""
2120+
transport = MagicMock()
2121+
transport.request.return_value = httpx.Response(401, text=body_text)
2122+
with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport):
2123+
with pytest.raises(APIDeploymentsClientException) as caught:
2124+
_platform_client().whoami()
2125+
assert "401" in str(caught.value), label
2126+
2127+
2128+
def test_a_transport_failure_arrives_as_the_requests_exception(monkeypatch):
2129+
"""`APIDeploymentsClient` routes every request through
2130+
`_translate_transport_errors` so callers catch the `requests` classes they
2131+
document. Calling the generated `sync_detailed` directly would let raw
2132+
`httpx.ConnectError` escape, contradicting the module docstring.
2133+
"""
2134+
transport = MagicMock()
2135+
transport.request.side_effect = httpx.ConnectError("nope")
2136+
with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport):
2137+
with pytest.raises(ConnectionError):
2138+
_platform_client().whoami()
2139+
2140+
2141+
def test_the_platform_key_is_read_per_request_not_captured():
2142+
"""`AuthenticatedClient` bakes its auth header on first use, so a key
2143+
reassigned after the transport was built would silently keep sending the
2144+
old one. `_send` sets the header per call for exactly this reason.
2145+
"""
2146+
identity = {
2147+
"organization_id": "org-a",
2148+
"organization_name": "Org A",
2149+
"permission": "read",
2150+
"key_name": "k",
2151+
}
2152+
client = _platform_client(api_key="pk-first")
2153+
transport = MagicMock()
2154+
transport.request.return_value = _httpx_response(200, identity)
2155+
with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport):
2156+
client.whoami()
2157+
first = transport.request.call_args.kwargs["headers"]["Authorization"]
2158+
client.api_key = "pk-second"
2159+
client.whoami()
2160+
second = transport.request.call_args.kwargs["headers"]["Authorization"]
2161+
2162+
assert first == "Bearer pk-first"
2163+
assert second == "Bearer pk-second"
2164+
2165+
2166+
def test_close_releases_the_pool_and_the_next_call_rebuilds_it():
2167+
"""Nothing else releases the transport's sockets, and the CLI builds one
2168+
client per job."""
2169+
client = _platform_client()
2170+
inner = MagicMock()
2171+
with patch.object(AuthenticatedClient, "get_httpx_client", return_value=inner):
2172+
assert client._transport is not None
2173+
client.close()
2174+
inner.close.assert_called_once()
2175+
assert client._transport_client is None
2176+
# Safe twice, and idempotent.
2177+
client.close()
2178+
2179+
2180+
def test_the_platform_client_is_a_context_manager():
2181+
with patch.object(
2182+
AuthenticatedClient, "get_httpx_client", return_value=MagicMock()
2183+
):
2184+
with _platform_client() as client:
2185+
assert client._transport is not None
2186+
assert client._transport_client is None
2187+
2188+
2189+
def test_both_clients_are_reachable_from_the_package_root():
2190+
"""A class only importable from the private module is not a published
2191+
surface, and the sibling is re-exported."""
2192+
from unstract import api_deployments as pkg
2193+
2194+
assert pkg.PlatformAPIClient is PlatformAPIClient
2195+
assert pkg.APIDeploymentsClient is APIDeploymentsClient

0 commit comments

Comments
 (0)