durable-workflow 2.10.0__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.0/src/durable_workflow.egg-info → durable_workflow-2.11.0}/PKG-INFO +1 -1
  2. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/pyproject.toml +3 -3
  3. {durable_workflow-2.10.0 → 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.0 → durable_workflow-2.11.0}/src/durable_workflow/_metric_routes.py +1 -0
  6. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/client.py +81 -18
  7. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/errors.py +29 -0
  8. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/worker.py +121 -14
  9. {durable_workflow-2.10.0 → durable_workflow-2.11.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
  10. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/SOURCES.txt +4 -0
  11. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_worker.py +8 -3
  12. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_remote_worker.py +19 -5
  13. durable_workflow-2.11.0/tests/test_failure_reporting.py +325 -0
  14. durable_workflow-2.11.0/tests/test_local_failure_reporting.py +113 -0
  15. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_query_task_authority.py +4 -1
  16. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_regression_corpus.py +17 -0
  17. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_storage_admission.py +17 -3
  18. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker.py +11 -8
  19. durable_workflow-2.11.0/tests/test_worker_deregistration.py +361 -0
  20. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_observation.py +12 -0
  21. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/LICENSE +0 -0
  22. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/README.md +0 -0
  23. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/setup.cfg +0 -0
  24. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_activity_lease.py +0 -0
  25. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_activity_process.py +0 -0
  26. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_avro.py +0 -0
  27. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope.py +0 -0
  28. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
  29. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
  30. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
  31. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_query_task_authority.py +0 -0
  32. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
  33. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/activity.py +0 -0
  34. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/auth_composition.py +0 -0
  35. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/cancellation.py +0 -0
  36. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_storage.py +0 -0
  37. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_task_input.py +0 -0
  38. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/external_task_result.py +0 -0
  39. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/history_bundle_verify.py +0 -0
  40. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/interceptors.py +0 -0
  41. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/invocable.py +0 -0
  42. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/metrics.py +0 -0
  43. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/nexus.py +0 -0
  44. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/py.typed +0 -0
  45. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/python_conformance.py +0 -0
  46. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/replay_conformance.py +0 -0
  47. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/replay_verify.py +0 -0
  48. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/retry_policy.py +0 -0
  49. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/serializer.py +0 -0
  50. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/sync.py +0 -0
  51. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/telemetry.py +0 -0
  52. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/telemetry_carrier.py +0 -0
  53. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/testing.py +0 -0
  54. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/worker_session.py +0 -0
  55. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/workflow.py +0 -0
  56. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
  57. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
  58. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
  59. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/requires.txt +0 -0
  60. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
  61. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_cancellation_policies.py +0 -0
  62. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_context.py +0 -0
  63. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_heartbeat_authority.py +0 -0
  64. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_authority.py +0 -0
  65. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_renewal.py +0 -0
  66. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_lease_worker.py +0 -0
  67. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_activity_process.py +0 -0
  68. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_auth_composition.py +0 -0
  69. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_avro_value_benchmark.py +0 -0
  70. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_avro_value_protocol.py +0 -0
  71. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_context.py +0 -0
  72. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_remaining_time.py +0 -0
  73. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_admission.py +0 -0
  74. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_authoring.py +0 -0
  75. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_boundary.py +0 -0
  76. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cancellation_scope_opening.py +0 -0
  77. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_child_workflow_policies.py +0 -0
  78. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_ci_checkout.py +0 -0
  79. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_ci_integration_endpoint.py +0 -0
  80. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client.py +0 -0
  81. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client_telemetry.py +0 -0
  82. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_client_timeouts.py +0 -0
  83. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_codec_regression_corpus.py +0 -0
  84. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
  85. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
  86. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_history.py +0 -0
  87. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
  88. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_condition_replay_drift.py +0 -0
  89. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_control_plane_parity_fixtures.py +0 -0
  90. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation.py +0 -0
  91. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_client.py +0 -0
  92. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_docstring_cross_references.py +0 -0
  93. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_durable_selection.py +0 -0
  94. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_errors.py +0 -0
  95. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_storage.py +0 -0
  96. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_task_input.py +0 -0
  97. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_external_task_result.py +0 -0
  98. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_golden_history_replay.py +0 -0
  99. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_bundle_schema2.py +0 -0
  100. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_bundle_verify.py +0 -0
  101. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_history_event_contract.py +0 -0
  102. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_invocable.py +0 -0
  103. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_message_streams.py +0 -0
  104. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_metric_routes.py +0 -0
  105. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_metrics.py +0 -0
  106. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_durable_wait.py +0 -0
  107. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_namespace.py +0 -0
  108. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_nexus_wire.py +0 -0
  109. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_order_processing_example.py +0 -0
  110. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_parallel_completion_order_replay.py +0 -0
  111. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_parallel_saga_authoring.py +0 -0
  112. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_patch_history.py +0 -0
  113. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_pending_operation_replay.py +0 -0
  114. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_poll_failure_backoff.py +0 -0
  115. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_client.py +0 -0
  116. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_groups.py +0 -0
  117. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_policies.py +0 -0
  118. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_worker.py +0 -0
  119. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_public_boundary_scanner.py +0 -0
  120. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_pypi_project_surface.py +0 -0
  121. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_python_conformance.py +0 -0
  122. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_queries.py +0 -0
  123. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_query_timeout_retry.py +0 -0
  124. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_readme_quickstart.py +0 -0
  125. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_redrive_replay.py +0 -0
  126. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_registration_ambiguity.py +0 -0
  127. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_release_metadata.py +0 -0
  128. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay.py +0 -0
  129. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_clock.py +0 -0
  130. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_conformance.py +0 -0
  131. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_replay_verify.py +0 -0
  132. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_retry_policy.py +0 -0
  133. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_run_cancellation_scope_replay.py +0 -0
  134. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_runtime_external_payload_transport.py +0 -0
  135. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_schedules.py +0 -0
  136. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_scoped_run_cancellation_context.py +0 -0
  137. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sdk_metrics_contract.py +0 -0
  138. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_serializer.py +0 -0
  139. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_signals.py +0 -0
  140. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sleep.py +0 -0
  141. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_standalone_activity_client.py +0 -0
  142. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sticky_execution.py +0 -0
  143. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sync.py +0 -0
  144. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_sync_activities.py +0 -0
  145. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_telemetry.py +0 -0
  146. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_telemetry_carrier.py +0 -0
  147. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_failures.py +0 -0
  148. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_harness.py +0 -0
  149. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_testing_nexus.py +0 -0
  150. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_threaded_heartbeat_example.py +0 -0
  151. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_tls_trust.py +0 -0
  152. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_transport_diagnostics.py +0 -0
  153. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_update_signal_condition_replay.py +0 -0
  154. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_updates.py +0 -0
  155. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_wait_condition.py +0 -0
  156. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_session.py +0 -0
  157. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_worker_telemetry_wire.py +0 -0
  158. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_entry_arguments.py +0 -0
  159. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_entrypoint_validation.py +0 -0
  160. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_handler_validation.py +0 -0
  161. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_identity.py +0 -0
  162. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_result_timeout.py +0 -0
  163. {durable_workflow-2.10.0 → durable_workflow-2.11.0}/tests/test_workflow_streams.py +0 -0
  164. {durable_workflow-2.10.0 → 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.0
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.0"
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.0"
80
- registry-version = "2.10.0"
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
@@ -12,6 +12,7 @@ _ROUTES = (
12
12
  for suffix in ("", "/pass")),
13
13
  "/workers", "/workers/{worker_id}",
14
14
  "/worker/register", "/worker/heartbeat", "/worker/registrations/{worker_id}",
15
+ "/worker/registrations/{worker_id}/deregister",
15
16
  "/worker/sessions", "/worker/sessions/{session_id}", "/worker/sessions/{session_id}/heartbeat",
16
17
  *(f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")),
17
18
  *(f"/worker/{kind}-tasks/{{task_id}}/{action}" for kind, actions in (
@@ -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,
@@ -1853,6 +1873,7 @@ class Client:
1853
1873
  json: Any = None,
1854
1874
  timeout: float | None = None,
1855
1875
  context: str = "",
1876
+ retry: bool = True,
1856
1877
  ) -> Any:
1857
1878
  if worker and discovery:
1858
1879
  raise ValueError("a request cannot be both worker-plane and cluster discovery")
@@ -1868,22 +1889,22 @@ class Client:
1868
1889
  activity_authority = current_activity_authority.get()
1869
1890
  if worker and activity_authority is not None and activity_authority.governs(self, self.namespace, path):
1870
1891
  activity_authority.remaining()
1871
- request_json = json
1892
+ request_json = prepare_failure_request(path, json)
1872
1893
  if (
1873
1894
  not discovery
1874
1895
  and json is not None
1875
- and _contains_direct_external_storage_envelope(json)
1896
+ and _contains_direct_external_storage_envelope(request_json)
1876
1897
  ):
1877
1898
  await self._require_direct_external_storage_support()
1878
1899
  if (
1879
1900
  not discovery
1880
1901
  and json is not None
1881
- and _contains_inline_payload_envelope(json)
1902
+ and _contains_inline_payload_envelope(request_json)
1882
1903
  ):
1883
1904
  transport = await self._runtime_external_payload_transport()
1884
1905
  if transport is not None:
1885
1906
  request_json = await self._externalize_runtime_payloads(
1886
- json,
1907
+ request_json,
1887
1908
  worker=worker,
1888
1909
  transport=transport,
1889
1910
  uploaded={},
@@ -1893,6 +1914,9 @@ class Client:
1893
1914
  ),
1894
1915
  )
1895
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())
1896
1920
  start = time.perf_counter()
1897
1921
  route = _route_for_metrics(path)
1898
1922
  discovery_uses_worker_token = discovery and bool(self.worker_token)
@@ -1920,7 +1944,7 @@ class Client:
1920
1944
 
1921
1945
  try:
1922
1946
  try:
1923
- resp = await self.retry_policy.execute(_do_request)
1947
+ resp = await self.retry_policy.execute(_do_request) if retry else await _do_request()
1924
1948
  except httpx.HTTPStatusError as exc:
1925
1949
  status_code = str(exc.response.status_code)
1926
1950
  outcome = "http_error"
@@ -5245,39 +5269,69 @@ class Client:
5245
5269
  json={"worker_id": worker_id, "reason": reason},
5246
5270
  ))
5247
5271
 
5248
- async def deregister_worker_registration(self, worker_id: str) -> dict[str, Any]:
5272
+ async def deregister_worker_registration(
5273
+ self, worker_id: str, *, registration_token: str | None = None, timeout: float | None = None,
5274
+ expected_namespace: str | None = None,
5275
+ ) -> dict[str, Any]:
5249
5276
  """Remove this runtime's successful worker-plane registration.
5250
5277
 
5251
5278
  Called by :class:`~durable_workflow.Worker` after its pollers and
5252
5279
  in-flight tasks have drained. This is distinct from the operator-only
5253
5280
  :meth:`deregister_worker` control-plane management operation. Returns
5254
5281
  the worker deregistration envelope, including recovered workflow-task
5255
- count.
5282
+ count. With a registration token, use the runtime's generation-fenced
5283
+ receipt endpoint. Each call makes one attempt: Worker owns safe retries
5284
+ and the remaining shutdown budget. An unfenced DELETE cannot safely be
5285
+ repeated after an uncertain response because the worker ID may be reused.
5286
+ ``expected_namespace`` is a local registration-scope precondition, not
5287
+ a namespace override.
5256
5288
  """
5289
+ if expected_namespace is not None and self.namespace != expected_namespace:
5290
+ raise RuntimeError("worker registration namespace changed; registration removal is unconfirmed")
5291
+ if registration_token is not None and (
5292
+ not isinstance(registration_token, str) or re.fullmatch(r"[a-f0-9]{32}", registration_token) is None
5293
+ ):
5294
+ raise ValueError("registration_token must contain exactly 32 lowercase hexadecimal characters")
5295
+ fenced = registration_token is not None
5257
5296
  try:
5258
5297
  data = await self._request(
5259
- "DELETE",
5260
- f"/worker/registrations/{quote(worker_id, safe='')}",
5298
+ "POST" if fenced else "DELETE",
5299
+ f"/worker/registrations/{quote(worker_id, safe='')}" + ("/deregister" if fenced else ""),
5261
5300
  worker=True,
5262
5301
  context=worker_id,
5302
+ json={"registration_token": registration_token} if fenced else None,
5303
+ timeout=timeout,
5304
+ retry=False,
5263
5305
  )
5264
5306
  except ServerError as error:
5265
- if error.status == 404 and error.reason() == "worker_not_found":
5307
+ if not fenced and error.status == 404 and error.reason() == "worker_not_found":
5266
5308
  return {
5267
5309
  "worker_id": worker_id,
5268
5310
  "outcome": "already_deregistered",
5269
5311
  "recovered_workflow_task_count": 0,
5270
5312
  }
5271
5313
  raise
5272
- if not isinstance(data, dict):
5314
+ if not self._valid_worker_deregistration_response(data, worker_id, registration_token):
5273
5315
  raise ServerError(
5274
5316
  200,
5275
5317
  {
5276
5318
  "reason": "invalid_worker_deregistration_response",
5277
- "message": f"expected JSON object, got {type(data).__name__}",
5319
+ "message": "expected a deregistration receipt for this worker registration",
5278
5320
  },
5279
5321
  )
5280
- return data
5322
+ return cast(dict[str, Any], data)
5323
+
5324
+ @staticmethod
5325
+ def _valid_worker_deregistration_response(data: Any, worker_id: str, token: str | None) -> bool:
5326
+ if not isinstance(data, dict):
5327
+ return False
5328
+ count = data.get("recovered_workflow_task_count")
5329
+ return (
5330
+ data.get("worker_id") == worker_id
5331
+ and data.get("outcome") == "deregistered"
5332
+ and type(count) is int and count >= 0
5333
+ and (token is None or data.get("registration_token") == token)
5334
+ )
5281
5335
 
5282
5336
  async def heartbeat_worker(
5283
5337
  self,
@@ -6177,11 +6231,20 @@ class Client:
6177
6231
  if non_retryable:
6178
6232
  failure["non_retryable"] = True
6179
6233
  if details is not None:
6180
- failure["details"] = self._payload_envelope(
6181
- details,
6182
- codec=codec,
6183
- kind="activity_failure_details",
6184
- 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
6185
6248
  )
6186
6249
  body: dict[str, Any] = {
6187
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
 
@@ -219,6 +233,21 @@ class ServerError(DurableWorkflowError):
219
233
  return self.body.get("reason")
220
234
  return None
221
235
 
236
+ def worker_deregistration_retry_delay(self, worker_id: str, registration_token: str) -> int | None:
237
+ """Return a delay only for an identity-preserving fenced transient."""
238
+ body = self.body
239
+ if (
240
+ self.status != 503 or not isinstance(body, dict)
241
+ or body.get("reason") not in ("backend_lock_pressure", "backend_unavailable")
242
+ or body.get("operation") != "deregister_worker" or body.get("outcome") != "unknown"
243
+ or body.get("worker_id") != worker_id or body.get("registration_token") != registration_token
244
+ or body.get("retryable") is not True
245
+ or type(body.get("retry_after_seconds")) is not int
246
+ or not 0 < body["retry_after_seconds"] <= 60
247
+ ):
248
+ return None
249
+ return int(body["retry_after_seconds"])
250
+
222
251
  def poll_capacity_backpressure_delay(self, task_kind: str, task_queue: str) -> int | None:
223
252
  """Return the advertised wait only for a complete poll-capacity refusal."""
224
253
  body = self.body