dex-python-sdk 1.15.0__tar.gz → 1.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/PKG-INFO +42 -1
  2. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/README.md +41 -0
  3. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/__init__.py +14 -7
  4. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_credential_transport.py +103 -4
  5. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_pull_worker.py +38 -2
  6. dex_python_sdk-1.16.0/dex/credential.py +547 -0
  7. dex_python_sdk-1.16.0/dex/dexpb/dex_pb2.py +551 -0
  8. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2.pyi +47 -16
  9. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2_grpc.py +14 -1
  10. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/flow_options.py +23 -1
  11. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/pyproject.toml +1 -1
  12. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.lock +709 -12
  13. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.toml +19 -0
  14. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +4 -0
  15. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache/README.md +9 -0
  16. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +15 -3
  17. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +152 -31
  18. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +61 -34
  19. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +83 -6
  20. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +37 -0
  21. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/README.md +5 -0
  22. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +2 -0
  23. dex_python_sdk-1.15.0/dex/credential.py +0 -146
  24. dex_python_sdk-1.15.0/dex/dexpb/dex_pb2.py +0 -549
  25. dex_python_sdk-1.15.0/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -17
  26. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/LEGACY_NOTICES.md +0 -0
  27. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/LICENSE +0 -0
  28. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_async_value_hydrator.py +0 -0
  29. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_async_worker_dispatcher.py +0 -0
  30. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_async_worker_service.py +0 -0
  31. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_grpc_errors.py +0 -0
  32. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_invocation_context.py +0 -0
  33. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_native.pyi +0 -0
  34. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_server_protocol.py +0 -0
  35. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_utils.py +0 -0
  36. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_value_hydrator.py +0 -0
  37. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_value_mapper.py +0 -0
  38. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_worker_dispatcher.py +0 -0
  39. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/_worker_service.py +0 -0
  40. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/async_client.py +0 -0
  41. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/async_worker.py +0 -0
  42. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/attribute.py +0 -0
  43. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/attribute_match.py +0 -0
  44. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/blob_cache.py +0 -0
  45. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/channel.py +0 -0
  46. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/client.py +0 -0
  47. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/client_options.py +0 -0
  48. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/codec.py +0 -0
  49. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/condition.py +0 -0
  50. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/context.py +0 -0
  51. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/dexpb/__init__.py +0 -0
  52. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/flow.py +0 -0
  53. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/flow_config.py +0 -0
  54. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/flow_info.py +0 -0
  55. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/flow_result.py +0 -0
  56. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/pull_registration.py +0 -0
  57. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/py.typed +0 -0
  58. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/rpc_invoke_options.py +0 -0
  59. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/runtime_errors.py +0 -0
  60. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/step.py +0 -0
  61. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/step_execution.py +0 -0
  62. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/stream.py +0 -0
  63. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/subflow.py +0 -0
  64. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/timer.py +0 -0
  65. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/wait.py +0 -0
  66. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/wait_options.py +0 -0
  67. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/worker.py +0 -0
  68. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/dex/worker_options.py +0 -0
  69. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  70. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  71. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  72. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  73. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  74. {dex_python_sdk-1.15.0 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 1.15.0
3
+ Version: 1.16.0
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -25,6 +25,47 @@ checks its mtime and size at most once per second, and reloads it after rotation
25
25
  Whitespace around the token is removed. An unreadable or empty file fails calls
26
26
  until it is restored; the SDK does not send an old token.
27
27
 
28
+ Use `ApiKeyCredential` when the application holds a long-lived API key and a
29
+ token endpoint issues short-lived access tokens scoped to a server ID. Supply
30
+ the token URL, server ID, and API key explicitly; the server ID is never derived
31
+ from the endpoint host. The endpoint must use HTTPS, except loopback HTTP on
32
+ `localhost`, `127.0.0.0/8`, or `::1` for local development.
33
+ Loopback HTTP bypasses environment proxies so the API key stays on the host.
34
+ HTTPS uses configured proxies with TLS through CONNECT. Token endpoint redirects
35
+ are refused, including redirects that preserve the POST method.
36
+
37
+ ```python
38
+ from dex import ApiKeyCredential, Client, ClientOptions
39
+
40
+ credential = ApiKeyCredential(
41
+ "https://auth.example.com/token", "dex-server", "application-api-key"
42
+ )
43
+ client = Client(registry, cache, ClientOptions(credential=credential))
44
+ try:
45
+ client.health_check()
46
+ finally:
47
+ client.close()
48
+ credential.close()
49
+ ```
50
+
51
+ The first call exchanges the key for a token. After 80% of its lifetime, a
52
+ single background refresh begins while calls continue using the valid token.
53
+ Callers waiting for an expired token share the exchange. Failed refreshes retry
54
+ no more often than every five seconds while the previous token remains valid.
55
+ Each exchange has a 10-second deadline by default, configurable through
56
+ `exchange_timeout`. It covers connecting, sending, reading, and parsing. A Dex
57
+ `UNAUTHENTICATED` response discards the rejected token
58
+ and retries a unary call once with a fresh token. Pull Worker streams reconnect
59
+ once immediately with a fresh token; a second rejection uses the normal backoff.
60
+ The credential is thread-safe.
61
+ It redacts the API key, issued access tokens, and JWT-shaped strings from
62
+ exchange errors, including endpoint details and transport causes. Client
63
+ calls raise `ApiKeyExchangeError` unchanged; pull Workers log it and retry.
64
+ Closing the credential is optional. After all Clients and Workers sharing it
65
+ are closed, `close()` prevents further token calls and waits at most until the
66
+ current exchange deadline. An uninterruptible network operation may continue
67
+ in its daemon thread after that deadline.
68
+
28
69
  ```python
29
70
  from dex import Client, ClientOptions, FileCredential, Worker, WorkerOptions
30
71
 
@@ -9,6 +9,47 @@ checks its mtime and size at most once per second, and reloads it after rotation
9
9
  Whitespace around the token is removed. An unreadable or empty file fails calls
10
10
  until it is restored; the SDK does not send an old token.
11
11
 
12
+ Use `ApiKeyCredential` when the application holds a long-lived API key and a
13
+ token endpoint issues short-lived access tokens scoped to a server ID. Supply
14
+ the token URL, server ID, and API key explicitly; the server ID is never derived
15
+ from the endpoint host. The endpoint must use HTTPS, except loopback HTTP on
16
+ `localhost`, `127.0.0.0/8`, or `::1` for local development.
17
+ Loopback HTTP bypasses environment proxies so the API key stays on the host.
18
+ HTTPS uses configured proxies with TLS through CONNECT. Token endpoint redirects
19
+ are refused, including redirects that preserve the POST method.
20
+
21
+ ```python
22
+ from dex import ApiKeyCredential, Client, ClientOptions
23
+
24
+ credential = ApiKeyCredential(
25
+ "https://auth.example.com/token", "dex-server", "application-api-key"
26
+ )
27
+ client = Client(registry, cache, ClientOptions(credential=credential))
28
+ try:
29
+ client.health_check()
30
+ finally:
31
+ client.close()
32
+ credential.close()
33
+ ```
34
+
35
+ The first call exchanges the key for a token. After 80% of its lifetime, a
36
+ single background refresh begins while calls continue using the valid token.
37
+ Callers waiting for an expired token share the exchange. Failed refreshes retry
38
+ no more often than every five seconds while the previous token remains valid.
39
+ Each exchange has a 10-second deadline by default, configurable through
40
+ `exchange_timeout`. It covers connecting, sending, reading, and parsing. A Dex
41
+ `UNAUTHENTICATED` response discards the rejected token
42
+ and retries a unary call once with a fresh token. Pull Worker streams reconnect
43
+ once immediately with a fresh token; a second rejection uses the normal backoff.
44
+ The credential is thread-safe.
45
+ It redacts the API key, issued access tokens, and JWT-shaped strings from
46
+ exchange errors, including endpoint details and transport causes. Client
47
+ calls raise `ApiKeyExchangeError` unchanged; pull Workers log it and retry.
48
+ Closing the credential is optional. After all Clients and Workers sharing it
49
+ are closed, `close()` prevents further token calls and waits at most until the
50
+ current exchange deadline. An uninterruptible network operation may continue
51
+ in its daemon thread after that deadline.
52
+
12
53
  ```python
13
54
  from dex import Client, ClientOptions, FileCredential, Worker, WorkerOptions
14
55
 
@@ -24,7 +24,6 @@ from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
24
24
  from dex.channel import Channel, ChannelMap, ChannelMapLoad, ChannelMessage
25
25
  from dex.client import Client
26
26
  from dex.client_options import ClientOptions
27
- from dex.credential import Credential, FileCredential
28
27
  from dex.codec import (
29
28
  BOOL,
30
29
  BYTES,
@@ -39,6 +38,12 @@ from dex.codec import (
39
38
  )
40
39
  from dex.condition import ConditionCombination
41
40
  from dex.context import AsyncContext, Context, RecoveryErrorInfo
41
+ from dex.credential import (
42
+ ApiKeyCredential,
43
+ ApiKeyExchangeError,
44
+ Credential,
45
+ FileCredential,
46
+ )
42
47
  from dex.flow import Flow, PersistenceSchema, Registry, RPCResult, rpc
43
48
  from dex.flow_config import ActiveStepSearchMode, FlowConfig
44
49
  from dex.flow_info import (
@@ -62,6 +67,12 @@ from dex.flow_options import (
62
67
  TimeTravelType,
63
68
  )
64
69
  from dex.flow_result import FlowResult, StepCompletion
70
+ from dex.pull_registration import (
71
+ PullQueueRegistrationState,
72
+ PullRegistrationError,
73
+ PullRegistrationState,
74
+ )
75
+ from dex.rpc_invoke_options import RPCInvokeOptions
65
76
  from dex.runtime_errors import (
66
77
  AttributeMapNotLoadedError,
67
78
  ChannelMessageNotFoundError,
@@ -80,7 +91,6 @@ from dex.runtime_errors import (
80
91
  ValueMappingError,
81
92
  WorkerInvocationError,
82
93
  )
83
- from dex.rpc_invoke_options import RPCInvokeOptions
84
94
  from dex.step import (
85
95
  RetryPolicy,
86
96
  Step,
@@ -113,11 +123,6 @@ from dex.timer import Timer
113
123
  from dex.wait import Wait
114
124
  from dex.wait_options import WaitForAttributeOptions, WaitForStepCompletionOptions
115
125
  from dex.worker import Worker
116
- from dex.pull_registration import (
117
- PullRegistrationError,
118
- PullRegistrationState,
119
- PullQueueRegistrationState,
120
- )
121
126
  from dex.worker_options import PullWorkerOptions, WorkerOptions, WorkerTarget
122
127
 
123
128
  __all__ = [
@@ -138,6 +143,8 @@ __all__ = [
138
143
  "AsyncClient",
139
144
  "AsyncBufferedTextStream",
140
145
  "AsyncWorker",
146
+ "ApiKeyCredential",
147
+ "ApiKeyExchangeError",
141
148
  "BlobCache",
142
149
  "BlobCacheConfig",
143
150
  "BufferedTextStream",
@@ -16,6 +16,7 @@ import asyncio
16
16
  import inspect
17
17
  import queue
18
18
  import threading
19
+ import time
19
20
  from collections.abc import Callable
20
21
  from concurrent.futures import Future
21
22
  from typing import Any, NamedTuple, cast
@@ -168,6 +169,38 @@ def credential_deadline_error() -> grpc.aio.AioRpcError:
168
169
  )
169
170
 
170
171
 
172
+ class _SyncCredentialDeadlineError(grpc.RpcError):
173
+ def code(self) -> grpc.StatusCode:
174
+ return grpc.StatusCode.DEADLINE_EXCEEDED
175
+
176
+ def details(self) -> str:
177
+ return "credential lookup exceeded RPC deadline"
178
+
179
+ def trailing_metadata(self) -> tuple[Any, ...]:
180
+ return ()
181
+
182
+
183
+ def _bounded_credential_metadata(
184
+ credential: Credential, metadata: Any, timeout: float | None
185
+ ) -> Any:
186
+ if timeout is not None and timeout <= 0:
187
+ raise _SyncCredentialDeadlineError()
188
+ bounded_token = getattr(credential, "_token_with_timeout", None)
189
+ if not callable(bounded_token) or timeout is None:
190
+ return credential_metadata(credential, metadata)
191
+ try:
192
+ token = bounded_token(timeout)
193
+ except TimeoutError:
194
+ raise _SyncCredentialDeadlineError() from None
195
+ return tuple(metadata or ()) + (("authorization", "Bearer " + token),)
196
+
197
+
198
+ def _reject_credential_token(credential: Credential, metadata: Any) -> None:
199
+ reject_token = getattr(credential, "_reject_token", None)
200
+ if callable(reject_token):
201
+ reject_token(metadata[-1][1][7:])
202
+
203
+
171
204
  class _SyncCredentialInterceptor(grpc.UnaryUnaryClientInterceptor):
172
205
  def __init__(self, credential: Credential) -> None:
173
206
  self._credential = credential
@@ -178,15 +211,49 @@ class _SyncCredentialInterceptor(grpc.UnaryUnaryClientInterceptor):
178
211
  client_call_details: grpc.ClientCallDetails,
179
212
  request: Any,
180
213
  ) -> Any:
214
+ started_at = time.monotonic()
215
+ timeout = client_call_details.timeout
216
+ metadata = _bounded_credential_metadata(
217
+ self._credential, client_call_details.metadata, timeout
218
+ )
219
+ if timeout is not None:
220
+ timeout -= time.monotonic() - started_at
221
+ if timeout <= 0:
222
+ raise _SyncCredentialDeadlineError()
181
223
  details = _SyncCallDetails(
182
224
  client_call_details.method,
183
- client_call_details.timeout,
184
- credential_metadata(self._credential, client_call_details.metadata),
225
+ timeout,
226
+ metadata,
185
227
  client_call_details.credentials,
186
228
  client_call_details.wait_for_ready,
187
229
  client_call_details.compression,
188
230
  )
189
- return continuation(details, request)
231
+ try:
232
+ response = continuation(details, request)
233
+ except grpc.RpcError as error:
234
+ if error.code() != grpc.StatusCode.UNAUTHENTICATED:
235
+ raise
236
+ if not callable(getattr(self._credential, "_reject_token", None)):
237
+ raise
238
+ else:
239
+ if response.code() != grpc.StatusCode.UNAUTHENTICATED:
240
+ return response
241
+ if not callable(getattr(self._credential, "_reject_token", None)):
242
+ return response
243
+ _reject_credential_token(self._credential, details.metadata)
244
+ if client_call_details.timeout is not None:
245
+ timeout = client_call_details.timeout - (time.monotonic() - started_at)
246
+ retry_details = details._replace(
247
+ metadata=_bounded_credential_metadata(
248
+ self._credential, client_call_details.metadata, timeout
249
+ )
250
+ )
251
+ if client_call_details.timeout is not None:
252
+ timeout = client_call_details.timeout - (time.monotonic() - started_at)
253
+ if timeout <= 0:
254
+ raise _SyncCredentialDeadlineError()
255
+ retry_details = retry_details._replace(timeout=timeout)
256
+ return continuation(retry_details, request)
190
257
 
191
258
 
192
259
  class _AsyncCredentialInterceptor(grpc.aio.UnaryUnaryClientInterceptor):
@@ -216,7 +283,39 @@ class _AsyncCredentialInterceptor(grpc.aio.UnaryUnaryClientInterceptor):
216
283
  client_call_details.credentials,
217
284
  client_call_details.wait_for_ready,
218
285
  )
219
- return await continuation(details, request)
286
+ call = await continuation(details, request)
287
+ try:
288
+ await call
289
+ except grpc.aio.AioRpcError as error:
290
+ if error.code() != grpc.StatusCode.UNAUTHENTICATED:
291
+ raise
292
+ if not callable(getattr(self._runner._credential, "_reject_token", None)):
293
+ raise
294
+ _reject_credential_token(self._runner._credential, metadata)
295
+ if client_call_details.timeout is not None:
296
+ remaining_timeout = client_call_details.timeout - (
297
+ loop.time() - started_at
298
+ )
299
+ if remaining_timeout <= 0:
300
+ raise credential_deadline_error() from None
301
+ retry_metadata = await async_credential_metadata(
302
+ self._runner, client_call_details.metadata, remaining_timeout
303
+ )
304
+ if client_call_details.timeout is not None:
305
+ remaining_timeout = client_call_details.timeout - (
306
+ loop.time() - started_at
307
+ )
308
+ if remaining_timeout <= 0:
309
+ raise credential_deadline_error() from None
310
+ retry_details = grpc.aio.ClientCallDetails(
311
+ client_call_details.method,
312
+ remaining_timeout,
313
+ retry_metadata,
314
+ client_call_details.credentials,
315
+ client_call_details.wait_for_ready,
316
+ )
317
+ return await continuation(retry_details, request)
318
+ return call
220
319
 
221
320
 
222
321
  def sync_channel(server_address: str, credential: Credential | None) -> grpc.Channel:
@@ -22,7 +22,11 @@ from typing import Any
22
22
 
23
23
  import grpc
24
24
 
25
- from dex._credential_transport import AsyncCredentialRunner, async_credential_metadata
25
+ from dex._credential_transport import (
26
+ AsyncCredentialRunner,
27
+ _reject_credential_token,
28
+ async_credential_metadata,
29
+ )
26
30
  from dex._grpc_errors import _worker_error_status
27
31
  from dex._server_protocol import sdk_version
28
32
  from dex.dexpb import dex_pb2 as pb
@@ -314,6 +318,7 @@ class PullStream:
314
318
  self._call: Any = None
315
319
  self._send_lock = asyncio.Lock()
316
320
  self._is_reconnecting = False
321
+ self._stream_metadata: Any = None
317
322
  self._deadline_handle: asyncio.TimerHandle | None = None
318
323
  self._supports_unknown_name_rejection = False
319
324
 
@@ -322,6 +327,7 @@ class PullStream:
322
327
 
323
328
  async def run(self) -> None:
324
329
  backoff = 0.1
330
+ has_retried_unauthenticated = False
325
331
  while not self.runtime.is_stopping:
326
332
  self.is_draining = False
327
333
  was_registered = False
@@ -336,12 +342,35 @@ class PullStream:
336
342
  )
337
343
  finally:
338
344
  code = error.code() if error is not None else None
345
+ if self._is_registered:
346
+ has_retried_unauthenticated = False
347
+ credential = (
348
+ self.runtime._credential_runner._credential
349
+ if self.runtime._credential_runner is not None
350
+ else None
351
+ )
352
+ can_reject_token = (
353
+ code is grpc.StatusCode.UNAUTHENTICATED
354
+ and self._stream_metadata is not None
355
+ and credential is not None
356
+ and callable(getattr(credential, "_reject_token", None))
357
+ )
358
+ should_retry_unauthenticated = (
359
+ can_reject_token and not has_retried_unauthenticated
360
+ )
361
+ if can_reject_token and credential is not None:
362
+ _reject_credential_token(credential, self._stream_metadata)
339
363
  rejection = self.runtime.registration.record_stream_result(
340
364
  self.queue,
341
365
  self._is_registered,
342
- error if code in _PERMANENT_CODES else None,
366
+ (
367
+ error
368
+ if code in _PERMANENT_CODES and not should_retry_unauthenticated
369
+ else None
370
+ ),
343
371
  )
344
372
  self._is_registered = False
373
+ self._stream_metadata = None
345
374
  if (
346
375
  rejection is not None
347
376
  and self.runtime._stop_on_permanent_stream_rejection
@@ -357,6 +386,11 @@ class PullStream:
357
386
  or self._is_retiring
358
387
  ):
359
388
  break
389
+ if should_retry_unauthenticated:
390
+ has_retried_unauthenticated = True
391
+ continue
392
+ if code is grpc.StatusCode.UNAUTHENTICATED:
393
+ has_retried_unauthenticated = False
360
394
  if self._is_reconnecting:
361
395
  self._is_reconnecting = False
362
396
  backoff = 0.1
@@ -390,6 +424,7 @@ class PullStream:
390
424
  if self.runtime.is_stopping:
391
425
  return False
392
426
  self._supports_unknown_name_rejection = False
427
+ self._stream_metadata = None
393
428
  if self.runtime._credential_runner is None:
394
429
  self._call = self.stub.PollWorkerTask()
395
430
  else:
@@ -398,6 +433,7 @@ class PullStream:
398
433
  )
399
434
  if self.runtime.is_stopping:
400
435
  return False
436
+ self._stream_metadata = metadata
401
437
  self._call = self.stub.PollWorkerTask(metadata=metadata)
402
438
  await self._send(
403
439
  pb.PollWorkerTaskRequest(