dex-python-sdk 0.1.1__tar.gz → 0.1.2__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-0.1.1 → dex_python_sdk-0.1.2}/PKG-INFO +57 -3
  2. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/README.md +56 -2
  3. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/__init__.py +18 -2
  4. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_async_worker_dispatcher.py +59 -43
  5. dex_python_sdk-0.1.2/dex/_grpc_errors.py +145 -0
  6. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_invocation_context.py +3 -1
  7. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_value_mapper.py +33 -0
  8. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_worker_dispatcher.py +61 -45
  9. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/async_client.py +357 -30
  10. dex_python_sdk-0.1.2/dex/async_worker.py +189 -0
  11. dex_python_sdk-0.1.2/dex/attribute.py +243 -0
  12. dex_python_sdk-0.1.2/dex/blob_cache.py +145 -0
  13. dex_python_sdk-0.1.2/dex/channel.py +342 -0
  14. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/client.py +350 -24
  15. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/client_options.py +9 -0
  16. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/codec.py +143 -4
  17. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/condition.py +17 -0
  18. dex_python_sdk-0.1.2/dex/context.py +169 -0
  19. dex_python_sdk-0.1.2/dex/dexpb/dex_pb2.py +395 -0
  20. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/dexpb/dex_pb2.pyi +82 -31
  21. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/dexpb/dex_pb2_grpc.py +43 -0
  22. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/flow.py +216 -23
  23. dex_python_sdk-0.1.2/dex/flow_config.py +59 -0
  24. dex_python_sdk-0.1.2/dex/flow_info.py +107 -0
  25. dex_python_sdk-0.1.2/dex/flow_options.py +214 -0
  26. dex_python_sdk-0.1.2/dex/runtime_errors.py +285 -0
  27. dex_python_sdk-0.1.2/dex/step.py +427 -0
  28. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/step_execution.py +36 -0
  29. dex_python_sdk-0.1.2/dex/timer.py +44 -0
  30. dex_python_sdk-0.1.2/dex/wait.py +108 -0
  31. dex_python_sdk-0.1.2/dex/worker.py +214 -0
  32. dex_python_sdk-0.1.2/dex/worker_options.py +52 -0
  33. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/pyproject.toml +1 -1
  34. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/Cargo.lock +140 -0
  35. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/Cargo.toml +5 -0
  36. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/config.rs +31 -0
  37. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/error.rs +11 -0
  38. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/lib.rs +76 -0
  39. dex_python_sdk-0.1.1/dex/_grpc_errors.py +0 -86
  40. dex_python_sdk-0.1.1/dex/async_worker.py +0 -106
  41. dex_python_sdk-0.1.1/dex/attribute.py +0 -87
  42. dex_python_sdk-0.1.1/dex/blob_cache.py +0 -73
  43. dex_python_sdk-0.1.1/dex/channel.py +0 -165
  44. dex_python_sdk-0.1.1/dex/context.py +0 -86
  45. dex_python_sdk-0.1.1/dex/dexpb/dex_pb2.py +0 -381
  46. dex_python_sdk-0.1.1/dex/flow_config.py +0 -29
  47. dex_python_sdk-0.1.1/dex/flow_info.py +0 -57
  48. dex_python_sdk-0.1.1/dex/flow_options.py +0 -122
  49. dex_python_sdk-0.1.1/dex/runtime_errors.py +0 -81
  50. dex_python_sdk-0.1.1/dex/step.py +0 -194
  51. dex_python_sdk-0.1.1/dex/timer.py +0 -21
  52. dex_python_sdk-0.1.1/dex/wait.py +0 -49
  53. dex_python_sdk-0.1.1/dex/worker.py +0 -121
  54. dex_python_sdk-0.1.1/dex/worker_options.py +0 -22
  55. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/LEGACY_NOTICES.md +0 -0
  56. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/LICENSE +0 -0
  57. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_async_value_hydrator.py +0 -0
  58. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_async_worker_service.py +0 -0
  59. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_native.pyi +0 -0
  60. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_utils.py +0 -0
  61. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_value_hydrator.py +0 -0
  62. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/_worker_service.py +0 -0
  63. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/dexpb/__init__.py +0 -0
  64. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/dex/py.typed +0 -0
  65. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  66. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  67. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  68. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  69. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  70. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  71. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  72. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  73. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  74. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.2}/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.1.1
3
+ Version: 0.1.2
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
@@ -39,7 +39,7 @@ class Run(dex.Step[str]):
39
39
  def wait_for(
40
40
  self, context: dex.Context, input: str
41
41
  ) -> dex.Wait:
42
- return dex.Wait.all_of(
42
+ return dex.Wait.until(
43
43
  dex.Timer.by_duration(timedelta(seconds=1))
44
44
  )
45
45
 
@@ -76,6 +76,11 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
76
76
  `PersistenceSchema.of(...)` accepts attributes and channels together and
77
77
  partitions them by definition type.
78
78
 
79
+ `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
80
+ Dex Server before opening their listener. Existing indexes return immediately;
81
+ failure or the default two-minute deadline aborts startup. An indexed
82
+ `AttributeMap` must provide one fixed `index_key`.
83
+
79
84
  Initial attributes retain their value types without a public wrapper class:
80
85
 
81
86
  ```python
@@ -86,6 +91,19 @@ options = (
86
91
  )
87
92
  ```
88
93
 
94
+ Opt in when declaring an Attribute or AttributeMap, and select the Store in
95
+ Flow configuration:
96
+
97
+ ```python
98
+ email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
99
+ config = dex.FlowConfig(attribute_store_name="profiles")
100
+ ```
101
+
102
+ The Store is an asynchronous latest-state projection. Deletion writes SQL
103
+ `NULL`, and projection failures do not roll back Flow Attributes. `None`
104
+ preserves the current target; an explicit empty string disables future
105
+ synchronization while retaining protocol presence.
106
+
89
107
  ```
90
108
  pip install dex-python-sdk==0.1.0
91
109
  ```
@@ -117,6 +135,31 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
117
135
  codec before Client or Worker startup. `Client` methods use these typed objects
118
136
  instead of raw Flow, Step, or RPC strings.
119
137
 
138
+ ### Errors
139
+
140
+ Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
141
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
142
+ `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
143
+ waits, config updates, and continue-as-new triggers raise
144
+ `FlowNotActiveError` when no running Flow can accept the operation.
145
+
146
+ ```python
147
+ try:
148
+ client.publish(flow_id, orders.approved, order_id)
149
+ except dex.FlowNotActiveError:
150
+ # The Flow is missing or already closed.
151
+ pass
152
+ ```
153
+
154
+ Duplicate starts, worker failures, RPC lock contention, and long-poll timeouts
155
+ raise `FlowAlreadyStartedError`, `WorkerInvocationError`,
156
+ `RpcLockConflictError`, and `LongPollTimeoutError`. All service errors retain
157
+ `code`, `sub_status`, `detail`, `operation`, `flow_id`, and the original gRPC
158
+ exception through Python exception chaining. Worker failures also expose
159
+ `worker_code`, `worker_error_type`, and `worker_error_detail`. Registration,
160
+ serialization, and invalid handler returns use `FlowDefinitionError`,
161
+ `ValueMappingError`, and `InvalidStepResultError`.
162
+
120
163
  ### Sync vs asyncio
121
164
 
122
165
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
@@ -209,7 +252,18 @@ make -C ../protos proto-python
209
252
  Checked-in Python stubs land in `dex/dexpb/`.
210
253
  #### Linting
211
254
 
212
- To run linting for this project:
255
+ Validate that every `dex.__all__` class, function, constant, public method,
256
+ argument, return value, dataclass field, enum value, and public instance
257
+ attribute has a Google-style docstring:
258
+
259
+ ```bash
260
+ uv run --frozen python scripts/check_public_docs.py
261
+ ```
262
+
263
+ The checker resolves definitions from the public package export table, so
264
+ private helpers and generated protobuf modules are excluded. Use `help(dex.Client)`
265
+ or IDE hover information to read the same documentation. To run all other
266
+ linting for this project:
213
267
 
214
268
  ```bash
215
269
  uv run --frozen pre-commit run --show-diff-on-failure --color=always --all-files
@@ -23,7 +23,7 @@ class Run(dex.Step[str]):
23
23
  def wait_for(
24
24
  self, context: dex.Context, input: str
25
25
  ) -> dex.Wait:
26
- return dex.Wait.all_of(
26
+ return dex.Wait.until(
27
27
  dex.Timer.by_duration(timedelta(seconds=1))
28
28
  )
29
29
 
@@ -60,6 +60,11 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
60
60
  `PersistenceSchema.of(...)` accepts attributes and channels together and
61
61
  partitions them by definition type.
62
62
 
63
+ `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
64
+ Dex Server before opening their listener. Existing indexes return immediately;
65
+ failure or the default two-minute deadline aborts startup. An indexed
66
+ `AttributeMap` must provide one fixed `index_key`.
67
+
63
68
  Initial attributes retain their value types without a public wrapper class:
64
69
 
65
70
  ```python
@@ -70,6 +75,19 @@ options = (
70
75
  )
71
76
  ```
72
77
 
78
+ Opt in when declaring an Attribute or AttributeMap, and select the Store in
79
+ Flow configuration:
80
+
81
+ ```python
82
+ email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
83
+ config = dex.FlowConfig(attribute_store_name="profiles")
84
+ ```
85
+
86
+ The Store is an asynchronous latest-state projection. Deletion writes SQL
87
+ `NULL`, and projection failures do not roll back Flow Attributes. `None`
88
+ preserves the current target; an explicit empty string disables future
89
+ synchronization while retaining protocol presence.
90
+
73
91
  ```
74
92
  pip install dex-python-sdk==0.1.0
75
93
  ```
@@ -101,6 +119,31 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
101
119
  codec before Client or Worker startup. `Client` methods use these typed objects
102
120
  instead of raw Flow, Step, or RPC strings.
103
121
 
122
+ ### Errors
123
+
124
+ Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
125
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
126
+ `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
127
+ waits, config updates, and continue-as-new triggers raise
128
+ `FlowNotActiveError` when no running Flow can accept the operation.
129
+
130
+ ```python
131
+ try:
132
+ client.publish(flow_id, orders.approved, order_id)
133
+ except dex.FlowNotActiveError:
134
+ # The Flow is missing or already closed.
135
+ pass
136
+ ```
137
+
138
+ Duplicate starts, worker failures, RPC lock contention, and long-poll timeouts
139
+ raise `FlowAlreadyStartedError`, `WorkerInvocationError`,
140
+ `RpcLockConflictError`, and `LongPollTimeoutError`. All service errors retain
141
+ `code`, `sub_status`, `detail`, `operation`, `flow_id`, and the original gRPC
142
+ exception through Python exception chaining. Worker failures also expose
143
+ `worker_code`, `worker_error_type`, and `worker_error_detail`. Registration,
144
+ serialization, and invalid handler returns use `FlowDefinitionError`,
145
+ `ValueMappingError`, and `InvalidStepResultError`.
146
+
104
147
  ### Sync vs asyncio
105
148
 
106
149
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
@@ -193,7 +236,18 @@ make -C ../protos proto-python
193
236
  Checked-in Python stubs land in `dex/dexpb/`.
194
237
  #### Linting
195
238
 
196
- To run linting for this project:
239
+ Validate that every `dex.__all__` class, function, constant, public method,
240
+ argument, return value, dataclass field, enum value, and public instance
241
+ attribute has a Google-style docstring:
242
+
243
+ ```bash
244
+ uv run --frozen python scripts/check_public_docs.py
245
+ ```
246
+
247
+ The checker resolves definitions from the public package export table, so
248
+ private helpers and generated protobuf modules are excluded. Use `help(dex.Client)`
249
+ or IDE hover information to read the same documentation. To run all other
250
+ linting for this project:
197
251
 
198
252
  ```bash
199
253
  uv run --frozen pre-commit run --show-diff-on-failure --color=always --all-files
@@ -53,11 +53,19 @@ from dex.flow_options import (
53
53
  StopType,
54
54
  )
55
55
  from dex.runtime_errors import (
56
- DexException,
56
+ DexServiceError,
57
57
  ErrorSubStatus,
58
+ FlowAlreadyStartedError,
59
+ FlowDefinitionError,
58
60
  FlowErrorType,
61
+ FlowNotActiveError,
62
+ FlowNotFoundError,
59
63
  FlowUncompletedError,
64
+ InvalidStepResultError,
60
65
  LongPollTimeoutError,
66
+ RpcLockConflictError,
67
+ ValueMappingError,
68
+ WorkerInvocationError,
61
69
  )
62
70
  from dex.step import (
63
71
  RetryPolicy,
@@ -105,21 +113,27 @@ __all__ = [
105
113
  "CodecRegistry",
106
114
  "ConditionCombination",
107
115
  "Context",
108
- "DexException",
116
+ "DexServiceError",
109
117
  "ErrorSubStatus",
110
118
  "Flow",
119
+ "FlowAlreadyStartedError",
111
120
  "FlowConfig",
121
+ "FlowDefinitionError",
112
122
  "FlowErrorType",
113
123
  "FlowInfo",
124
+ "FlowNotActiveError",
125
+ "FlowNotFoundError",
114
126
  "FlowStatus",
115
127
  "FlowUncompletedError",
116
128
  "HealthInfo",
117
129
  "IdReusePolicy",
130
+ "InvalidStepResultError",
118
131
  "IndexType",
119
132
  "JsonCodec",
120
133
  "LongPollTimeoutError",
121
134
  "PersistenceSchema",
122
135
  "RPCResult",
136
+ "RpcLockConflictError",
123
137
  "Registry",
124
138
  "ResetFlowOptions",
125
139
  "ResetType",
@@ -139,10 +153,12 @@ __all__ = [
139
153
  "Timer",
140
154
  "TimerId",
141
155
  "Value",
156
+ "ValueMappingError",
142
157
  "Wait",
143
158
  "WaitForFailurePolicy",
144
159
  "WireKind",
145
160
  "Worker",
161
+ "WorkerInvocationError",
146
162
  "WorkerOptions",
147
163
  "WorkerTarget",
148
164
  "dead_end",
@@ -17,6 +17,7 @@ from dex._value_mapper import ValueMapper
17
17
  from dex._worker_dispatcher import WorkerDispatcher
18
18
  from dex.dexpb import dex_pb2 as pb
19
19
  from dex.flow import RPCResult, Registry
20
+ from dex.runtime_errors import InvalidStepResultError, ValueMappingError
20
21
  from dex.step import StepDecision
21
22
  from dex.wait import Wait
22
23
 
@@ -30,13 +31,13 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
30
31
  ) -> None:
31
32
  self._registry = registry
32
33
  self._values = values
33
- self._hydrator = hydrator # type: ignore[assignment]
34
+ self._async_hydrator = hydrator
34
35
 
35
36
  async def invoke_wait_for( # type: ignore[override]
36
37
  self,
37
38
  original: pb.InvokeWaitForMethodRequest,
38
39
  ) -> pb.InvokeWaitForMethodResponse:
39
- request = await self._hydrator.wait_for_request(original)
40
+ request = await self._async_hydrator.wait_for_request(original)
40
41
  flow = self._registry._flow_by_type(request.flow_type)
41
42
  step = flow.step(request.step_type)
42
43
  context = InvocationContext(
@@ -50,24 +51,29 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
50
51
  wait = step.step.wait_for(context, input)
51
52
  if isawaitable(wait):
52
53
  wait = await wait
53
- if not isinstance(wait, Wait):
54
- raise TypeError("wait_for must return Wait")
55
- response = pb.InvokeWaitForMethodResponse(
56
- upsert_attributes=list(context.attribute_writes.values()),
57
- upsert_step_exe_locals=list(context.local_writes.values()),
58
- record_events=context.events,
59
- publish_to_channel=context.publications,
60
- )
61
- waiting = self._map_wait(flow, wait)
62
- if waiting is not None:
63
- response.waiting_condition.CopyFrom(waiting)
64
- return response
54
+ try:
55
+ if not isinstance(wait, Wait):
56
+ raise TypeError("wait_for must return Wait")
57
+ response = pb.InvokeWaitForMethodResponse(
58
+ upsert_attributes=list(context.attribute_writes.values()),
59
+ upsert_step_exe_locals=list(context.local_writes.values()),
60
+ record_events=context.events,
61
+ publish_to_channel=context.publications,
62
+ )
63
+ waiting = self._map_wait(flow, wait)
64
+ if waiting is not None:
65
+ response.waiting_condition.CopyFrom(waiting)
66
+ return response
67
+ except (TypeError, ValueError) as error:
68
+ raise InvalidStepResultError(
69
+ flow.name, step.name, "wait_for", str(error)
70
+ ) from error
65
71
 
66
72
  async def invoke_execute( # type: ignore[override]
67
73
  self,
68
74
  original: pb.InvokeExecuteMethodRequest,
69
75
  ) -> pb.InvokeExecuteMethodResponse:
70
- request = await self._hydrator.execute_request(original)
76
+ request = await self._async_hydrator.execute_request(original)
71
77
  flow = self._registry._flow_by_type(request.flow_type)
72
78
  step = flow.step(request.step_type)
73
79
  condition_results = (
@@ -86,21 +92,26 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
86
92
  decision: Any = step.step.execute(context, input)
87
93
  if isawaitable(decision):
88
94
  decision = await decision
89
- if not isinstance(decision, StepDecision):
90
- raise TypeError("execute must return StepDecision")
91
- return pb.InvokeExecuteMethodResponse(
92
- step_decision=self._map_decision(flow, decision),
93
- upsert_attributes=list(context.attribute_writes.values()),
94
- record_events=context.events,
95
- upsert_step_exe_locals=list(context.local_writes.values()),
96
- publish_to_channel=context.publications,
97
- )
95
+ try:
96
+ if not isinstance(decision, StepDecision):
97
+ raise TypeError("execute must return StepDecision")
98
+ return pb.InvokeExecuteMethodResponse(
99
+ step_decision=self._map_decision(flow, decision),
100
+ upsert_attributes=list(context.attribute_writes.values()),
101
+ record_events=context.events,
102
+ upsert_step_exe_locals=list(context.local_writes.values()),
103
+ publish_to_channel=context.publications,
104
+ )
105
+ except (TypeError, ValueError) as error:
106
+ raise InvalidStepResultError(
107
+ flow.name, step.name, "execute", str(error)
108
+ ) from error
98
109
 
99
110
  async def invoke_rpc( # type: ignore[override]
100
111
  self,
101
112
  original: pb.InvokeWorkerRPCRequest,
102
113
  ) -> pb.InvokeWorkerRPCResponse:
103
- request = await self._hydrator.rpc_request(original)
114
+ request = await self._async_hydrator.rpc_request(original)
104
115
  flow = self._registry._flow_by_type(request.flow_type)
105
116
  rpc = flow.rpc(request.rpc_name)
106
117
  context = InvocationContext(
@@ -117,23 +128,28 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
117
128
  returned: Any = rpc.method(*arguments)
118
129
  if isawaitable(returned):
119
130
  returned = await returned
120
- response = pb.InvokeWorkerRPCResponse(
121
- upsert_attributes=list(context.attribute_writes.values()),
122
- record_events=context.events,
123
- publish_to_channel=context.publications,
124
- )
125
- if isinstance(returned, RPCResult):
126
- if rpc.output_codec is None:
127
- raise TypeError("RPCResult requires an output type")
128
- response.output.CopyFrom(
129
- self._values.encode(returned.output, rpc.output_codec)
131
+ try:
132
+ response = pb.InvokeWorkerRPCResponse(
133
+ upsert_attributes=list(context.attribute_writes.values()),
134
+ record_events=context.events,
135
+ publish_to_channel=context.publications,
130
136
  )
131
- if returned.next_steps:
132
- response.step_decision.next_steps.extend(
133
- self._map_movements(flow, returned.next_steps)
137
+ if isinstance(returned, RPCResult):
138
+ if rpc.output_codec is None:
139
+ raise TypeError("RPCResult requires an output type")
140
+ response.output.CopyFrom(
141
+ self._values.encode(returned.output, rpc.output_codec)
134
142
  )
135
- elif returned is None and rpc.output_codec is None:
136
- response.output.CopyFrom(self._values.encode_dynamic(None))
137
- else:
138
- raise TypeError("RPC must return RPCResult or None")
139
- return response
143
+ if returned.next_steps:
144
+ response.step_decision.next_steps.extend(
145
+ self._map_movements(flow, returned.next_steps)
146
+ )
147
+ elif returned is None and rpc.output_codec is None:
148
+ response.output.CopyFrom(self._values.encode_dynamic(None))
149
+ else:
150
+ raise TypeError("RPC must return RPCResult or None")
151
+ return response
152
+ except ValueMappingError:
153
+ raise
154
+ except (TypeError, ValueError) as error:
155
+ raise InvalidStepResultError(flow.name, None, "rpc", str(error)) from error
@@ -0,0 +1,145 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, cast
12
+
13
+ import grpc
14
+ from google.protobuf import any_pb2
15
+ from google.rpc import status_pb2
16
+ from grpc_status import rpc_status
17
+
18
+ from dex.dexpb import dex_pb2 as pb
19
+ from dex.runtime_errors import (
20
+ DexServiceError,
21
+ ErrorSubStatus,
22
+ FlowAlreadyStartedError,
23
+ FlowNotActiveError,
24
+ FlowNotFoundError,
25
+ LongPollTimeoutError,
26
+ RpcLockConflictError,
27
+ WorkerInvocationError,
28
+ )
29
+
30
+ FlowTargetRequirement = Literal["none", "existing", "active"]
31
+
32
+
33
+ def translate_rpc_error(
34
+ error: grpc.RpcError,
35
+ operation: str,
36
+ flow_id: str | None,
37
+ requirement: FlowTargetRequirement,
38
+ ) -> DexServiceError:
39
+ details: pb.ErrorResponse | None = None
40
+ try:
41
+ status = rpc_status.from_call(cast(grpc.Call, error))
42
+ if status is not None:
43
+ for packed in status.details:
44
+ candidate = pb.ErrorResponse()
45
+ if packed.Is(candidate.DESCRIPTOR):
46
+ packed.Unpack(candidate)
47
+ details = candidate
48
+ break
49
+ except Exception as malformed:
50
+ translated = DexServiceError(
51
+ error.code(),
52
+ ErrorSubStatus.UNCATEGORIZED,
53
+ f"Dex returned malformed error details: {malformed}",
54
+ operation,
55
+ flow_id,
56
+ )
57
+ return translated
58
+ detail = (
59
+ details.detail if details is not None and details.detail else error.details()
60
+ ) or str(error)
61
+ code = error.code()
62
+ sub_status = (
63
+ _map_sub_status(details.sub_status)
64
+ if details is not None
65
+ else ErrorSubStatus.UNCATEGORIZED
66
+ )
67
+ parameters = (code, sub_status, detail, operation, flow_id)
68
+ if sub_status is ErrorSubStatus.FLOW_ALREADY_STARTED:
69
+ return FlowAlreadyStartedError(*parameters)
70
+ if sub_status is ErrorSubStatus.FLOW_NOT_EXISTS:
71
+ if requirement == "existing":
72
+ return FlowNotFoundError(*parameters)
73
+ if requirement == "active":
74
+ return FlowNotActiveError(*parameters)
75
+ return DexServiceError(*parameters)
76
+ if sub_status is ErrorSubStatus.WORKER_API_ERROR:
77
+ if code is grpc.StatusCode.ABORTED:
78
+ return RpcLockConflictError(*parameters)
79
+ return WorkerInvocationError(
80
+ *parameters,
81
+ _worker_code(details),
82
+ details.original_worker_error_type if details is not None else "",
83
+ details.original_worker_error_detail if details is not None else "",
84
+ )
85
+ if sub_status is ErrorSubStatus.LONG_POLL_TIMEOUT:
86
+ return LongPollTimeoutError(*parameters)
87
+ return DexServiceError(*parameters)
88
+
89
+
90
+ def _worker_code(details: pb.ErrorResponse | None) -> grpc.StatusCode | None:
91
+ if details is None or details.original_worker_error_status == 0:
92
+ return None
93
+ return next(
94
+ (
95
+ code
96
+ for code in grpc.StatusCode
97
+ if code.value[0] == details.original_worker_error_status
98
+ ),
99
+ grpc.StatusCode.UNKNOWN,
100
+ )
101
+
102
+
103
+ def abort_worker_error(
104
+ context: grpc.ServicerContext,
105
+ error: BaseException,
106
+ ) -> None:
107
+ context.abort_with_status(rpc_status.to_status(_worker_error_status(error)))
108
+
109
+
110
+ async def async_abort_worker_error(
111
+ context: grpc.aio.ServicerContext[Any, Any],
112
+ error: BaseException,
113
+ ) -> None:
114
+ # types-grpcio on 3.11 omits aio abort_with_status; runtime provides it.
115
+ await cast(Any, context).abort_with_status(
116
+ rpc_status.to_status(_worker_error_status(error))
117
+ )
118
+
119
+
120
+ def _worker_error_status(error: BaseException) -> status_pb2.Status:
121
+ message = str(error) or type(error).__name__
122
+ worker_error = pb.WorkerErrorResponse(
123
+ detail=message,
124
+ error_type=f"{type(error).__module__}.{type(error).__qualname__}",
125
+ )
126
+ packed = any_pb2.Any()
127
+ packed.Pack(worker_error)
128
+ return status_pb2.Status(
129
+ code=grpc.StatusCode.UNKNOWN.value[0],
130
+ message=message,
131
+ details=[packed],
132
+ )
133
+
134
+
135
+ def _map_sub_status(value: int) -> ErrorSubStatus:
136
+ statuses: dict[int, ErrorSubStatus] = {
137
+ int(pb.ERROR_SUB_STATUS_UNCATEGORIZED): ErrorSubStatus.UNCATEGORIZED,
138
+ int(
139
+ pb.ERROR_SUB_STATUS_FLOW_ALREADY_STARTED
140
+ ): ErrorSubStatus.FLOW_ALREADY_STARTED,
141
+ int(pb.ERROR_SUB_STATUS_FLOW_NOT_EXISTS): ErrorSubStatus.FLOW_NOT_EXISTS,
142
+ int(pb.ERROR_SUB_STATUS_WORKER_API_ERROR): ErrorSubStatus.WORKER_API_ERROR,
143
+ int(pb.ERROR_SUB_STATUS_LONG_POLL_TIME_OUT): ErrorSubStatus.LONG_POLL_TIMEOUT,
144
+ }
145
+ return statuses.get(value, ErrorSubStatus.UNCATEGORIZED)
@@ -13,7 +13,7 @@ from typing import Any, Sequence, TypeVar, cast
13
13
 
14
14
  from dex._utils import require_name
15
15
  from dex._value_mapper import ValueMapper
16
- from dex.attribute import Attribute, AttributeMap
16
+ from dex.attribute import Attribute, AttributeMap, _apply_attribute_store_sync
17
17
  from dex.channel import Channel, ChannelMap
18
18
  from dex.codec import Codec
19
19
  from dex.dexpb import dex_pb2 as pb
@@ -161,6 +161,7 @@ class InvocationContext:
161
161
  )
162
162
  if index is not None:
163
163
  write.index_config.CopyFrom(index)
164
+ _apply_attribute_store_sync(write, definition)
164
165
  self.attribute_writes[key] = write
165
166
 
166
167
  def _delete_attribute(
@@ -177,6 +178,7 @@ class InvocationContext:
177
178
  )
178
179
  if index is not None:
179
180
  write.index_config.CopyFrom(index)
181
+ _apply_attribute_store_sync(write, definition)
180
182
  self.attribute_writes[key] = write
181
183
 
182
184
  def _publish_channel(
@@ -19,6 +19,7 @@ from google.protobuf import struct_pb2
19
19
  from dex.attribute import AttributeIndex, IndexType
20
20
  from dex.codec import Codec, CodecRegistry, Value, WireKind
21
21
  from dex.dexpb import dex_pb2 as pb
22
+ from dex.runtime_errors import ValueMappingError
22
23
 
23
24
  _JSON_ENCODING = "json"
24
25
  _RAW_BYTES_ENCODING = "rawbytes"
@@ -32,6 +33,14 @@ class ValueMapper:
32
33
  return self._codecs.resolve(value_type)
33
34
 
34
35
  def encode(self, value: Any, codec: Codec[Any]) -> pb.Value:
36
+ try:
37
+ return self._encode(value, codec)
38
+ except ValueMappingError:
39
+ raise
40
+ except Exception as error:
41
+ raise ValueMappingError("encode", str(error)) from error
42
+
43
+ def _encode(self, value: Any, codec: Codec[Any]) -> pb.Value:
35
44
  if value is None:
36
45
  return pb.Value(
37
46
  obj_value=pb.EncodedObject(
@@ -66,6 +75,14 @@ class ValueMapper:
66
75
  raise TypeError(f"unsupported wire kind {logical.kind}")
67
76
 
68
77
  def encode_dynamic(self, value: Any) -> pb.Value:
78
+ try:
79
+ return self._encode_dynamic(value)
80
+ except ValueMappingError:
81
+ raise
82
+ except Exception as error:
83
+ raise ValueMappingError("encode", str(error)) from error
84
+
85
+ def _encode_dynamic(self, value: Any) -> pb.Value:
69
86
  if value is None:
70
87
  return self.encode(value, self._codecs.resolve(type(None)))
71
88
  value_type = type(value)
@@ -83,6 +100,14 @@ class ValueMapper:
83
100
  return self.encode(value, codec)
84
101
 
85
102
  def decode(self, value: pb.Value, codec: Codec[Any]) -> Any:
103
+ try:
104
+ return self._decode(value, codec)
105
+ except ValueMappingError:
106
+ raise
107
+ except Exception as error:
108
+ raise ValueMappingError("decode", str(error)) from error
109
+
110
+ def _decode(self, value: pb.Value, codec: Codec[Any]) -> Any:
86
111
  kind = value.WhichOneof("kind")
87
112
  expected = codec.wire_kind
88
113
  if kind == "string_value" and expected is WireKind.STRING:
@@ -113,6 +138,14 @@ class ValueMapper:
113
138
  raise TypeError(f"cannot decode {kind or 'empty Value'} as {codec.type_name}")
114
139
 
115
140
  def to_value(self, value: pb.Value) -> Value:
141
+ try:
142
+ return self._to_value(value)
143
+ except ValueMappingError:
144
+ raise
145
+ except Exception as error:
146
+ raise ValueMappingError("decode", str(error)) from error
147
+
148
+ def _to_value(self, value: pb.Value) -> Value:
116
149
  kind = value.WhichOneof("kind")
117
150
  if kind == "string_value":
118
151
  return Value(WireKind.STRING, value.string_value)