durable-workflow 2.1.2__tar.gz → 2.3.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 (90) hide show
  1. {durable_workflow-2.1.2/src/durable_workflow.egg-info → durable_workflow-2.3.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/pyproject.toml +4 -4
  3. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/__init__.py +3 -0
  4. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/activity.py +10 -1
  5. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/client.py +66 -13
  6. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/errors.py +5 -3
  7. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/external_storage.py +12 -2
  8. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/worker.py +412 -26
  9. durable_workflow-2.3.0/src/durable_workflow/worker_session.py +115 -0
  10. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/workflow.py +183 -8
  11. {durable_workflow-2.1.2 → durable_workflow-2.3.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  12. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/SOURCES.txt +2 -0
  13. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_client.py +24 -0
  14. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_external_storage.py +219 -1
  15. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_release_metadata.py +1 -1
  16. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_replay.py +81 -0
  17. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_replay_regression_corpus.py +7 -0
  18. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_worker.py +263 -5
  19. durable_workflow-2.3.0/tests/test_worker_session.py +118 -0
  20. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/LICENSE +0 -0
  21. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/README.md +0 -0
  22. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/setup.cfg +0 -0
  23. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/_avro.py +0 -0
  24. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/auth_composition.py +0 -0
  25. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/external_task_input.py +0 -0
  26. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/external_task_result.py +0 -0
  27. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  28. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/interceptors.py +0 -0
  29. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/invocable.py +0 -0
  30. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/metrics.py +0 -0
  31. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/nexus.py +0 -0
  32. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/py.typed +0 -0
  33. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/python_conformance.py +0 -0
  34. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/replay_conformance.py +0 -0
  35. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/replay_verify.py +0 -0
  36. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/retry_policy.py +0 -0
  37. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/serializer.py +0 -0
  38. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/sync.py +0 -0
  39. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/testing.py +0 -0
  40. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  41. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  42. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  43. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  44. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  45. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_activity_context.py +0 -0
  46. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_auth_composition.py +0 -0
  47. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_avro_value_benchmark.py +0 -0
  48. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_avro_value_protocol.py +0 -0
  49. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_ci_checkout.py +0 -0
  50. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_ci_integration_endpoint.py +0 -0
  51. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_codec_regression_corpus.py +0 -0
  52. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  53. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_docstring_cross_references.py +0 -0
  54. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_durable_selection.py +0 -0
  55. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_errors.py +0 -0
  56. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_external_task_input.py +0 -0
  57. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_external_task_result.py +0 -0
  58. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_golden_history_replay.py +0 -0
  59. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_history_bundle_verify.py +0 -0
  60. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_history_event_contract.py +0 -0
  61. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_invocable.py +0 -0
  62. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_message_streams.py +0 -0
  63. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_metrics.py +0 -0
  64. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_order_processing_example.py +0 -0
  65. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_parallel_completion_order_replay.py +0 -0
  66. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_parallel_saga_authoring.py +0 -0
  67. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_public_boundary_scanner.py +0 -0
  68. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_pypi_project_surface.py +0 -0
  69. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_python_conformance.py +0 -0
  70. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_queries.py +0 -0
  71. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_readme_quickstart.py +0 -0
  72. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_redrive_replay.py +0 -0
  73. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_replay_conformance.py +0 -0
  74. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_replay_verify.py +0 -0
  75. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_retry_policy.py +0 -0
  76. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_runtime_external_payload_transport.py +0 -0
  77. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_schedules.py +0 -0
  78. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_serializer.py +0 -0
  79. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_signals.py +0 -0
  80. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_sleep.py +0 -0
  81. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_standalone_activity_client.py +0 -0
  82. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_storage_admission.py +0 -0
  83. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_sync.py +0 -0
  84. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_testing_harness.py +0 -0
  85. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_update_signal_condition_replay.py +0 -0
  86. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_updates.py +0 -0
  87. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_wait_condition.py +0 -0
  88. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_workflow_result_timeout.py +0 -0
  89. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_workflow_streams.py +0 -0
  90. {durable_workflow-2.1.2 → durable_workflow-2.3.0}/tests/test_workflow_updates_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.1.2
3
+ Version: 2.3.0
4
4
  Summary: Python client and worker SDK for Durable Workflow Cloud and self-hosted Server
5
5
  Author: Durable Workflow Contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "durable-workflow"
7
- version = "2.1.2"
7
+ version = "2.3.0"
8
8
  description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -71,9 +71,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
71
71
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
72
72
 
73
73
  [tool.durable-workflow]
74
- product-train = "2.1.2"
75
- registry-version = "2.1.2"
76
- supported-server-versions = "2.4.0"
74
+ product-train = "2.3.0"
75
+ registry-version = "2.3.0"
76
+ supported-server-versions = "2.4.2"
77
77
  worker-protocol-version = "1.19"
78
78
  control-plane-version = "2"
79
79
  durable-selection = true
@@ -210,6 +210,7 @@ from .serializer import (
210
210
  to_avro_payload_values,
211
211
  )
212
212
  from .worker import Worker
213
+ from .worker_session import WorkerSession, WorkerSessionOptions
213
214
  from .workflow import (
214
215
  ActivityRetryPolicy,
215
216
  ChildWorkflowRetryPolicy,
@@ -290,6 +291,8 @@ __all__ = [
290
291
  "TaskQueueQueryAdmission",
291
292
  "TaskQueueTaskAdmission",
292
293
  "Worker",
294
+ "WorkerSession",
295
+ "WorkerSessionOptions",
293
296
  "WorkerInterceptor",
294
297
  "WorkflowCommandResult",
295
298
  "WorkflowExecution",
@@ -2,7 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import contextvars
5
- from collections.abc import Callable
5
+ from collections.abc import Awaitable, Callable
6
6
  from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
@@ -38,9 +38,11 @@ class ActivityContext:
38
38
  *,
39
39
  info: ActivityInfo,
40
40
  client: Client,
41
+ heartbeat_callback: Callable[[dict[str, Any] | None], Awaitable[None]] | None = None,
41
42
  ) -> None:
42
43
  self._info = info
43
44
  self._client = client
45
+ self._heartbeat_callback = heartbeat_callback
44
46
  self._cancel_requested = False
45
47
 
46
48
  @property
@@ -65,6 +67,13 @@ class ActivityContext:
65
67
  owning workflow has requested cancellation, so the activity can exit
66
68
  cleanly at its next natural break point.
67
69
  """
70
+ if self._heartbeat_callback is not None:
71
+ try:
72
+ await self._heartbeat_callback(details)
73
+ except ActivityCancelled:
74
+ self._cancel_requested = True
75
+ raise
76
+ return
68
77
  resp = await self._client.heartbeat_activity_task(
69
78
  task_id=self._info.task_id,
70
79
  activity_attempt_id=self._info.activity_attempt_id,
@@ -31,7 +31,7 @@ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
31
31
  from dataclasses import dataclass
32
32
  from importlib.metadata import PackageNotFoundError
33
33
  from importlib.metadata import version as _pkg_version
34
- from typing import Any
34
+ from typing import Any, cast
35
35
  from urllib.parse import quote, unquote, urlencode, urlsplit
36
36
 
37
37
  import httpx
@@ -61,19 +61,20 @@ from .external_storage import (
61
61
  from .metrics import CLIENT_REQUEST_DURATION_SECONDS, CLIENT_REQUESTS, NOOP_METRICS, MetricsRecorder
62
62
  from .nexus import NexusOperationResult, nexus_request_payload
63
63
  from .retry_policy import TransportRetryPolicy
64
+ from .worker_session import WorkerSessionOptions
64
65
 
65
66
  PROTOCOL_VERSION = "1.19"
66
67
  CONTROL_PLANE_VERSION = "2"
67
68
  PORTABLE_WORKER_AFFINITY_CAPABILITY_MANIFEST: dict[str, dict[str, str | bool]] = {
68
69
  "local_activities": {
69
- "supported": False,
70
+ "supported": True,
70
71
  "minimum_protocol_version": "1.18",
71
- "reason": "python_worker_does_not_execute_record_local_activity",
72
+ "implementation": "record_local_activity",
72
73
  },
73
74
  "worker_sessions": {
74
- "supported": False,
75
+ "supported": True,
75
76
  "minimum_protocol_version": "1.18",
76
- "reason": "python_worker_has_no_typed_session_lifecycle",
77
+ "implementation": "typed_worker_session",
77
78
  },
78
79
  "sticky_execution": {
79
80
  "supported": False,
@@ -1316,7 +1317,7 @@ class WorkflowHandle:
1316
1317
  return await self._client.query_workflow(self.workflow_id, query_name, args=args)
1317
1318
 
1318
1319
  async def cancel(self, *, reason: str | None = None) -> None:
1319
- """Request graceful cancellation of this workflow. See :meth:`Client.cancel_workflow`."""
1320
+ """Close this workflow's current run as cancelled. See :meth:`Client.cancel_workflow`."""
1320
1321
  await self._client.cancel_workflow(self.workflow_id, reason=reason)
1321
1322
 
1322
1323
  async def terminate(self, *, reason: str | None = None) -> None:
@@ -4114,11 +4115,13 @@ class Client:
4114
4115
  )
4115
4116
 
4116
4117
  async def cancel_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
4117
- """Request graceful cancellation of a workflow's current run.
4118
+ """Close the current run as cancelled immediately.
4118
4119
 
4119
- Cancellation is cooperative: the server delivers a cancellation signal
4120
- that the workflow can observe and handle (e.g. to roll back via a
4121
- saga). Compare with :meth:`terminate_workflow`, which is forceful.
4120
+ Server cancels open tasks and timers; it does not resume workflow code
4121
+ to run saga or ``finally`` cleanup. :meth:`terminate_workflow` also
4122
+ closes immediately, with a distinct terminal outcome. Embedded
4123
+ Laravel's cooperative ``requestCancellation()`` is not yet available
4124
+ through this service-mode API.
4122
4125
  """
4123
4126
  body: dict[str, Any] = {}
4124
4127
  if reason is not None:
@@ -4126,10 +4129,11 @@ class Client:
4126
4129
  await self._request("POST", f"/workflows/{workflow_id}/cancel", json=body, context=workflow_id)
4127
4130
 
4128
4131
  async def terminate_workflow(self, workflow_id: str, *, reason: str | None = None) -> None:
4129
- """Forcefully stop a workflow without giving it a chance to clean up.
4132
+ """Close the current run as terminated immediately.
4130
4133
 
4131
- Prefer :meth:`cancel_workflow` when the workflow code can implement
4132
- graceful shutdown. Termination is an operator escape hatch.
4134
+ Like :meth:`cancel_workflow`, this does not resume workflow code for
4135
+ cleanup. Use the distinct terminal outcome when termination is the
4136
+ appropriate operator action.
4133
4137
  """
4134
4138
  body: dict[str, Any] = {}
4135
4139
  if reason is not None:
@@ -4667,6 +4671,7 @@ class Client:
4667
4671
  supported_activity_types: list[str] | None = None,
4668
4672
  max_concurrent_workflow_tasks: int | None = None,
4669
4673
  max_concurrent_activity_tasks: int | None = None,
4674
+ max_concurrent_worker_sessions: int | None = None,
4670
4675
  runtime: str = "python",
4671
4676
  sdk_version: str | None = None,
4672
4677
  build_id: str | None = None,
@@ -4690,6 +4695,8 @@ class Client:
4690
4695
  raise ValueError("max_concurrent_workflow_tasks must be at least 1")
4691
4696
  if max_concurrent_activity_tasks is not None and max_concurrent_activity_tasks < 1:
4692
4697
  raise ValueError("max_concurrent_activity_tasks must be at least 1")
4698
+ if max_concurrent_worker_sessions is not None and max_concurrent_worker_sessions < 1:
4699
+ raise ValueError("max_concurrent_worker_sessions must be at least 1")
4693
4700
  if (
4694
4701
  capabilities
4695
4702
  and _MESSAGE_STREAMS_CAPABILITY in capabilities
@@ -4718,6 +4725,8 @@ class Client:
4718
4725
  body["max_concurrent_workflow_tasks"] = max_concurrent_workflow_tasks
4719
4726
  if max_concurrent_activity_tasks is not None:
4720
4727
  body["max_concurrent_activity_tasks"] = max_concurrent_activity_tasks
4728
+ if max_concurrent_worker_sessions is not None:
4729
+ body["max_concurrent_worker_sessions"] = max_concurrent_worker_sessions
4721
4730
  if task_slots is not None:
4722
4731
  body["task_slots"] = task_slots
4723
4732
  if process_metrics is not None:
@@ -4726,6 +4735,32 @@ class Client:
4726
4735
  body["heartbeat_interval_seconds"] = heartbeat_interval_seconds
4727
4736
  return await self._request("POST", "/worker/register", worker=True, json=body)
4728
4737
 
4738
+ async def create_worker_session(
4739
+ self, worker_id: str, options: WorkerSessionOptions
4740
+ ) -> dict[str, Any]:
4741
+ return cast(dict[str, Any], await self._request(
4742
+ "POST", "/worker/sessions", worker=True,
4743
+ json={"worker_id": worker_id, **options.to_wire()},
4744
+ ))
4745
+
4746
+ async def renew_worker_session(
4747
+ self, worker_id: str, session_id: str, lease_seconds: int
4748
+ ) -> dict[str, Any]:
4749
+ if lease_seconds < 1:
4750
+ raise ValueError("worker session lease_seconds must be positive")
4751
+ return cast(dict[str, Any], await self._request(
4752
+ "POST", f"/worker/sessions/{quote(session_id, safe='')}/heartbeat", worker=True,
4753
+ json={"worker_id": worker_id, "lease_seconds": lease_seconds},
4754
+ ))
4755
+
4756
+ async def close_worker_session(
4757
+ self, worker_id: str, session_id: str, reason: str = "worker_shutdown"
4758
+ ) -> dict[str, Any]:
4759
+ return cast(dict[str, Any], await self._request(
4760
+ "DELETE", f"/worker/sessions/{quote(session_id, safe='')}", worker=True,
4761
+ json={"worker_id": worker_id, "reason": reason},
4762
+ ))
4763
+
4729
4764
  async def deregister_worker_registration(self, worker_id: str) -> dict[str, Any]:
4730
4765
  """Remove this runtime's successful worker-plane registration.
4731
4766
 
@@ -4964,6 +4999,24 @@ class Client:
4964
4999
  "POST", f"/worker/workflow-tasks/{task_id}/fail", worker=True, json=body
4965
5000
  )
4966
5001
 
5002
+ async def heartbeat_workflow_task(
5003
+ self,
5004
+ *,
5005
+ task_id: str,
5006
+ lease_owner: str,
5007
+ workflow_task_attempt: int,
5008
+ ) -> Any:
5009
+ """Renew a leased workflow task while in-process work is running."""
5010
+ return await self._request(
5011
+ "POST",
5012
+ f"/worker/workflow-tasks/{task_id}/heartbeat",
5013
+ worker=True,
5014
+ json={
5015
+ "lease_owner": lease_owner,
5016
+ "workflow_task_attempt": workflow_task_attempt,
5017
+ },
5018
+ )
5019
+
4967
5020
  async def workflow_task_history(
4968
5021
  self,
4969
5022
  *,
@@ -581,7 +581,8 @@ class ActivityFailed(DurableWorkflowError):
581
581
  class WorkflowTerminated(DurableWorkflowError):
582
582
  """A workflow was terminated by operator action.
583
583
 
584
- Termination is non-gracious and skips normal cleanup, unlike cancellation.
584
+ Like terminal cancellation, termination does not resume workflow cleanup.
585
+ It records a distinct terminal outcome.
585
586
  """
586
587
 
587
588
  def __init__(self, message: str = "workflow was terminated") -> None:
@@ -643,8 +644,9 @@ class ActivityCancelled(BaseException):
643
644
  """An in-flight activity was cancelled.
644
645
 
645
646
  Raised inside :meth:`durable_workflow.ActivityContext.heartbeat` when the
646
- server reports that the owning workflow has asked for cancellation, so the
647
- activity can exit cleanly on its next heartbeat.
647
+ server reports that the task was revoked or its run was cancelled, so the
648
+ activity can exit on its next heartbeat. This permits local activity cleanup,
649
+ not durable workflow compensation after the run closes.
648
650
 
649
651
  Inherits from :class:`BaseException` — not :class:`Exception` — so that a
650
652
  user ``except Exception:`` block inside the activity function cannot
@@ -12,7 +12,12 @@ from pathlib import Path
12
12
  from typing import Any, Protocol
13
13
  from urllib.parse import quote, unquote, urlparse
14
14
 
15
- from .errors import ExternalPayloadIntegrityMismatch, ExternalPayloadUnsupported
15
+ from .errors import (
16
+ ExternalPayloadError,
17
+ ExternalPayloadIntegrityMismatch,
18
+ ExternalPayloadUnavailable,
19
+ ExternalPayloadUnsupported,
20
+ )
16
21
 
17
22
  EXTERNAL_PAYLOAD_REFERENCE_SCHEMA = "durable-workflow.v2.external-payload-reference.v1"
18
23
  RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA = (
@@ -556,7 +561,12 @@ def store_external_payload(
556
561
  if expires_at is not None:
557
562
  _validate_rfc3339(expires_at)
558
563
  sha256 = hashlib.sha256(data).hexdigest()
559
- uri = driver.put(data, sha256=sha256, codec=codec)
564
+ try:
565
+ uri = driver.put(data, sha256=sha256, codec=codec)
566
+ except ExternalPayloadError:
567
+ raise
568
+ except Exception as exc:
569
+ raise ExternalPayloadUnavailable("external payload storage upload failed") from exc
560
570
  return ExternalPayloadReference(
561
571
  uri=uri,
562
572
  sha256=sha256,