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.
- {durable_workflow-2.10.1/src/durable_workflow.egg-info → durable_workflow-2.11.0}/PKG-INFO +1 -1
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/pyproject.toml +3 -3
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/__init__.py +2 -0
- durable_workflow-2.11.0/src/durable_workflow/_failure_reporting.py +299 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/client.py +41 -9
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/errors.py +14 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/worker.py +25 -11
- {durable_workflow-2.10.1 → durable_workflow-2.11.0/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/SOURCES.txt +3 -0
- durable_workflow-2.11.0/tests/test_failure_reporting.py +325 -0
- durable_workflow-2.11.0/tests/test_local_failure_reporting.py +113 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_query_task_authority.py +4 -1
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_regression_corpus.py +15 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/LICENSE +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/README.md +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/setup.cfg +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_metric_routes.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/history_bundle_verify.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/sync.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/testing.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_cancellation_worker.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_cooperative_remote_worker.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_errors.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_bundle_schema2.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_invocable.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_metric_routes.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_metrics.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_poll_failure_backoff.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_queries.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_schedules.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sdk_metrics_contract.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_serializer.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_signals.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sleep.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_storage_admission.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sync.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_failures.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_testing_nexus.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_tls_trust.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_transport_diagnostics.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_updates.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_deregistration.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_observation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.10.1 → durable_workflow-2.11.0}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.
|
|
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.
|
|
80
|
-
registry-version = "2.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
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
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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
|
|
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,
|
{durable_workflow-2.10.1 → durable_workflow-2.11.0}/src/durable_workflow.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|