Skip to content

Commit 4fb09ac

Browse files
Code review fixes
1 parent 908d418 commit 4fb09ac

11 files changed

Lines changed: 396 additions & 53 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ s = UnstructuredClient(debug_logger=logging.getLogger("unstructured_client"))
504504

505505
## Authentication with Client Secrets
506506

507-
> Available from SDK version **X.Y.Z** (first release carrying the
507+
> Available from SDK version **0.44.0** (first release carrying the
508508
> `unstructured_client.auth` module).
509509
510510
If you are running against an Unstructured deployment that issues **client
@@ -586,6 +586,14 @@ client = UnstructuredClient(
586586
`token_exchange_enabled=False`, the call raises
587587
`TokenExchangeDisabledError` — that deployment expects the plain
588588
`api_key_auth="..."` string form instead.
589+
- **Custom HTTP client:** pass `http_client=httpx.Client(...)` (or
590+
`httpx.AsyncClient(...)` for the async variant) to share a connection
591+
pool, route through a corporate proxy, pin a custom CA bundle for mTLS,
592+
or otherwise control how the SDK reaches account-service. When the
593+
argument is omitted, the auth callable lazily creates and owns a private
594+
`httpx.Client`; that client is closed automatically when the auth
595+
instance is garbage-collected, or you can call `close()` /
596+
`aclose()` explicitly.
589597

590598
### Backward compatibility
591599

_test_unstructured_client/integration/test_client_credentials_e2e.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
:class:`LegacyKeyExchange`.
33
44
This test is **opt-in**: it only runs when every required env var is set.
5-
Point it at a deployment (e.g. the ``unsightly-koala`` dedicated-instance
6-
test cluster) that has ``DEPLOYMENT_MODE=dedicated`` and a valid client
7-
secret provisioned via account-service.
5+
Point it at any deployment that has ``DEPLOYMENT_MODE=dedicated`` (or any
6+
other configuration that accepts ``/auth/token-exchange``) and a valid
7+
client secret provisioned via account-service.
88
99
Required env vars
1010
-----------------
1111
1212
- ``UNS_ACCOUNTS_URL`` base URL of account-service (e.g.
13-
``https://accounts.unsightly-koala.example``)
13+
``https://accounts.<your-deployment>.example``)
1414
- ``UNS_CLIENT_SECRET`` ``uns_sk_...`` client secret
1515
- ``UNS_PLATFORM_API_URL`` platform-api base URL to hit after exchange
1616
@@ -48,8 +48,8 @@
4848

4949
_REASON = (
5050
"Opt-in E2E: set UNS_ACCOUNTS_URL, UNS_CLIENT_SECRET, and "
51-
"UNS_PLATFORM_API_URL to run against a real dedicated-instance "
52-
"deployment (e.g. unsightly-koala)."
51+
"UNS_PLATFORM_API_URL to run against a real deployment that supports "
52+
"/auth/token-exchange."
5353
)
5454

5555

_test_unstructured_client/unit/auth/test_async_client_credentials.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import threading
67
from typing import List
78

89
import httpx
@@ -168,3 +169,94 @@ async def test_sync_call_works_inside_running_loop(fake_clock):
168169

169170
token = await asyncio.to_thread(acc)
170171
assert token == "jwt-1"
172+
173+
174+
def test_sync_call_re_exchanges_after_cache_lapse_across_loops(fake_clock):
175+
"""Regression: ``__call__`` must not crash on the second exchange.
176+
177+
Each invocation outside a running loop spins a fresh event loop via
178+
``asyncio.run``. The internal ``asyncio.Lock`` is created lazily inside
179+
``acquire`` so it binds to whatever loop is current; if it were created
180+
in ``__init__`` it would stay bound to the first loop and ``async with``
181+
would raise ``RuntimeError`` on the second exchange.
182+
"""
183+
transport = AsyncScriptedTransport(
184+
[
185+
exchange_response(access_token="jwt-1", expires_in=900),
186+
exchange_response(access_token="jwt-2", expires_in=900),
187+
]
188+
)
189+
http_client = httpx.AsyncClient(transport=transport)
190+
acc = AsyncClientCredentials(
191+
client_secret=SECRET,
192+
server_url=SERVER_URL,
193+
http_client=http_client,
194+
refresh_buffer_seconds=60,
195+
)
196+
197+
assert acc() == "jwt-1"
198+
fake_clock["now"] += 900 - 30 # past refresh buffer
199+
assert acc() == "jwt-2"
200+
assert len(transport.requests) == 2
201+
202+
203+
@pytest.mark.asyncio
204+
async def test_acquire_re_uses_correct_async_lock_after_loop_change(fake_clock):
205+
"""The lazy per-loop lock must refresh when the running loop changes."""
206+
transport = AsyncScriptedTransport(
207+
[
208+
exchange_response(access_token="jwt-1", expires_in=900),
209+
exchange_response(access_token="jwt-2", expires_in=900),
210+
]
211+
)
212+
http_client = httpx.AsyncClient(transport=transport)
213+
acc = AsyncClientCredentials(
214+
client_secret=SECRET,
215+
server_url=SERVER_URL,
216+
http_client=http_client,
217+
refresh_buffer_seconds=60,
218+
)
219+
220+
first = await acc.acquire()
221+
assert first == "jwt-1"
222+
first_loop = acc._async_lock_loop # type: ignore[attr-defined]
223+
224+
fake_clock["now"] += 900 - 30 # past refresh buffer
225+
226+
# Run the next exchange on a *different* event loop driven from a worker
227+
# thread; the lazy-init code path must re-bind the lock to that loop.
228+
second = await asyncio.to_thread(lambda: asyncio.run(acc.acquire()))
229+
assert second == "jwt-2"
230+
231+
second_loop = acc._async_lock_loop # type: ignore[attr-defined]
232+
assert second_loop is not None
233+
assert second_loop is not first_loop
234+
235+
236+
def test_sync_call_coalesces_concurrent_threads_into_one_exchange(fake_clock):
237+
"""Two OS threads racing into ``__call__`` must share one exchange."""
238+
barrier = threading.Barrier(2)
239+
transport = AsyncScriptedTransport(
240+
[exchange_response(access_token="jwt-1", expires_in=900)]
241+
)
242+
http_client = httpx.AsyncClient(transport=transport)
243+
acc = AsyncClientCredentials(
244+
client_secret=SECRET,
245+
server_url=SERVER_URL,
246+
http_client=http_client,
247+
)
248+
249+
results = []
250+
251+
def _worker():
252+
barrier.wait()
253+
results.append(acc())
254+
255+
threads = [threading.Thread(target=_worker) for _ in range(2)]
256+
for t in threads:
257+
t.start()
258+
for t in threads:
259+
t.join()
260+
261+
assert results == ["jwt-1", "jwt-1"]
262+
assert len(transport.requests) == 1

_test_unstructured_client/unit/auth/test_auth_header_hook.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,40 @@ def _mock(request: httpx.Request) -> httpx.Response:
175175
assert "unstructured-api-key" not in {k.lower() for k in headers}
176176

177177

178+
def test_security_factory_exposes_wrapped_callable_for_hook_detection():
179+
"""Speakeasy-regen guard: the security factory built by ``UnstructuredClient``
180+
must expose ``__wrapped_callable__`` so :class:`AuthHeaderBeforeRequestHook`
181+
can recognize our token-exchange callables.
182+
183+
If a future Speakeasy regeneration strips the hand-edited block in
184+
``sdk.py``, this test will fail loudly instead of letting the auth-header
185+
rewrite silently break for every user.
186+
"""
187+
transport = ScriptedTransport([exchange_response()])
188+
cc = ClientCredentials(
189+
client_secret=SECRET,
190+
server_url=ACCOUNTS_URL,
191+
http_client=httpx.Client(transport=transport),
192+
)
193+
194+
session = UnstructuredClient(
195+
api_key_auth=cc,
196+
client=httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(200))),
197+
server_url=SERVER_URL,
198+
)
199+
200+
security_factory = session.sdk_configuration.security
201+
assert callable(security_factory), (
202+
"When api_key_auth is callable, sdk.py must wrap it in a factory."
203+
)
204+
wrapped = getattr(security_factory, "__wrapped_callable__", None)
205+
assert wrapped is cc, (
206+
"sdk.py must attach __wrapped_callable__ pointing at the original "
207+
"user callable; without it AuthHeaderBeforeRequestHook cannot detect "
208+
"ClientCredentials / LegacyKeyExchange instances."
209+
)
210+
211+
178212
def test_integration_leaves_legacy_path_unchanged_for_plain_string():
179213
captured: dict = {}
180214

_test_unstructured_client/unit/auth/test_legacy_key_exchange.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,53 @@ async def test_async_sends_grant_type_api_key_and_api_key_field():
9595
def test_rejects_empty_api_key(bad):
9696
with pytest.raises(ValueError):
9797
LegacyKeyExchange(api_key=bad, server_url=SERVER_URL) # type: ignore[arg-type]
98+
99+
100+
def test_caches_jwt_within_ttl_for_legacy_path(monkeypatch):
101+
"""Caching applies to the legacy api-key path just like client-secrets."""
102+
state = {"now": 1_000_000.0}
103+
monkeypatch.setattr("unstructured_client.auth._base.time.monotonic", lambda: state["now"])
104+
monkeypatch.setattr(
105+
"unstructured_client.auth.client_credentials.time.monotonic", lambda: state["now"]
106+
)
107+
108+
transport = ScriptedTransport([exchange_response(access_token="jwt-1", expires_in=900)])
109+
http_client = httpx.Client(transport=transport)
110+
lke = LegacyKeyExchange(
111+
api_key=LEGACY_KEY,
112+
server_url=SERVER_URL,
113+
http_client=http_client,
114+
refresh_buffer_seconds=60,
115+
)
116+
117+
assert lke() == "jwt-1"
118+
assert lke() == "jwt-1"
119+
assert lke() == "jwt-1"
120+
assert len(transport.requests) == 1
121+
122+
123+
def test_retries_5xx_on_legacy_path_then_succeeds(monkeypatch):
124+
"""5xx exponential-backoff retries also apply to the legacy api-key path."""
125+
state = {"now": 1_000_000.0}
126+
monkeypatch.setattr("unstructured_client.auth._base.time.monotonic", lambda: state["now"])
127+
monkeypatch.setattr(
128+
"unstructured_client.auth.client_credentials.time.monotonic", lambda: state["now"]
129+
)
130+
131+
transport = ScriptedTransport(
132+
[
133+
httpx.Response(500),
134+
httpx.Response(503),
135+
exchange_response(access_token="jwt-1", expires_in=900),
136+
]
137+
)
138+
http_client = httpx.Client(transport=transport)
139+
lke = LegacyKeyExchange(
140+
api_key=LEGACY_KEY,
141+
server_url=SERVER_URL,
142+
http_client=http_client,
143+
max_retries=3,
144+
)
145+
146+
assert lke() == "jwt-1"
147+
assert len(transport.requests) == 3

src/unstructured_client/_hooks/custom/auth_header_hook.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Before-request hook that promotes exchanged JWTs to ``Authorization: Bearer``.
22
3-
Speakeasy's generated ``Security`` model places ``api_key_auth`` in the
3+
The generated ``Security`` model places ``api_key_auth`` in the
44
``unstructured-api-key`` header. When the user supplies a token-exchange
5-
callable (``ClientCredentials`` or ``LegacyKeyExchange``) the value is a JWT
6-
and must be sent as ``Authorization: Bearer <jwt>`` so the service-side
7-
``utic-jwt-auth`` validator picks it up (see ``core-product`` auth_context
8-
and ``platform-api`` public_api/dependencies).
5+
callable from :mod:`unstructured_client.auth` (such as
6+
:class:`~unstructured_client.auth.ClientCredentials` or
7+
:class:`~unstructured_client.auth.LegacyKeyExchange`) the value is a JWT
8+
and must be sent as ``Authorization: Bearer <jwt>`` so the server-side
9+
JWT validator accepts it.
910
1011
Plain-string ``api_key_auth`` is untouched.
1112
"""
@@ -43,8 +44,10 @@ def _is_exchange_callable(security_source: object) -> bool:
4344
"""Return True when ``security_source`` was built from one of our
4445
token-exchange callables.
4546
46-
The SDK wraps a user-supplied callable into an internal factory and
47-
attaches ``__wrapped_callable__`` to it (see ``sdk.py``).
47+
``UnstructuredClient.__init__`` wraps a user-supplied callable into an
48+
internal security factory and attaches ``__wrapped_callable__`` to it
49+
so this hook can detect token-exchange instances without reaching
50+
into the lambda's closure.
4851
"""
4952
if security_source is None:
5053
return False

src/unstructured_client/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import importlib.metadata
44

55
__title__: str = "unstructured-client"
6-
__version__: str = "0.43.3"
6+
__version__: str = "0.44.0"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.43.3 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.44.0 2.680.0 1.2.31 unstructured-client"
1010

1111
try:
1212
if __package__ is not None:

src/unstructured_client/auth/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
as the ``unstructured-api-key`` header.
2020
"""
2121

22-
from ._base import _ExchangeCallableBase
2322
from ._exceptions import (
2423
InvalidCredentialError,
2524
TokenExchangeDisabledError,
@@ -36,5 +35,4 @@
3635
"LegacyKeyExchange",
3736
"TokenExchangeDisabledError",
3837
"TokenExchangeError",
39-
"_ExchangeCallableBase",
4038
]

src/unstructured_client/auth/_base.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,16 @@ def _raise_for_status(self, response: httpx.Response) -> None:
165165
"""Map HTTP status to auth-specific exceptions before retry decisions."""
166166
if response.status_code == 401:
167167
raise InvalidCredentialError(
168-
"Account-service rejected the credential (401). Check that the "
169-
"client secret / API key is correct and not revoked.",
168+
f"Account-service rejected the credential (401) at "
169+
f"{self._exchange_url}. Check that the client secret / API "
170+
f"key is correct and not revoked.",
170171
)
171172
if response.status_code == 400:
172173
raise TokenExchangeError(
173-
f"Account-service rejected the token-exchange request (400): "
174+
f"Account-service rejected the token-exchange request (400) "
175+
f"at {self._exchange_url}. This commonly indicates the "
176+
f"server_url is pointing somewhere other than account-service "
177+
f"(for example, at platform-api). Response body: "
174178
f"{response.text[:500]}",
175179
)
176180

0 commit comments

Comments
 (0)