dex-python-sdk 1.14.1__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.14.1 → dex_python_sdk-1.16.0}/PKG-INFO +76 -1
  2. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/README.md +75 -0
  3. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/__init__.py +14 -7
  4. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_credential_transport.py +103 -4
  5. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_pull_worker.py +66 -4
  6. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/async_worker.py +17 -0
  7. dex_python_sdk-1.16.0/dex/credential.py +547 -0
  8. dex_python_sdk-1.16.0/dex/dexpb/dex_pb2.py +551 -0
  9. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2.pyi +120 -42
  10. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/dex_pb2_grpc.py +14 -1
  11. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_options.py +23 -1
  12. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/worker.py +17 -0
  13. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/worker_options.py +29 -2
  14. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/pyproject.toml +1 -1
  15. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.lock +710 -12
  16. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/Cargo.toml +20 -0
  17. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +4 -0
  18. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache/README.md +9 -0
  19. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +15 -3
  20. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +152 -31
  21. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +61 -34
  22. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +83 -6
  23. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +37 -0
  24. dex_python_sdk-1.16.0/sdk-rust/crates/dex-blob-cache-python/README.md +5 -0
  25. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +2 -0
  26. dex_python_sdk-1.14.1/dex/credential.py +0 -146
  27. dex_python_sdk-1.14.1/dex/dexpb/dex_pb2.py +0 -545
  28. dex_python_sdk-1.14.1/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -17
  29. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/LEGACY_NOTICES.md +0 -0
  30. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/LICENSE +0 -0
  31. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_value_hydrator.py +0 -0
  32. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_worker_dispatcher.py +0 -0
  33. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_async_worker_service.py +0 -0
  34. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_grpc_errors.py +0 -0
  35. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_invocation_context.py +0 -0
  36. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_native.pyi +0 -0
  37. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_server_protocol.py +0 -0
  38. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_utils.py +0 -0
  39. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_value_hydrator.py +0 -0
  40. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_value_mapper.py +0 -0
  41. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_worker_dispatcher.py +0 -0
  42. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/_worker_service.py +0 -0
  43. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/async_client.py +0 -0
  44. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/attribute.py +0 -0
  45. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/attribute_match.py +0 -0
  46. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/blob_cache.py +0 -0
  47. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/channel.py +0 -0
  48. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/client.py +0 -0
  49. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/client_options.py +0 -0
  50. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/codec.py +0 -0
  51. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/condition.py +0 -0
  52. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/context.py +0 -0
  53. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/dexpb/__init__.py +0 -0
  54. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow.py +0 -0
  55. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_config.py +0 -0
  56. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_info.py +0 -0
  57. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/flow_result.py +0 -0
  58. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/pull_registration.py +0 -0
  59. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/py.typed +0 -0
  60. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/rpc_invoke_options.py +0 -0
  61. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/runtime_errors.py +0 -0
  62. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/step.py +0 -0
  63. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/step_execution.py +0 -0
  64. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/stream.py +0 -0
  65. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/subflow.py +0 -0
  66. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/timer.py +0 -0
  67. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/wait.py +0 -0
  68. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/dex/wait_options.py +0 -0
  69. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  70. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  71. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  72. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  73. {dex_python_sdk-1.14.1 → dex_python_sdk-1.16.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  74. {dex_python_sdk-1.14.1 → 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.14.1
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
 
@@ -157,6 +198,40 @@ applies before and after readiness. Another registered stream for that queue
157
198
  prevents shutdown. The default is False and retries indefinitely. Always call
158
199
  `stop()` or `close()` afterward; both remain safe after `start()` returns.
159
200
 
201
+ ### Reserved RPC capacity
202
+
203
+ Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
204
+ behind long-running Steps. When unset, it reserves one slot if the resolved task
205
+ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
206
+ fewer slot than before. Set it explicitly to zero to opt out. With one slot and
207
+ no explicit reserve, Worker start logs one warning: RPCs may wait behind
208
+ long-running Steps; use a limit of at least two or set the reserve explicitly.
209
+ Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
210
+ engines prioritize reads. The value must be nonnegative and less than
211
+ `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
212
+ fail construction. RPC and Step handlers count together: WaitFor and Execute
213
+ tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
214
+ count reaches the task limit minus the reserve. API-direct and transactional
215
+ InvokeWorkerRPC tasks can use the full limit.
216
+
217
+ ```python
218
+ pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
219
+ ```
220
+
221
+ A decline lets Dex try another Worker without consuming an attempt. If all
222
+ Workers remain at their Step limit, a Step can wait until the dispatch wait
223
+ expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
224
+ retry policy, as when all Workers are full. Local attempts wait the local
225
+ dispatch wait, then fall back according to their policy. Each queue still keeps
226
+ its configured idle streams; a reserve decline does not close a stream or change
227
+ registration readiness.
228
+
229
+ Each reserve decline records the Flow type through the dex._pull_worker logger
230
+ at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
231
+ to distinguish reserve pressure from full-capacity rejections. The count
232
+ excludes full-capacity rejections and unknown names, never resets, and remains
233
+ available after shutdown. Listening Workers return zero.
234
+
160
235
  ## Flow state I/O
161
236
 
162
237
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -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
 
@@ -141,6 +182,40 @@ applies before and after readiness. Another registered stream for that queue
141
182
  prevents shutdown. The default is False and retries indefinitely. Always call
142
183
  `stop()` or `close()` afterward; both remain safe after `start()` returns.
143
184
 
185
+ ### Reserved RPC capacity
186
+
187
+ Set `reserved_rpc_tasks` when synchronous RPC latency must stay responsive
188
+ behind long-running Steps. When unset, it reserves one slot if the resolved task
189
+ limit is at least two, otherwise zero. With the CPU-count default, Steps get one
190
+ fewer slot than before. Set it explicitly to zero to opt out. With one slot and
191
+ no explicit reserve, Worker start logs one warning: RPCs may wait behind
192
+ long-running Steps; use a limit of at least two or set the reserve explicitly.
193
+ Flows must stay responsive; Dex cannot prioritize synchronous RPCs like workflow
194
+ engines prioritize reads. The value must be nonnegative and less than
195
+ `max_concurrent_tasks` after its CPU-count default is resolved; invalid values
196
+ fail construction. RPC and Step handlers count together: WaitFor and Execute
197
+ tasks, including local attempts, are declined with `NO_CAPACITY` when the busy
198
+ count reaches the task limit minus the reserve. API-direct and transactional
199
+ InvokeWorkerRPC tasks can use the full limit.
200
+
201
+ ```python
202
+ pull = PullWorkerOptions(max_concurrent_tasks=8, reserved_rpc_tasks=2)
203
+ ```
204
+
205
+ A decline lets Dex try another Worker without consuming an attempt. If all
206
+ Workers remain at their Step limit, a Step can wait until the dispatch wait
207
+ expires, then receive `NO_WORKER_AVAILABLE`, consume an attempt, and follow its
208
+ retry policy, as when all Workers are full. Local attempts wait the local
209
+ dispatch wait, then fall back according to their policy. Each queue still keeps
210
+ its configured idle streams; a reserve decline does not close a stream or change
211
+ registration readiness.
212
+
213
+ Each reserve decline records the Flow type through the dex._pull_worker logger
214
+ at DEBUG level. Export the cumulative `pull_reserved_rpc_declines` Worker value
215
+ to distinguish reserve pressure from full-capacity rejections. The count
216
+ excludes full-capacity rejections and unknown names, never resets, and remains
217
+ available after shutdown. Listening Workers return zero.
218
+
144
219
  ## Flow state I/O
145
220
 
146
221
  Applications read and write Flow state through typed RPCs. Client and AsyncClient
@@ -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
@@ -94,6 +98,15 @@ class PullWorkerRuntime:
94
98
  self._permanent_error: PullRegistrationError | None = None
95
99
  self._rejection_stop_task: asyncio.Task[None] | None = None
96
100
  self.max_concurrent_tasks = options.max_concurrent_tasks or os.cpu_count() or 1
101
+ self.reserved_rpc_tasks = (
102
+ options.reserved_rpc_tasks
103
+ if options.reserved_rpc_tasks is not None
104
+ else int(self.max_concurrent_tasks >= 2)
105
+ )
106
+ self._should_warn_rpc_capacity = (
107
+ options.reserved_rpc_tasks is None and self.max_concurrent_tasks == 1
108
+ )
109
+ self.reserved_rpc_declines = 0
97
110
  self.idle_streams_per_queue = options.idle_streams_per_queue or 1
98
111
  self.stop_drain_timeout = options.stop_drain_timeout.total_seconds() or 30
99
112
  self._dispatcher = dispatcher
@@ -132,6 +145,10 @@ class PullWorkerRuntime:
132
145
  return None
133
146
 
134
147
  async def run(self) -> None:
148
+ if self._should_warn_rpc_capacity:
149
+ _LOGGER.warning(
150
+ "RPCs may wait behind long-running Steps; use max_concurrent_tasks >= 2 or set reserved_rpc_tasks explicitly"
151
+ )
135
152
  self._loop = asyncio.get_running_loop()
136
153
  self._loop_thread_id = threading.get_ident()
137
154
  self._wake = asyncio.Event()
@@ -193,11 +210,24 @@ class PullWorkerRuntime:
193
210
  _LOGGER.error("Worker task stream failed", exc_info=failure)
194
211
  self.signal()
195
212
 
196
- def accept_task(self) -> bool:
213
+ def accept_task(self, task: pb.PollWorkerTask | None = None) -> bool:
197
214
  if self._stopping is None or self._stopping.is_set():
198
215
  return False
199
216
  if self._busy_tasks >= self.max_concurrent_tasks:
200
217
  return False
218
+ if (
219
+ task is not None
220
+ and task.WhichOneof("request") in ("wait_for", "execute")
221
+ and self.reserved_rpc_tasks > 0
222
+ and self._busy_tasks >= self.max_concurrent_tasks - self.reserved_rpc_tasks
223
+ and self.unknown_task_reason(task) is None
224
+ ):
225
+ self.reserved_rpc_declines += 1
226
+ request = task.wait_for if task.HasField("wait_for") else task.execute
227
+ _LOGGER.debug(
228
+ "decline Step task for reserved RPC capacity: %s", request.flow_type
229
+ )
230
+ return False
201
231
  self._busy_tasks += 1
202
232
  self.signal()
203
233
  return True
@@ -288,6 +318,7 @@ class PullStream:
288
318
  self._call: Any = None
289
319
  self._send_lock = asyncio.Lock()
290
320
  self._is_reconnecting = False
321
+ self._stream_metadata: Any = None
291
322
  self._deadline_handle: asyncio.TimerHandle | None = None
292
323
  self._supports_unknown_name_rejection = False
293
324
 
@@ -296,6 +327,7 @@ class PullStream:
296
327
 
297
328
  async def run(self) -> None:
298
329
  backoff = 0.1
330
+ has_retried_unauthenticated = False
299
331
  while not self.runtime.is_stopping:
300
332
  self.is_draining = False
301
333
  was_registered = False
@@ -310,12 +342,35 @@ class PullStream:
310
342
  )
311
343
  finally:
312
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)
313
363
  rejection = self.runtime.registration.record_stream_result(
314
364
  self.queue,
315
365
  self._is_registered,
316
- 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
+ ),
317
371
  )
318
372
  self._is_registered = False
373
+ self._stream_metadata = None
319
374
  if (
320
375
  rejection is not None
321
376
  and self.runtime._stop_on_permanent_stream_rejection
@@ -331,6 +386,11 @@ class PullStream:
331
386
  or self._is_retiring
332
387
  ):
333
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
334
394
  if self._is_reconnecting:
335
395
  self._is_reconnecting = False
336
396
  backoff = 0.1
@@ -364,6 +424,7 @@ class PullStream:
364
424
  if self.runtime.is_stopping:
365
425
  return False
366
426
  self._supports_unknown_name_rejection = False
427
+ self._stream_metadata = None
367
428
  if self.runtime._credential_runner is None:
368
429
  self._call = self.stub.PollWorkerTask()
369
430
  else:
@@ -372,6 +433,7 @@ class PullStream:
372
433
  )
373
434
  if self.runtime.is_stopping:
374
435
  return False
436
+ self._stream_metadata = metadata
375
437
  self._call = self.stub.PollWorkerTask(metadata=metadata)
376
438
  await self._send(
377
439
  pb.PollWorkerTaskRequest(
@@ -524,7 +586,7 @@ class PullStream:
524
586
  if unknown_reason is not None:
525
587
  await self._reject_task(task, unknown_reason)
526
588
  return
527
- if self.is_busy or self.is_draining or not self.runtime.accept_task():
589
+ if self.is_busy or self.is_draining or not self.runtime.accept_task(task):
528
590
  await self._reject_task(task)
529
591
  return
530
592
  self.is_busy = True
@@ -221,6 +221,23 @@ class AsyncWorker:
221
221
  raise RuntimeError("wait_pull_registered requires a pull Worker")
222
222
  await self._pull.registration.wait_async(timeout)
223
223
 
224
+ @property
225
+ def pull_reserved_rpc_declines(self) -> int:
226
+ """Return cumulative Step declines caused by reserved RPC capacity.
227
+
228
+ Full-capacity rejections and unknown names are excluded. The count never
229
+ resets during this Worker's lifetime and stays available after stop.
230
+ Reading it alongside handlers is safe. Listening Workers return zero.
231
+ Export this value to a monitoring counter to identify reserve pressure.
232
+
233
+ Returns:
234
+ The cumulative reserve-decline count, or zero for a listening Worker.
235
+
236
+ Examples:
237
+ >>> declines = worker.pull_reserved_rpc_declines
238
+ """
239
+ return 0 if self._pull is None else self._pull.reserved_rpc_declines
240
+
224
241
  def pull_registration_state(self) -> PullRegistrationState:
225
242
  """Return an immutable snapshot of pull queue registrations.
226
243