dex-python-sdk 0.6.0__tar.gz → 0.7.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 (63) hide show
  1. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/PKG-INFO +12 -1
  2. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/README.md +11 -0
  3. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/__init__.py +8 -3
  4. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_grpc_errors.py +38 -11
  5. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/async_client.py +89 -65
  6. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/client.py +90 -66
  7. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/dexpb/dex_pb2.py +22 -22
  8. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/dexpb/dex_pb2.pyi +2 -0
  9. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/dexpb/dex_pb2_grpc.py +18 -18
  10. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/runtime_errors.py +8 -0
  11. dex_python_sdk-0.7.0/dex/wait_options.py +76 -0
  12. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/pyproject.toml +1 -1
  13. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/Cargo.lock +6 -6
  14. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/Cargo.toml +3 -3
  15. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/LEGACY_NOTICES.md +0 -0
  16. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/LICENSE +0 -0
  17. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_async_value_hydrator.py +0 -0
  18. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_async_worker_dispatcher.py +0 -0
  19. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_async_worker_service.py +0 -0
  20. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_invocation_context.py +0 -0
  21. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_native.pyi +0 -0
  22. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_utils.py +0 -0
  23. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_value_hydrator.py +0 -0
  24. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_value_mapper.py +0 -0
  25. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_worker_dispatcher.py +0 -0
  26. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/_worker_service.py +0 -0
  27. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/async_worker.py +0 -0
  28. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/attribute.py +0 -0
  29. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/attribute_match.py +0 -0
  30. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/blob_cache.py +0 -0
  31. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/channel.py +0 -0
  32. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/client_options.py +0 -0
  33. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/codec.py +0 -0
  34. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/condition.py +0 -0
  35. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/context.py +0 -0
  36. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/dexpb/__init__.py +0 -0
  37. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/flow.py +0 -0
  38. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/flow_config.py +0 -0
  39. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/flow_info.py +0 -0
  40. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/flow_options.py +0 -0
  41. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/flow_result.py +0 -0
  42. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/py.typed +0 -0
  43. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/step.py +0 -0
  44. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/step_execution.py +0 -0
  45. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/stream.py +0 -0
  46. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/subflow.py +0 -0
  47. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/timer.py +0 -0
  48. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/wait.py +0 -0
  49. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/worker.py +0 -0
  50. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/dex/worker_options.py +0 -0
  51. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  52. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  53. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  54. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  55. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  56. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  57. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  58. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  59. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  60. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  61. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  62. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  63. {dex_python_sdk-0.6.0 → dex_python_sdk-0.7.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -361,6 +361,17 @@ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
361
361
  include buffered publishes, and omit empty instances. Keys are decoded and
362
362
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
363
363
 
364
+ Request IDs are optional for both durable waits. When omitted, the server
365
+ derives a namespaced stable ID from the Step execution or Attribute condition,
366
+ such as `wait-for-attribute:myInt>10`. Reuse an override only for the same
367
+ logical wait. The Client automatically reattaches transport long polls. If an
368
+ earlier Update with that ID exhausted its handler budget, the server appends an
369
+ increasing `-N` suffix and starts a new Update. `maximum_wait_time` is optional
370
+ and is the total handler budget across reattachments; zero waits indefinitely.
371
+ A positive budget expiry raises `WaitHandlerTimeoutError`. An abandoned infinite
372
+ wait remains accepted and counts against Temporal's in-flight Update limit until
373
+ it completes or the Flow closes.
374
+
364
375
  `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
365
376
  `FlowResult` after hydrating every output-bearing completion. Use
366
377
  `single_output` only when the Flow contract produces exactly one output:
@@ -345,6 +345,17 @@ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
345
345
  include buffered publishes, and omit empty instances. Keys are decoded and
346
346
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
347
347
 
348
+ Request IDs are optional for both durable waits. When omitted, the server
349
+ derives a namespaced stable ID from the Step execution or Attribute condition,
350
+ such as `wait-for-attribute:myInt>10`. Reuse an override only for the same
351
+ logical wait. The Client automatically reattaches transport long polls. If an
352
+ earlier Update with that ID exhausted its handler budget, the server appends an
353
+ increasing `-N` suffix and starts a new Update. `maximum_wait_time` is optional
354
+ and is the total handler budget across reattachments; zero waits indefinitely.
355
+ A positive budget expiry raises `WaitHandlerTimeoutError`. An abandoned infinite
356
+ wait remains accepted and counts against Temporal's in-flight Update limit until
357
+ it completes or the Flow closes.
358
+
348
359
  `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
349
360
  `FlowResult` after hydrating every output-bearing completion. Use
350
361
  `single_output` only when the Flow contract produces exactly one output:
@@ -62,10 +62,11 @@ from dex.flow_options import (
62
62
  )
63
63
  from dex.flow_result import FlowResult, StepCompletion
64
64
  from dex.runtime_errors import (
65
- DexServiceError,
66
- ErrorSubStatus,
67
65
  AttributeMapNotLoadedError,
66
+ ChannelMessageNotFoundError,
68
67
  ChannelMessagesNotLoadedError,
68
+ DexServiceError,
69
+ ErrorSubStatus,
69
70
  FlowAlreadyStartedError,
70
71
  FlowDefinitionError,
71
72
  FlowErrorType,
@@ -73,9 +74,9 @@ from dex.runtime_errors import (
73
74
  FlowNotFoundError,
74
75
  InvalidStepResultError,
75
76
  LongPollTimeoutError,
76
- ChannelMessageNotFoundError,
77
77
  RpcLockConflictError,
78
78
  ValueMappingError,
79
+ WaitHandlerTimeoutError,
79
80
  WorkerInvocationError,
80
81
  )
81
82
  from dex.step import (
@@ -108,6 +109,7 @@ from dex.stream import (
108
109
  from dex.subflow import SubFlow
109
110
  from dex.timer import Timer
110
111
  from dex.wait import Wait
112
+ from dex.wait_options import WaitForAttributeOptions, WaitForStepCompletionOptions
111
113
  from dex.worker import Worker
112
114
  from dex.worker_options import WorkerOptions, WorkerTarget
113
115
 
@@ -164,6 +166,7 @@ __all__ = [
164
166
  "IndexType",
165
167
  "JsonCodec",
166
168
  "LongPollTimeoutError",
169
+ "WaitHandlerTimeoutError",
167
170
  "PersistenceSchema",
168
171
  "RPCResult",
169
172
  "RecoveryErrorInfo",
@@ -205,6 +208,8 @@ __all__ = [
205
208
  "WorkerInvocationError",
206
209
  "WorkerOptions",
207
210
  "WorkerTarget",
211
+ "WaitForAttributeOptions",
212
+ "WaitForStepCompletionOptions",
208
213
  "dead_end",
209
214
  "force_complete",
210
215
  "force_complete_if_channels_empty",
@@ -27,12 +27,17 @@ from dex.runtime_errors import (
27
27
  FlowNotFoundError,
28
28
  LongPollTimeoutError,
29
29
  RpcLockConflictError,
30
+ WaitHandlerTimeoutError,
30
31
  WorkerInvocationError,
31
32
  )
32
33
 
33
34
  FlowTargetRequirement = Literal["none", "existing", "active"]
34
35
 
35
- MAX_WORKER_STACK_TRACE_BYTES = 16 * 1024
36
+ MAX_WORKER_ERROR_DETAIL_BYTES = 1024
37
+ MAX_WORKER_ERROR_TYPE_BYTES = 256
38
+ MAX_WORKER_STACK_TRACE_BYTES = 4 * 1024
39
+ _ERROR_DETAIL_TRUNCATION_MARKER = b"\n... error detail truncated by Dex Python SDK ..."
40
+ _ERROR_TYPE_TRUNCATION_MARKER = b"\n... error type truncated by Dex Python SDK ..."
36
41
  _STACK_TRACE_TRUNCATION_MARKER = b"\n... stack trace truncated by Dex Python SDK ..."
37
42
 
38
43
 
@@ -139,6 +144,8 @@ def translate_rpc_error(
139
144
  return LongPollTimeoutError(*parameters)
140
145
  if sub_status is ErrorSubStatus.CHANNEL_MESSAGE_NOT_FOUND:
141
146
  return ChannelMessageNotFoundError(*parameters)
147
+ if sub_status is ErrorSubStatus.WAIT_HANDLER_TIMEOUT:
148
+ return WaitHandlerTimeoutError(*parameters)
142
149
  return DexServiceError(*parameters)
143
150
 
144
151
 
@@ -179,11 +186,19 @@ def _worker_error_status(error: BaseException) -> status_pb2.Status:
179
186
  retry_after_error = error
180
187
  reported = error.cause
181
188
 
182
- message = str(reported) or type(reported).__name__
189
+ message = _truncate_worker_failure_field(
190
+ str(reported) or type(reported).__name__,
191
+ MAX_WORKER_ERROR_DETAIL_BYTES,
192
+ _ERROR_DETAIL_TRUNCATION_MARKER,
193
+ )
183
194
  stack_trace_source = error if retry_after_error is not None else reported
184
195
  worker_error = pb.WorkerErrorResponse(
185
196
  detail=message,
186
- error_type=f"{type(reported).__module__}.{type(reported).__qualname__}",
197
+ error_type=_truncate_worker_failure_field(
198
+ f"{type(reported).__module__}.{type(reported).__qualname__}",
199
+ MAX_WORKER_ERROR_TYPE_BYTES,
200
+ _ERROR_TYPE_TRUNCATION_MARKER,
201
+ ),
187
202
  stack_trace=_worker_stack_trace(stack_trace_source),
188
203
  )
189
204
  if retry_after_error is not None:
@@ -201,16 +216,25 @@ def _worker_stack_trace(error: BaseException) -> str:
201
216
  if error.__traceback__ is None:
202
217
  return ""
203
218
  lines = traceback.format_exception(type(error), error, error.__traceback__)
204
- encoded = "".join(lines).encode()
205
- if len(encoded) <= MAX_WORKER_STACK_TRACE_BYTES:
206
- return encoded.decode()
207
- prefix_length = MAX_WORKER_STACK_TRACE_BYTES - len(_STACK_TRACE_TRUNCATION_MARKER)
219
+ return _truncate_worker_failure_field(
220
+ "".join(lines),
221
+ MAX_WORKER_STACK_TRACE_BYTES,
222
+ _STACK_TRACE_TRUNCATION_MARKER,
223
+ )
224
+
225
+
226
+ def _truncate_worker_failure_field(
227
+ value: str,
228
+ maximum_bytes: int,
229
+ truncation_marker: bytes,
230
+ ) -> str:
231
+ encoded = value.encode()
232
+ if len(encoded) <= maximum_bytes:
233
+ return value
234
+ prefix_length = maximum_bytes - len(truncation_marker)
208
235
  while prefix_length > 0 and (encoded[prefix_length] & 0xC0) == 0x80:
209
236
  prefix_length -= 1
210
- return (
211
- encoded[:prefix_length].decode(errors="replace")
212
- + _STACK_TRACE_TRUNCATION_MARKER.decode()
213
- )
237
+ return encoded[:prefix_length].decode() + truncation_marker.decode()
214
238
 
215
239
 
216
240
  def _map_sub_status(value: int) -> ErrorSubStatus:
@@ -225,5 +249,8 @@ def _map_sub_status(value: int) -> ErrorSubStatus:
225
249
  int(
226
250
  pb.ERROR_SUB_STATUS_CHANNEL_MESSAGE_NOT_FOUND
227
251
  ): ErrorSubStatus.CHANNEL_MESSAGE_NOT_FOUND,
252
+ int(
253
+ pb.ERROR_SUB_STATUS_WAIT_HANDLER_TIME_OUT
254
+ ): ErrorSubStatus.WAIT_HANDLER_TIMEOUT,
228
255
  }
229
256
  return statuses.get(value, ErrorSubStatus.UNCATEGORIZED)
@@ -45,10 +45,15 @@ from dex.flow_options import (
45
45
  _resolve_flow_timeout_policy,
46
46
  )
47
47
  from dex.flow_result import FlowResult, flow_result_from_proto
48
- from dex.runtime_errors import FlowErrorType
48
+ from dex.runtime_errors import FlowErrorType, LongPollTimeoutError
49
49
  from dex.step import RetryPolicy, StepDurability
50
50
  from dex.step_execution import StepExecutionId, TimerId
51
51
  from dex.stream import Stream, StreamMessage, StreamMessagesPage
52
+ from dex.wait_options import (
53
+ WaitForAttributeOptions,
54
+ WaitForStepCompletionOptions,
55
+ _ClientWaitBudget,
56
+ )
52
57
 
53
58
  InputT = TypeVar("InputT")
54
59
  OutputT = TypeVar("OutputT")
@@ -723,33 +728,45 @@ class AsyncClient:
723
728
  self,
724
729
  flow_id: str,
725
730
  step_execution_id: StepExecutionId,
726
- timeout: timedelta,
731
+ options: WaitForStepCompletionOptions,
727
732
  ) -> None:
728
- """Await one Step execution's completion or a long-poll timeout.
733
+ """Await one Step execution's completion or its handler budget expiry.
734
+
735
+ The server derives a stable Request ID from the Step execution when none
736
+ is supplied. Transport long polls automatically reattach to the same wait.
729
737
 
730
738
  Args:
731
739
  flow_id: The non-empty active Flow ID.
732
740
  step_execution_id: The Step type and positive execution number.
733
- timeout: The non-negative server-side wait duration.
741
+ options: The optional Request ID override and total handler wait budget.
734
742
 
735
743
  Raises:
736
- LongPollTimeoutError: If completion is not observed before ``timeout``.
744
+ ValueError: If the Request ID or handler wait budget is invalid.
745
+ WaitHandlerTimeoutError: If a positive handler budget expires first.
737
746
  FlowNotActiveError: If the Flow closes first.
738
747
  DexServiceError: If FlowService cannot perform the wait.
739
748
  """
740
- await self._call(
741
- self._service.WaitForStepCompletion,
742
- pb.WaitForStepCompletionRequest(
743
- flow_id=require_name(flow_id),
744
- step_type=step_execution_id.step_type,
745
- step_execution_number=str(step_execution_id.number),
746
- wait_time_seconds=self._seconds32(timeout),
747
- request_id=str(uuid4()),
748
- ),
749
- "wait_for_step_completion",
750
- flow_id,
751
- "active",
752
- )
749
+ wait_budget = _ClientWaitBudget(options.maximum_wait_time)
750
+ while True:
751
+ try:
752
+ await self._call(
753
+ self._service.WaitForStepCompletion,
754
+ pb.WaitForStepCompletionRequest(
755
+ flow_id=require_name(flow_id),
756
+ step_type=step_execution_id.step_type,
757
+ step_execution_number=str(step_execution_id.number),
758
+ wait_time_seconds=wait_budget.remaining_seconds(
759
+ "wait_for_step_completion", flow_id
760
+ ),
761
+ request_id=options.request_id,
762
+ ),
763
+ "wait_for_step_completion",
764
+ flow_id,
765
+ "active",
766
+ )
767
+ return
768
+ except LongPollTimeoutError:
769
+ continue
753
770
 
754
771
  @overload
755
772
  async def wait_for_attribute_match(
@@ -757,26 +774,26 @@ class AsyncClient:
757
774
  flow_id: str,
758
775
  attribute: Attribute[ValueT],
759
776
  match: AttributeMatch[ValueT],
760
- timeout: timedelta,
777
+ options: WaitForAttributeOptions,
761
778
  ) -> ValueT:
762
779
  """Await a singleton Attribute in the current run satisfying ``match``.
763
780
 
764
- The Client returns the value observed by the successful wait. JSON,
765
- bytes, and null operands raise ``ValueError`` before transport. A remote
766
- expiry raises ``LongPollTimeoutError``.
781
+ The Client returns the value observed by the successful wait. The server
782
+ derives a stable Request ID from the condition when none is supplied.
783
+ JSON, bytes, and null operands raise ``ValueError`` before transport.
767
784
 
768
785
  Args:
769
786
  flow_id: The non-empty active Flow ID.
770
787
  attribute: The registered singleton Attribute to observe.
771
788
  match: The scalar predicate to await.
772
- timeout: The non-negative server-side wait duration.
789
+ options: The optional Request ID override and total handler wait budget.
773
790
 
774
791
  Returns:
775
792
  The current Attribute value that satisfied ``match``.
776
793
 
777
794
  Raises:
778
- ValueError: If an identifier, timeout, or match operand is invalid.
779
- LongPollTimeoutError: If the match is not observed before ``timeout``.
795
+ ValueError: If an identifier, option, or match operand is invalid.
796
+ WaitHandlerTimeoutError: If a positive handler budget expires first.
780
797
  FlowNotActiveError: If the Flow closes first.
781
798
  DexServiceError: If FlowService cannot perform the wait.
782
799
  """
@@ -789,11 +806,11 @@ class AsyncClient:
789
806
  attribute: AttributeMap[ValueT],
790
807
  instance: str,
791
808
  match: AttributeMatch[ValueT],
792
- timeout: timedelta,
809
+ options: WaitForAttributeOptions,
793
810
  ) -> ValueT:
794
811
  """Await one AttributeMap instance in the current run satisfying a match.
795
812
 
796
- Match restrictions, request-ID generation, timeout behavior, and service
813
+ Match restrictions, Request ID, handler-budget behavior, and service
797
814
  errors match :meth:`wait_for_attribute_match`.
798
815
 
799
816
  Args:
@@ -801,14 +818,14 @@ class AsyncClient:
801
818
  attribute: The registered AttributeMap to observe.
802
819
  instance: The map instance to observe. Slash is prohibited because it is a reserved character.
803
820
  match: The scalar predicate to await.
804
- timeout: The non-negative server-side wait duration.
821
+ options: The optional Request ID override and total handler wait budget.
805
822
 
806
823
  Returns:
807
824
  The current AttributeMap value that satisfied ``match``.
808
825
 
809
826
  Raises:
810
- ValueError: If an identifier, timeout, or match operand is invalid.
811
- LongPollTimeoutError: If the match is not observed before ``timeout``.
827
+ ValueError: If an identifier, option, or match operand is invalid.
828
+ WaitHandlerTimeoutError: If a positive handler budget expires first.
812
829
  FlowNotActiveError: If the Flow closes first.
813
830
  DexServiceError: If FlowService cannot perform the wait.
814
831
  """
@@ -824,12 +841,12 @@ class AsyncClient:
824
841
  """Await a singleton Attribute or AttributeMap instance satisfying a match.
825
842
 
826
843
  Singleton form is ``wait_for_attribute_match(flow_id, attribute, match,
827
- timeout)``; map form adds ``instance`` before ``match``.
844
+ options)``; map form adds ``instance`` before ``match``.
828
845
 
829
846
  Args:
830
847
  flow_id: The non-empty active Flow ID.
831
848
  attribute: The registered Attribute or AttributeMap to observe.
832
- *args: Positional match and timeout, optionally preceded by a map instance.
849
+ *args: Positional match and options, optionally preceded by a map instance.
833
850
  **kwargs: The same arguments supplied by name.
834
851
 
835
852
  Returns:
@@ -837,16 +854,16 @@ class AsyncClient:
837
854
 
838
855
  Raises:
839
856
  TypeError: If arguments do not match the Attribute definition.
840
- ValueError: If an identifier, timeout, or expected value is invalid.
841
- LongPollTimeoutError: If the match is not observed before the timeout.
857
+ ValueError: If an identifier, option, or expected value is invalid.
858
+ WaitHandlerTimeoutError: If a positive handler budget expires first.
842
859
  FlowNotActiveError: If the Flow closes first.
843
860
  DexServiceError: If FlowService cannot perform the wait.
844
861
  """
845
- instance, match, timeout = self._attribute_wait_arguments(
862
+ instance, match, options = self._attribute_wait_arguments(
846
863
  attribute, args, kwargs
847
864
  )
848
865
  return await self._wait_for_attribute_match(
849
- flow_id, attribute, instance, match, timeout
866
+ flow_id, attribute, instance, match, options
850
867
  )
851
868
 
852
869
  async def _wait_for_attribute_match(
@@ -855,7 +872,7 @@ class AsyncClient:
855
872
  attribute: Attribute[Any] | AttributeMap[Any],
856
873
  instance: str | None,
857
874
  match: object,
858
- timeout: timedelta,
875
+ options: WaitForAttributeOptions,
859
876
  ) -> Any:
860
877
  encoded_match = _encode_attribute_match(
861
878
  cast(AttributeMatch[Any], match),
@@ -863,27 +880,34 @@ class AsyncClient:
863
880
  self._values.codec(attribute.value_type),
864
881
  )
865
882
  encoded_match.key = self._definition_name(attribute, instance)
866
- response = cast(
867
- pb.WaitForAttributeResponse,
868
- await self._call(
869
- self._service.WaitForAttribute,
870
- pb.WaitForAttributeRequest(
871
- flow_id=require_name(flow_id),
872
- match=encoded_match,
873
- wait_time_seconds=self._seconds32(timeout),
874
- request_id=str(uuid4()),
875
- ),
876
- "wait_for_attribute_match",
877
- flow_id,
878
- "active",
879
- ),
880
- )
881
- if not response.HasField("matched_value"):
882
- raise ValueError("wait_for_attribute_match response is incomplete")
883
- return self._values.decode(
884
- response.matched_value,
885
- self._values.codec(attribute.value_type),
886
- )
883
+ wait_budget = _ClientWaitBudget(options.maximum_wait_time)
884
+ while True:
885
+ try:
886
+ response = cast(
887
+ pb.WaitForAttributeResponse,
888
+ await self._call(
889
+ self._service.WaitForAttribute,
890
+ pb.WaitForAttributeRequest(
891
+ flow_id=require_name(flow_id),
892
+ match=encoded_match,
893
+ wait_time_seconds=wait_budget.remaining_seconds(
894
+ "wait_for_attribute_match", flow_id
895
+ ),
896
+ request_id=options.request_id,
897
+ ),
898
+ "wait_for_attribute_match",
899
+ flow_id,
900
+ "active",
901
+ ),
902
+ )
903
+ if not response.HasField("matched_value"):
904
+ raise ValueError("wait_for_attribute_match response is incomplete")
905
+ return self._values.decode(
906
+ response.matched_value,
907
+ self._values.codec(attribute.value_type),
908
+ )
909
+ except LongPollTimeoutError:
910
+ continue
887
911
 
888
912
  async def update_flow_config(self, flow_id: str, config: FlowConfig) -> None:
889
913
  """Replace mutable configuration for an active Flow.
@@ -1108,12 +1132,12 @@ class AsyncClient:
1108
1132
  definition: Attribute[Any] | AttributeMap[Any],
1109
1133
  args: tuple[object, ...],
1110
1134
  kwargs: dict[str, object],
1111
- ) -> tuple[str | None, object, timedelta]:
1135
+ ) -> tuple[str | None, object, WaitForAttributeOptions]:
1112
1136
  parameter_names: tuple[str, ...]
1113
1137
  if isinstance(definition, Attribute):
1114
- parameter_names = ("match", "timeout")
1138
+ parameter_names = ("match", "options")
1115
1139
  elif isinstance(definition, AttributeMap):
1116
- parameter_names = ("instance", "match", "timeout")
1140
+ parameter_names = ("instance", "match", "options")
1117
1141
  else:
1118
1142
  raise TypeError("wait_for_attribute_match received invalid arguments")
1119
1143
  if len(args) > len(parameter_names):
@@ -1125,15 +1149,15 @@ class AsyncClient:
1125
1149
  arguments[name] = value
1126
1150
  if set(arguments) != set(parameter_names):
1127
1151
  raise TypeError("wait_for_attribute_match received invalid arguments")
1128
- timeout = arguments["timeout"]
1129
- if not isinstance(timeout, timedelta):
1152
+ options = arguments["options"]
1153
+ if not isinstance(options, WaitForAttributeOptions):
1130
1154
  raise TypeError("wait_for_attribute_match received invalid arguments")
1131
1155
  if isinstance(definition, Attribute):
1132
- return None, arguments["match"], timeout
1156
+ return None, arguments["match"], options
1133
1157
  instance = arguments["instance"]
1134
1158
  if not isinstance(instance, str):
1135
1159
  raise TypeError("wait_for_attribute_match received invalid arguments")
1136
- return instance, arguments["match"], timeout
1160
+ return instance, arguments["match"], options
1137
1161
 
1138
1162
  @staticmethod
1139
1163
  def _resolve_timeout_policy(