durable-workflow 2.10.1__tar.gz → 2.11.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 (164) hide show
  1. {durable_workflow-2.10.1/src/durable_workflow.egg-info → durable_workflow-2.11.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/__init__.py +2 -0
  4. durable_workflow-2.11.0/src/durable_workflow/_failure_reporting.py +299 -0
  5. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/client.py +41 -9
  6. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/errors.py +14 -0
  7. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/worker.py +25 -11
  8. {durable_workflow-2.10.1 → durable_workflow-2.11.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  9. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
  10. durable_workflow-2.11.0/tests/test_failure_reporting.py +325 -0
  11. durable_workflow-2.11.0/tests/test_local_failure_reporting.py +113 -0
  12. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_query_task_authority.py +4 -1
  13. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_regression_corpus.py +15 -0
  14. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/LICENSE +0 -0
  15. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/README.md +0 -0
  16. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/setup.cfg +0 -0
  17. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_activity_lease.py +0 -0
  18. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_activity_process.py +0 -0
  19. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_avro.py +0 -0
  20. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  21. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  22. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  23. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_metric_routes.py +0 -0
  24. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  25. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_query_task_authority.py +0 -0
  26. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  27. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/activity.py +0 -0
  28. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/auth_composition.py +0 -0
  29. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/cancellation.py +0 -0
  30. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_storage.py +0 -0
  31. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_task_input.py +0 -0
  32. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_task_result.py +0 -0
  33. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  34. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/interceptors.py +0 -0
  35. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/invocable.py +0 -0
  36. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/metrics.py +0 -0
  37. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/nexus.py +0 -0
  38. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/py.typed +0 -0
  39. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/python_conformance.py +0 -0
  40. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/replay_conformance.py +0 -0
  41. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/replay_verify.py +0 -0
  42. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/retry_policy.py +0 -0
  43. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/serializer.py +0 -0
  44. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/sync.py +0 -0
  45. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/telemetry.py +0 -0
  46. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/telemetry_carrier.py +0 -0
  47. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/testing.py +0 -0
  48. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/worker_session.py +0 -0
  49. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/workflow.py +0 -0
  50. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  51. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  52. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  53. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  54. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  55. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_cancellation_policies.py +0 -0
  56. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_context.py +0 -0
  57. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_heartbeat_authority.py +0 -0
  58. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_authority.py +0 -0
  59. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_renewal.py +0 -0
  60. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_worker.py +0 -0
  61. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_process.py +0 -0
  62. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_auth_composition.py +0 -0
  63. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_avro_value_benchmark.py +0 -0
  64. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_avro_value_protocol.py +0 -0
  65. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_context.py +0 -0
  66. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_remaining_time.py +0 -0
  67. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_admission.py +0 -0
  68. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_authoring.py +0 -0
  69. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_boundary.py +0 -0
  70. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_opening.py +0 -0
  71. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_child_workflow_policies.py +0 -0
  72. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_ci_checkout.py +0 -0
  73. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_ci_integration_endpoint.py +0 -0
  74. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client.py +0 -0
  75. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client_telemetry.py +0 -0
  76. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client_timeouts.py +0 -0
  77. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_codec_regression_corpus.py +0 -0
  78. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  79. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  80. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  81. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  82. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_condition_replay_drift.py +0 -0
  83. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  84. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation.py +0 -0
  85. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_client.py +0 -0
  86. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_worker.py +0 -0
  87. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_remote_worker.py +0 -0
  88. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_docstring_cross_references.py +0 -0
  89. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_durable_selection.py +0 -0
  90. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_errors.py +0 -0
  91. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_storage.py +0 -0
  92. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_task_input.py +0 -0
  93. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_task_result.py +0 -0
  94. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_golden_history_replay.py +0 -0
  95. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_bundle_schema2.py +0 -0
  96. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_bundle_verify.py +0 -0
  97. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_event_contract.py +0 -0
  98. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_invocable.py +0 -0
  99. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_message_streams.py +0 -0
  100. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_metric_routes.py +0 -0
  101. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_metrics.py +0 -0
  102. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_durable_wait.py +0 -0
  103. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_namespace.py +0 -0
  104. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_wire.py +0 -0
  105. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_order_processing_example.py +0 -0
  106. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_parallel_completion_order_replay.py +0 -0
  107. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_parallel_saga_authoring.py +0 -0
  108. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_patch_history.py +0 -0
  109. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_pending_operation_replay.py +0 -0
  110. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_poll_failure_backoff.py +0 -0
  111. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_client.py +0 -0
  112. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_groups.py +0 -0
  113. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_policies.py +0 -0
  114. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_worker.py +0 -0
  115. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_public_boundary_scanner.py +0 -0
  116. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_pypi_project_surface.py +0 -0
  117. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_python_conformance.py +0 -0
  118. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_queries.py +0 -0
  119. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_query_timeout_retry.py +0 -0
  120. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_readme_quickstart.py +0 -0
  121. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_redrive_replay.py +0 -0
  122. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_registration_ambiguity.py +0 -0
  123. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_release_metadata.py +0 -0
  124. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay.py +0 -0
  125. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_clock.py +0 -0
  126. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_conformance.py +0 -0
  127. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_verify.py +0 -0
  128. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_retry_policy.py +0 -0
  129. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  130. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_runtime_external_payload_transport.py +0 -0
  131. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_schedules.py +0 -0
  132. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  133. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sdk_metrics_contract.py +0 -0
  134. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_serializer.py +0 -0
  135. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_signals.py +0 -0
  136. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sleep.py +0 -0
  137. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_standalone_activity_client.py +0 -0
  138. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sticky_execution.py +0 -0
  139. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_storage_admission.py +0 -0
  140. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sync.py +0 -0
  141. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sync_activities.py +0 -0
  142. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_telemetry.py +0 -0
  143. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_telemetry_carrier.py +0 -0
  144. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_failures.py +0 -0
  145. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_harness.py +0 -0
  146. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_nexus.py +0 -0
  147. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_threaded_heartbeat_example.py +0 -0
  148. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_tls_trust.py +0 -0
  149. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_transport_diagnostics.py +0 -0
  150. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_update_signal_condition_replay.py +0 -0
  151. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_updates.py +0 -0
  152. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_wait_condition.py +0 -0
  153. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker.py +0 -0
  154. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_deregistration.py +0 -0
  155. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_observation.py +0 -0
  156. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_session.py +0 -0
  157. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_telemetry_wire.py +0 -0
  158. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_entry_arguments.py +0 -0
  159. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  160. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_handler_validation.py +0 -0
  161. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_identity.py +0 -0
  162. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_result_timeout.py +0 -0
  163. {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_streams.py +0 -0
  164. {durable_workflow-2.10.1 → durable_workflow-2.11.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.10.1
3
+ Version: 2.11.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.10.1"
7
+ version = "2.11.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"
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
76
76
  durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
77
77
 
78
78
  [tool.durable-workflow]
79
- product-train = "2.10.1"
80
- registry-version = "2.10.1"
79
+ product-train = "2.11.0"
80
+ registry-version = "2.11.0"
81
81
  supported-server-versions = "2.8.0"
82
82
  worker-protocol-version = "1.20"
83
83
  control-plane-version = "2"
@@ -81,6 +81,7 @@ from .errors import (
81
81
  ExternalPayloadUnauthorized,
82
82
  ExternalPayloadUnavailable,
83
83
  ExternalPayloadUnsupported,
84
+ FailureReportTooLarge,
84
85
  InvalidArgument,
85
86
  InvalidWorkflowDefinition,
86
87
  NamespaceNotFound,
@@ -334,6 +335,7 @@ __all__ = [
334
335
  "testing",
335
336
  "workflow",
336
337
  "DurableWorkflowError",
338
+ "FailureReportTooLarge",
337
339
  "EXTERNAL_PAYLOAD_REFERENCE_SCHEMA",
338
340
  "RUNTIME_EXTERNAL_PAYLOAD_REFERENCE_SCHEMA",
339
341
  "AzureBlobExternalStorage",
@@ -0,0 +1,299 @@
1
+ """Bound diagnostic text using the same JSON encoder as the HTTP transport.
2
+
3
+ Only failure diagnostics may be reduced. Claim identity, classification and
4
+ retry policy remain exact, even when that makes the complete report unfit.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .errors import FailureReportTooLarge
14
+
15
+ MESSAGE_BYTES = 62 * 1024
16
+ STACK_BYTES = 128 * 1024
17
+ DETAIL_BYTES = 64 * 1024
18
+ REQUEST_BYTES = 1024 * 1024
19
+ UNENCODABLE_DETAILS = object()
20
+ _STACK_MARKER = "\n[truncated]"
21
+ _OPTIONAL = ("details", "diagnostics", "runtime_diagnostics", "validation_errors")
22
+ _EXCEPTION_IDENTITY = frozenset({
23
+ "type", "class", "code", "reason", "non_retryable", "retryable", "retry_after_seconds",
24
+ "next_retry_delay", "activity_type", "activity_attempt_id", "activity_execution_id",
25
+ "failed_step_sequence", "failed_activity_execution_id",
26
+ })
27
+
28
+
29
+ def encoded_json(value: Any) -> bytes:
30
+ """Use HTTPX's actual JSON settings, including escaping and NaN refusal."""
31
+ return httpx.Request("POST", "https://failure.invalid", json=value).content
32
+
33
+
34
+ def _size(value: Any) -> int | None:
35
+ try:
36
+ return len(encoded_json(value))
37
+ except (TypeError, ValueError, RecursionError):
38
+ return None
39
+
40
+
41
+ def _utf8(value: str) -> bytes:
42
+ return value.encode("utf-8", errors="replace")
43
+
44
+
45
+ def _prefix(value: str, size: int) -> str:
46
+ return _utf8(value)[:max(0, size)].decode("utf-8", errors="ignore")
47
+
48
+
49
+ def _marker(omitted: dict[str, int | None]) -> str:
50
+ return "\n[truncated " + encoded_json({"omitted_fields": omitted}).decode("utf-8") + "]"
51
+
52
+
53
+ class _ObservedLocalFailure(str):
54
+ """A local message already delivered to workflow code.
55
+
56
+ JSON serializes this as ordinary text. Keep the in-memory distinction until
57
+ encoding so final batch fitting cannot change first-run/replay behavior.
58
+ """
59
+
60
+
61
+ @dataclass
62
+ class _Report:
63
+ original: dict[str, Any]
64
+ terminal: bool = False
65
+ structured: bool = False
66
+
67
+ def _copy(self) -> dict[str, Any]:
68
+ result = dict(self.original)
69
+ if self.terminal and isinstance(result.get("exception"), dict):
70
+ result["exception"] = dict(result["exception"])
71
+ return result
72
+
73
+ def prepare(self) -> dict[str, Any]:
74
+ result = self._copy()
75
+ omitted: dict[str, int | None] = {}
76
+ self._bound_metadata(result, omitted, DETAIL_BYTES)
77
+ if omitted:
78
+ self._attach_diagnostics(result, omitted)
79
+ return result
80
+
81
+ def render(self, budget: int) -> dict[str, Any]:
82
+ result = self._copy()
83
+ if isinstance(result.get("message"), _ObservedLocalFailure):
84
+ return result
85
+ previous: dict[str, int | None] = {}
86
+ if self.structured:
87
+ self._previous_sizes(previous)
88
+ omitted = dict(previous)
89
+ message = result.get("message", "")
90
+ message_limit = min(MESSAGE_BYTES, budget)
91
+ if len(_utf8(message)) > message_limit or _prefix(message, len(_utf8(message))) != message:
92
+ omitted.setdefault("message", len(_utf8(message)))
93
+ self._bound_stack(result, omitted, min(STACK_BYTES, budget))
94
+ self._bound_metadata(result, omitted, min(DETAIL_BYTES, budget))
95
+ if not omitted:
96
+ return result
97
+ if self._unchanged(previous, omitted, budget):
98
+ return result
99
+ # Inserting the marker can itself shorten an otherwise small message,
100
+ # and inserting diagnostics can require omitting another optional field.
101
+ # Recompute until the finite set of omitted fields is stable.
102
+ while True:
103
+ before = dict(omitted)
104
+ self._attach_diagnostics(result, omitted)
105
+ marker = _marker(omitted)
106
+ if len(_utf8(message)) + len(_utf8(marker)) > message_limit:
107
+ omitted.setdefault("message", len(_utf8(message)))
108
+ if omitted == before:
109
+ break
110
+ retained = max(0, message_limit - len(_utf8(marker)))
111
+ result["message"] = _prefix(message, retained) + marker
112
+ return result
113
+
114
+ @staticmethod
115
+ def _bound_stack(result: dict[str, Any], omitted: dict[str, int | None], stack_limit: int) -> None:
116
+ stack = result.get("stack_trace")
117
+ if isinstance(stack, str) and len(_utf8(stack)) > stack_limit:
118
+ omitted.setdefault("stack_trace", len(_utf8(stack)))
119
+ if stack_limit >= len(_STACK_MARKER):
120
+ result["stack_trace"] = _prefix(stack, stack_limit - len(_STACK_MARKER)) + _STACK_MARKER
121
+ else:
122
+ result.pop("stack_trace", None)
123
+
124
+ def _unchanged(
125
+ self, previous: dict[str, int | None], omitted: dict[str, int | None], budget: int,
126
+ ) -> bool:
127
+ message = self.original.get("message", "")
128
+ stack = self.original.get("stack_trace")
129
+ return bool(
130
+ previous and previous == omitted and message.endswith(_marker(previous))
131
+ and len(_utf8(message)) <= min(MESSAGE_BYTES, budget)
132
+ and (not isinstance(stack, str) or len(_utf8(stack)) <= min(STACK_BYTES, budget))
133
+ )
134
+
135
+ def _bound_metadata(self, result: dict[str, Any], omitted: dict[str, int | None], budget: int) -> None:
136
+ fields = ("exception",) if self.terminal else _OPTIONAL
137
+ values = {key: result[key] for key in fields if key in result}
138
+ size = _size(values)
139
+ if size is not None and size <= budget:
140
+ return
141
+ for key, value in values.items():
142
+ omitted.setdefault(key, _size(value))
143
+ if key == "exception" and isinstance(value, dict):
144
+ result[key] = {k: v for k, v in value.items() if k in _EXCEPTION_IDENTITY}
145
+ else:
146
+ result.pop(key, None)
147
+
148
+ def _previous_sizes(self, omitted: dict[str, int | None]) -> None:
149
+ original = self.original.get("exception", {}) if self.terminal else self.original
150
+ diagnostics = original.get("diagnostics") if isinstance(original, dict) else None
151
+ previous = diagnostics.get("failure_report") if isinstance(diagnostics, dict) else None
152
+ sizes = previous.get("omitted_fields") if isinstance(previous, dict) else None
153
+ if not isinstance(sizes, dict):
154
+ return
155
+ for key, size in sizes.items():
156
+ if (key in {*_OPTIONAL, "exception", "message", "stack_trace"}
157
+ and (size is None or type(size) is int and size >= 0)):
158
+ omitted[key] = size
159
+
160
+ def _attach_diagnostics(self, result: dict[str, Any], omitted: dict[str, int | None]) -> None:
161
+ if not self.structured:
162
+ return
163
+ container = result.setdefault("exception", {}) if self.terminal else result
164
+ self._previous_sizes(omitted)
165
+ original = self.original.get("exception", {}) if self.terminal else self.original
166
+ existing = original.get("diagnostics") if isinstance(original, dict) else None
167
+ diagnostics = dict(existing) if isinstance(existing, dict) and "diagnostics" not in omitted else {}
168
+ diagnostics["failure_report"] = {"omitted_fields": omitted}
169
+ container["diagnostics"] = diagnostics
170
+ optional = {key: container[key] for key in _OPTIONAL if key in container}
171
+ size = _size(container if self.terminal else optional)
172
+ if size is not None and size <= DETAIL_BYTES:
173
+ return
174
+ self._omit_optional_metadata(container, omitted, existing)
175
+
176
+ def _omit_optional_metadata(
177
+ self, container: dict[str, Any], omitted: dict[str, int | None], existing: Any,
178
+ ) -> None:
179
+ # Count inserted diagnostics too. Omit optional fields whole so an
180
+ # Avro envelope is either valid and intact or explicitly absent.
181
+ if existing is not None:
182
+ omitted.setdefault("diagnostics", _size(existing))
183
+ # Snapshot the keys because the loop removes entries from this dictionary.
184
+ for key in list(container):
185
+ removable = key not in _EXCEPTION_IDENTITY if self.terminal else key in _OPTIONAL
186
+ if removable and key != "diagnostics":
187
+ omitted.setdefault(key if key in _OPTIONAL else "exception", _size(container.pop(key)))
188
+ container["diagnostics"] = {"failure_report": {"omitted_fields": omitted}}
189
+
190
+
191
+ def _command_reports(command: dict[str, Any]) -> list[tuple[tuple[str | int, ...], _Report]]:
192
+ kind = command.get("type")
193
+ terminal = kind == "fail_workflow"
194
+ result: list[tuple[tuple[str | int, ...], _Report]] = []
195
+ if kind in {"fail_workflow", "fail_update"} or kind == "record_local_activity" and "message" in command:
196
+ result.append(((), _Report(command, terminal=terminal, structured=terminal)))
197
+ if kind == "record_local_activity":
198
+ for index, attempt in enumerate(command.get("attempts", [])):
199
+ if isinstance(attempt, dict) and "message" in attempt:
200
+ result.append((("attempts", index), _Report(attempt)))
201
+ return result
202
+
203
+
204
+ def _reports(path: str, body: dict[str, Any]) -> list[tuple[tuple[str | int, ...], _Report]]:
205
+ failure = body.get("failure")
206
+ if isinstance(failure, dict) and (path.endswith(("/fail", "/reject"))):
207
+ return [(("failure",), _Report(failure, structured="/activity-tasks/" in path))]
208
+ if "/local-activities/" in path and path.endswith("/outcome"):
209
+ report = body.get("report")
210
+ if isinstance(report, dict) and "message" in report:
211
+ return [(("report",), _Report(report))]
212
+ commands = body.get("commands")
213
+ if not isinstance(commands, list) or not path.endswith("/complete"):
214
+ return []
215
+ return [
216
+ (("commands", index, *location), report)
217
+ for index, command in enumerate(commands) if isinstance(command, dict)
218
+ for location, report in _command_reports(command)
219
+ ]
220
+
221
+
222
+ def _replace_report(body: Any, location: tuple[str | int, ...], report: dict[str, Any]) -> Any:
223
+ if not location:
224
+ return report
225
+ result: Any = dict(body) if isinstance(body, dict) else list(body)
226
+ key = location[0]
227
+ result[key] = _replace_report(body[key], location[1:], report)
228
+ return result
229
+
230
+
231
+ def prepare_failure_request(path: str, body: Any) -> Any:
232
+ """Omit oversized diagnostic envelopes before optional payload uploads.
233
+
234
+ Keep original message/stack text until the final fit so its original sizes
235
+ survive normal externalization of unrelated business payloads in a batch.
236
+ """
237
+ if not path.startswith("/worker/") or not isinstance(body, dict):
238
+ return body
239
+ result = body
240
+ for location, report in _reports(path, body):
241
+ if report.structured:
242
+ result = _replace_report(result, location, report.prepare())
243
+ return result
244
+
245
+
246
+ def _render_reports(
247
+ body: dict[str, Any], reports: list[tuple[tuple[str | int, ...], _Report]], budget: int, limit: int,
248
+ ) -> dict[str, Any]:
249
+ result = body
250
+ for location, report in reports:
251
+ rendered = report.render(budget)
252
+ metadata = rendered.get("exception", {}) if report.terminal else {
253
+ key: rendered[key] for key in _OPTIONAL if key in rendered
254
+ }
255
+ metadata_size = _size(metadata)
256
+ if metadata_size is None or metadata_size > DETAIL_BYTES:
257
+ raise FailureReportTooLarge(limit)
258
+ result = _replace_report(result, location, rendered)
259
+ return result
260
+
261
+
262
+ def bound_failure_request(path: str, body: Any, limit: int = REQUEST_BYTES) -> Any:
263
+ if not path.startswith("/worker/") or not isinstance(body, dict):
264
+ return body
265
+ reports = _reports(path, body)
266
+ if not reports:
267
+ return body
268
+ limit = min(limit, REQUEST_BYTES)
269
+ budget = STACK_BYTES
270
+ while True:
271
+ result = _render_reports(body, reports, budget, limit)
272
+ size = _size(result)
273
+ if size is not None and size <= limit:
274
+ return result
275
+ if budget == 0:
276
+ raise FailureReportTooLarge(limit)
277
+ budget //= 2
278
+
279
+
280
+ def normalize_local_failure(
281
+ command: dict[str, Any], *, lease_owner: str, workflow_task_attempt: int, limit: int = REQUEST_BYTES,
282
+ ) -> dict[str, Any]:
283
+ """Fit local diagnostics before the workflow sees its durable failure.
284
+
285
+ Business arguments can still be externalized by Client. Include all claim,
286
+ retry and attempt data here; the final complete request is measured again.
287
+ An already observed message is then immutable, including on a lower limit
288
+ discovered later or when another command makes the batch unfit.
289
+ """
290
+ frame = {key: value for key, value in command.items() if key != "arguments"}
291
+ body = {"lease_owner": lease_owner, "workflow_task_attempt": workflow_task_attempt, "commands": [frame]}
292
+ normalized = bound_failure_request("/worker/workflow-tasks/local/complete", body, limit)["commands"][0]
293
+ normalized = {**command, **normalized}
294
+ if isinstance(normalized.get("message"), str):
295
+ normalized["message"] = _ObservedLocalFailure(normalized["message"])
296
+ for attempt in normalized.get("attempts", []):
297
+ if isinstance(attempt, dict) and isinstance(attempt.get("message"), str):
298
+ attempt["message"] = _ObservedLocalFailure(attempt["message"])
299
+ return normalized
@@ -48,6 +48,14 @@ from ._cancellation_scope_history import (
48
48
  scope_timestamp,
49
49
  )
50
50
  from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
51
+ from ._failure_reporting import (
52
+ DETAIL_BYTES,
53
+ REQUEST_BYTES,
54
+ UNENCODABLE_DETAILS,
55
+ bound_failure_request,
56
+ encoded_json,
57
+ prepare_failure_request,
58
+ )
51
59
  from ._metric_routes import route_for_metrics as _route_for_metrics
52
60
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
53
61
  from .errors import (
@@ -1622,6 +1630,7 @@ class Client:
1622
1630
  payload_size_limit_bytes: int = serializer.DEFAULT_PAYLOAD_SIZE_BYTES,
1623
1631
  payload_size_warning_threshold_percent: int = serializer.DEFAULT_WARNING_THRESHOLD_PERCENT,
1624
1632
  payload_size_warnings: bool = True,
1633
+ failure_report_limit_bytes: int = REQUEST_BYTES,
1625
1634
  external_storage: ExternalStorageDriver | None = None,
1626
1635
  external_storage_threshold_bytes: int | None = None,
1627
1636
  external_storage_cache: ExternalPayloadCache | None = None,
@@ -1643,6 +1652,9 @@ class Client:
1643
1652
  self._tls_context = tls_context
1644
1653
  self.retry_policy = retry_policy or TransportRetryPolicy()
1645
1654
  self.metrics = metrics if metrics is not None else NOOP_METRICS
1655
+ if type(failure_report_limit_bytes) is not int or failure_report_limit_bytes < 1:
1656
+ raise ValueError("failure_report_limit_bytes must be a positive integer")
1657
+ self.failure_report_limit_bytes = min(REQUEST_BYTES, failure_report_limit_bytes)
1646
1658
  self.payload_size_warning_config = (
1647
1659
  serializer.PayloadSizeWarningConfig(
1648
1660
  limit_bytes=payload_size_limit_bytes,
@@ -1843,6 +1855,14 @@ class Client:
1843
1855
  ),
1844
1856
  )
1845
1857
 
1858
+ def _failure_report_limit(self) -> int:
1859
+ limit = self.failure_report_limit_bytes
1860
+ limits = (self._cluster_info or {}).get("limits")
1861
+ advertised = limits.get("max_payload_bytes") if isinstance(limits, dict) else None
1862
+ if type(advertised) is int and advertised > 0:
1863
+ limit = min(limit, advertised)
1864
+ return limit
1865
+
1846
1866
  async def _request(
1847
1867
  self,
1848
1868
  method: str,
@@ -1869,22 +1889,22 @@ class Client:
1869
1889
  activity_authority = current_activity_authority.get()
1870
1890
  if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1871
1891
  activity_authority.remaining()
1872
- request_json = json
1892
+ request_json = prepare_failure_request(path, json)
1873
1893
  if (
1874
1894
  not discovery
1875
1895
  and json is not None
1876
- and _contains_direct_external_storage_envelope(json)
1896
+ and _contains_direct_external_storage_envelope(request_json)
1877
1897
  ):
1878
1898
  await self._require_direct_external_storage_support()
1879
1899
  if (
1880
1900
  not discovery
1881
1901
  and json is not None
1882
- and _contains_inline_payload_envelope(json)
1902
+ and _contains_inline_payload_envelope(request_json)
1883
1903
  ):
1884
1904
  transport = await self._runtime_external_payload_transport()
1885
1905
  if transport is not None:
1886
1906
  request_json = await self._externalize_runtime_payloads(
1887
- json,
1907
+ request_json,
1888
1908
  worker=worker,
1889
1909
  transport=transport,
1890
1910
  uploaded={},
@@ -1894,6 +1914,9 @@ class Client:
1894
1914
  ),
1895
1915
  )
1896
1916
 
1917
+ # Discovery/external payload references may change the known limit or
1918
+ # final envelope. Measure again after those transformations.
1919
+ request_json = bound_failure_request(path, request_json, self._failure_report_limit())
1897
1920
  start = time.perf_counter()
1898
1921
  route = _route_for_metrics(path)
1899
1922
  discovery_uses_worker_token = discovery and bool(self.worker_token)
@@ -6208,11 +6231,20 @@ class Client:
6208
6231
  if non_retryable:
6209
6232
  failure["non_retryable"] = True
6210
6233
  if details is not None:
6211
- failure["details"] = self._payload_envelope(
6212
- details,
6213
- codec=codec,
6214
- kind="activity_failure_details",
6215
- activity_name=activity_name,
6234
+ # Inspect the official inline encoding before an optional storage
6235
+ # driver uploads diagnostics that the failure budget will omit.
6236
+ try:
6237
+ inline_details: Any = serializer.envelope(details, codec=codec, size_warning=None)
6238
+ except (TypeError, ValueError, RecursionError):
6239
+ if codec != serializer.AVRO_CODEC:
6240
+ raise
6241
+ inline_details = UNENCODABLE_DETAILS
6242
+ failure["details"] = (
6243
+ self._payload_envelope(
6244
+ details, codec=codec, kind="activity_failure_details", activity_name=activity_name,
6245
+ )
6246
+ if isinstance(inline_details, dict) and len(encoded_json(inline_details)) <= DETAIL_BYTES
6247
+ else inline_details
6216
6248
  )
6217
6249
  body: dict[str, Any] = {
6218
6250
  "activity_attempt_id": activity_attempt_id,
@@ -27,6 +27,20 @@ class DurableWorkflowError(Exception):
27
27
  """Base class for every exception raised by the SDK."""
28
28
 
29
29
 
30
+ class FailureReportTooLarge(DurableWorkflowError, ValueError):
31
+ """Required failure identity or semantics cannot fit the reporting budget.
32
+
33
+ This is a local reporting refusal, not the application's failure. No claim
34
+ identity, failure type or retry decision is shortened to make it fit.
35
+ """
36
+
37
+ def __init__(self, limit_bytes: int) -> None:
38
+ self.limit_bytes = limit_bytes
39
+ super().__init__(
40
+ f"failure report identity or semantics cannot fit the {limit_bytes}-byte encoded request budget"
41
+ )
42
+
43
+
30
44
  class QueryTaskAuthorityLost(DurableWorkflowError):
31
45
  """A query delivery cannot safely load or invoke its handler."""
32
46
 
@@ -44,6 +44,7 @@ from ._activity_lease import activity_lease_renewal_supported, current_activity_
44
44
  from ._activity_process import CallbackFailure, CallbackInvocation, CallbackProcessLost, SupervisedCallback
45
45
  from ._cancellation_scope_history import CancellationScopeBudget, CancellationScopeDeliveryReceipt
46
46
  from ._cooperative_cancellation import CancellationRequest, read_cancellation_history
47
+ from ._failure_reporting import REQUEST_BYTES, normalize_local_failure
47
48
  from ._prepared_local_activity import PreparedAttempt, PreparedCancellationObserved, PreparedLocalRunner
48
49
  from ._query_task_authority import QueryTaskAuthority, current_query_authority
49
50
  from ._sticky_workflow_cache import CacheKey, StickyWorkflowCache, complete_history
@@ -76,6 +77,7 @@ from .errors import (
76
77
  DuplicateRegistrationError,
77
78
  DurableWorkflowError,
78
79
  ExternalPayloadError,
80
+ FailureReportTooLarge,
79
81
  InvalidArgument,
80
82
  NonDeterministicReplayError,
81
83
  NonRetryableError,
@@ -2328,8 +2330,22 @@ class Worker:
2328
2330
  }
2329
2331
  if timeout_kind is not None:
2330
2332
  command.outcome["timeout_kind"] = timeout_kind
2333
+ known_limit = getattr(self.client, "_failure_report_limit", None)
2334
+ limit = known_limit() if callable(known_limit) else REQUEST_BYTES
2335
+ if type(limit) is not int:
2336
+ limit = REQUEST_BYTES
2337
+ try:
2338
+ normalized = normalize_local_failure(
2339
+ command.to_server_command(self.task_queue, payload_codec=payload_codec),
2340
+ lease_owner=self.worker_id, workflow_task_attempt=task["workflow_task_attempt"], limit=limit,
2341
+ )
2342
+ except FailureReportTooLarge as error:
2343
+ raise LocalActivityExecutionAborted(
2344
+ "local failure report cannot fit before workflow observation",
2345
+ ) from error
2346
+ command.outcome = {key: normalized[key] for key in command.outcome}
2331
2347
  return ActivityFailed(
2332
- message,
2348
+ command.outcome["message"],
2333
2349
  activity_type=command.activity_type,
2334
2350
  exception_type=exception_type,
2335
2351
  non_retryable=non_retryable,
@@ -2469,18 +2485,15 @@ class Worker:
2469
2485
  cancelled = isinstance(exc, ActivityCancelled)
2470
2486
  exception_type = type(exc).__name__
2471
2487
  message = str(exc).strip() or f"local activity failed with {exception_type}"
2472
- invalid_report = len(exception_type.encode("utf-8")) > 255
2473
2488
  try:
2474
- json.dumps({"message": message, "exception_type": exception_type}, allow_nan=False).encode("utf-8")
2475
- except (TypeError, ValueError, UnicodeError):
2476
- invalid_report = True
2477
- if invalid_report:
2478
- exception_type = "InvalidLocalActivityReport"
2479
- message = "local activity failure metadata cannot cross the HTTP JSON boundary"
2480
- timed_out = False
2481
- cancelled = False
2489
+ if len(exception_type.encode("utf-8")) > 255:
2490
+ raise ValueError("local activity failure type exceeds the wire limit")
2491
+ except ValueError as error:
2492
+ raise LocalActivityExecutionAborted(
2493
+ "local activity failure type cannot cross the HTTP boundary",
2494
+ ) from error
2482
2495
  non_retryable = (
2483
- cancelled or invalid_report or isinstance(exc, NonRetryableError)
2496
+ cancelled or isinstance(exc, NonRetryableError)
2484
2497
  or exception_type in non_retryable_types
2485
2498
  )
2486
2499
  retry = not non_retryable and attempt_number < max_attempts
@@ -4159,6 +4172,7 @@ class Worker:
4159
4172
  else serializer.DEFAULT_WARNING_THRESHOLD_PERCENT
4160
4173
  ),
4161
4174
  payload_size_warnings=warning_config is not None,
4175
+ failure_report_limit_bytes=self.client.failure_report_limit_bytes,
4162
4176
  external_storage=self.external_storage,
4163
4177
  external_storage_threshold_bytes=self.external_storage_threshold_bytes,
4164
4178
  external_storage_cache=self.external_storage_cache,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durable-workflow
3
- Version: 2.10.1
3
+ Version: 2.11.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
@@ -8,6 +8,7 @@ src/durable_workflow/_avro.py
8
8
  src/durable_workflow/_cancellation_scope.py
9
9
  src/durable_workflow/_cancellation_scope_history.py
10
10
  src/durable_workflow/_cooperative_cancellation.py
11
+ src/durable_workflow/_failure_reporting.py
11
12
  src/durable_workflow/_metric_routes.py
12
13
  src/durable_workflow/_prepared_local_activity.py
13
14
  src/durable_workflow/_query_task_authority.py
@@ -84,11 +85,13 @@ tests/test_errors.py
84
85
  tests/test_external_storage.py
85
86
  tests/test_external_task_input.py
86
87
  tests/test_external_task_result.py
88
+ tests/test_failure_reporting.py
87
89
  tests/test_golden_history_replay.py
88
90
  tests/test_history_bundle_schema2.py
89
91
  tests/test_history_bundle_verify.py
90
92
  tests/test_history_event_contract.py
91
93
  tests/test_invocable.py
94
+ tests/test_local_failure_reporting.py
92
95
  tests/test_message_streams.py
93
96
  tests/test_metric_routes.py
94
97
  tests/test_metrics.py