aws-durable-execution-sdk-python 2.0.1__py3-none-any.whl → 2.1.0__py3-none-any.whl

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.
@@ -75,29 +75,6 @@ class OperationType(Enum):
75
75
  CALLBACK = "CALLBACK"
76
76
  CHAINED_INVOKE = "CHAINED_INVOKE"
77
77
 
78
- @classmethod
79
- def from_sub_type(cls, sub_type: OperationSubType) -> OperationType:
80
- match sub_type:
81
- case OperationSubType.STEP | OperationSubType.WAIT_FOR_CONDITION:
82
- return OperationType.STEP
83
- case OperationSubType.WAIT:
84
- return OperationType.WAIT
85
- case OperationSubType.CHAINED_INVOKE:
86
- return OperationType.CHAINED_INVOKE
87
- case OperationSubType.CALLBACK:
88
- return OperationType.CALLBACK
89
- case (
90
- OperationSubType.WAIT_FOR_CALLBACK
91
- | OperationSubType.RUN_IN_CHILD_CONTEXT
92
- | OperationSubType.MAP
93
- | OperationSubType.MAP_ITERATION
94
- | OperationSubType.PARALLEL
95
- | OperationSubType.PARALLEL_BRANCH
96
- ):
97
- return OperationType.CONTEXT
98
- case _:
99
- raise ValueError(f"Unknown operation sub-type {sub_type}")
100
-
101
78
 
102
79
  class CallbackTimeoutType(Enum):
103
80
  TIMEOUT = "Callback.Timeout"
@@ -181,6 +158,11 @@ class DurableExecutionInvocationOutput:
181
158
  """Create a failed invocation output."""
182
159
  return cls(status=InvocationStatus.RETRY, error=error)
183
160
 
161
+ @classmethod
162
+ def create_pending(cls) -> DurableExecutionInvocationOutput:
163
+ """Create a pending invocation output without a result or error."""
164
+ return cls(status=InvocationStatus.PENDING)
165
+
184
166
 
185
167
  @dataclass(frozen=True)
186
168
  class ExecutionDetails:
@@ -453,19 +435,18 @@ class CallbackOptions:
453
435
 
454
436
  @dataclass(frozen=True)
455
437
  class ChainedInvokeOptions:
456
- """
457
- As of 2025/10/27:
458
- - Chained invoke options only contains a function name
459
- """
438
+ """Target and optional per-operation trace context for a chained invocation."""
460
439
 
461
440
  function_name: str
462
441
  tenant_id: str | None = None
442
+ x_amzn_trace_id: str | None = None
463
443
 
464
444
  @classmethod
465
445
  def from_dict(cls, data: MutableMapping[str, Any]) -> ChainedInvokeOptions:
466
446
  return cls(
467
447
  function_name=data["FunctionName"],
468
448
  tenant_id=data.get("TenantId"),
449
+ x_amzn_trace_id=data.get("XAmznTraceId"),
469
450
  )
470
451
 
471
452
  def to_dict(self) -> MutableMapping[str, Any]:
@@ -474,6 +455,8 @@ class ChainedInvokeOptions:
474
455
  }
475
456
  if self.tenant_id is not None:
476
457
  result["TenantId"] = self.tenant_id
458
+ if self.x_amzn_trace_id is not None:
459
+ result["XAmznTraceId"] = self.x_amzn_trace_id
477
460
 
478
461
  return result
479
462
 
@@ -10,11 +10,13 @@ from aws_durable_execution_sdk_python.exceptions import (
10
10
  ChildContextError,
11
11
  ExecutionError,
12
12
  InvocationError,
13
+ NonDeterministicExecutionError,
13
14
  SuspendExecution,
14
15
  )
15
16
  from aws_durable_execution_sdk_python.lambda_service import (
16
17
  ContextOptions,
17
18
  ErrorObject,
19
+ Operation,
18
20
  OperationStatus,
19
21
  OperationSubType,
20
22
  OperationUpdate,
@@ -71,6 +73,28 @@ class ChildOperationExecutor(OperationExecutor[T]):
71
73
  self.is_virtual: bool = config.is_virtual
72
74
  self.sub_type = config.sub_type or OperationSubType.RUN_IN_CHILD_CONTEXT
73
75
 
76
+ @staticmethod
77
+ def validate_virtual_checkpoint(
78
+ operation_identifier: OperationIdentifier,
79
+ checkpoint: Operation | None,
80
+ ) -> None:
81
+ """Reject a checkpoint at the operation id of a virtual child context.
82
+
83
+ A virtual child never checkpoints itself, so a checkpoint at its id was
84
+ written by a non-virtual child in an earlier invocation. Consuming it
85
+ would skip the virtual body and leave its inner operations unvalidated.
86
+ """
87
+ if not isinstance(checkpoint, Operation):
88
+ return
89
+ msg = (
90
+ "Non-deterministic child context at "
91
+ f"id={operation_identifier.operation_id!r}: checkpoint contains a "
92
+ "non-virtual child context but current child context is virtual"
93
+ )
94
+ raise NonDeterministicExecutionError(
95
+ msg, step_id=operation_identifier.operation_id
96
+ )
97
+
74
98
  def check_result_status(self) -> CheckResult[T]:
75
99
  """Check operation status and create START checkpoint if needed.
76
100
 
@@ -82,8 +106,14 @@ class ChildOperationExecutor(OperationExecutor[T]):
82
106
 
83
107
  Raises:
84
108
  ChildContextError: For FAILED operations
109
+ NonDeterministicExecutionError: For a virtual child whose operation
110
+ id already has a checkpoint
85
111
  """
86
112
  checkpointed_result = self._get_checkpoint_result()
113
+ if self.is_virtual:
114
+ self.validate_virtual_checkpoint(
115
+ self.operation_identifier, checkpointed_result.operation
116
+ )
87
117
 
88
118
  # Terminal success without replay_children - deserialize and return
89
119
  if (
@@ -123,12 +123,18 @@ class InvokeOperationExecutor(OperationExecutor[R]):
123
123
  operation_id=self.operation_identifier.operation_id,
124
124
  durable_execution_arn=self.state.durable_execution_arn,
125
125
  )
126
+ # Only a new START contributes outbound context. Replayed pending or
127
+ # terminal invokes keep their checkpointed identity and outcome.
128
+ propagation = self.state.provide_propagation_metadata(
129
+ self.operation_identifier, self.function_name
130
+ )
126
131
  start_operation: OperationUpdate = OperationUpdate.create_invoke_start(
127
132
  identifier=self.operation_identifier,
128
133
  payload=serialized_payload,
129
134
  chained_invoke_options=ChainedInvokeOptions(
130
135
  function_name=self.function_name,
131
136
  tenant_id=self.config.tenant_id,
137
+ x_amzn_trace_id=propagation.x_amzn_trace_id,
132
138
  ),
133
139
  )
134
140
  # Checkpoint invoke START with blocking (is_sync=True).
@@ -1,15 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ import contextvars
4
5
  import copy
5
6
  import datetime
6
- import functools
7
+ import inspect
7
8
  import logging
8
9
  from collections.abc import Mapping, Sequence
9
10
  from concurrent.futures import ThreadPoolExecutor
10
11
  from dataclasses import dataclass, field
11
12
  from enum import Enum
12
- from typing import Any, Callable, MutableMapping, cast
13
+ from typing import Any, Callable, cast
13
14
 
14
15
  from aws_durable_execution_sdk_python.identifier import OperationIdentifier
15
16
  from aws_durable_execution_sdk_python.lambda_service import (
@@ -259,8 +260,8 @@ class InvocationInfo:
259
260
  )
260
261
  """EXPERIMENTAL: The deserialized execution input, when available.
261
262
 
262
- Surfaced to instrumentation plugins that need to record it (e.g. Workflow
263
- Insight). Mirrors the JS SDK's ``InvocationInfo.executionInput``.
263
+ Surfaced to instrumentation plugins that need to record it (e.g. Durable
264
+ Execution Telemetry). Mirrors the JS SDK's ``InvocationInfo.executionInput``.
264
265
 
265
266
  Excluded from ``repr`` on purpose: instrumentation logs hook infos wholesale
266
267
  (the bundled OTel plugins at debug level, the plugin example at info), so
@@ -381,9 +382,41 @@ class InvocationEndInfo(InvocationInfo):
381
382
  )
382
383
 
383
384
 
385
+ @dataclass(frozen=True)
386
+ class PropagationInput:
387
+ """SDK-owned identity for a new chained-invoke START propagation request.
388
+
389
+ This contract does not contain generated service-model or telemetry types.
390
+ """
391
+
392
+ execution_arn: str
393
+ operation_id: str
394
+ target_function_name: str
395
+ parent_operation_id: str | None = None
396
+
397
+
398
+ @dataclass(frozen=True)
399
+ class PropagationMetadata:
400
+ """Immutable plugin contribution; members are opaque to the core SDK."""
401
+
402
+ x_amzn_trace_id: str | None = None
403
+
404
+
384
405
  class DurableInstrumentationPlugin:
385
406
  """Base class for plugins. Override only the methods you need."""
386
407
 
408
+ def provide_propagation_metadata(
409
+ self,
410
+ info: PropagationInput,
411
+ ) -> PropagationMetadata | None:
412
+ """Synchronously provide metadata without performing a durable operation.
413
+
414
+ Called before checkpointing a new invoke START, never for its replay.
415
+ Existing plugins can inherit the no-op default. A failed, uncommitted
416
+ START may collect again on a later attempt.
417
+ """
418
+ return None
419
+
387
420
  def on_invocation_start(self, info: InvocationStartInfo) -> None:
388
421
  """Called when an invocation starts. This is called within the thread that runs user function handler.
389
422
 
@@ -466,6 +499,92 @@ class PluginExecutor:
466
499
  self._executor: ThreadPoolExecutor | None = None
467
500
  self._invocation_status: InvocationStartInfo | None = None
468
501
  self._operations_provider: Callable[[], Mapping[str, Operation]] | None = None
502
+ # Non-None only after a Start hook fails: later setup and invocation
503
+ # work use its pre-hook snapshot. End still uses each hook's token owner.
504
+ self._startup_context: contextvars.Context | None = None
505
+ self._invocation_contexts: list[contextvars.Context | None] = []
506
+
507
+ def provide_propagation_metadata(
508
+ self, info: PropagationInput
509
+ ) -> PropagationMetadata:
510
+ """Collect opaque metadata synchronously in configured plugin order.
511
+
512
+ The first non-blank value wins. Ordinary plugin failures are isolated,
513
+ matching lifecycle dispatch; BaseException cancellation/control signals
514
+ retain the existing propagation policy. The caller attaches the result
515
+ to its operation model before checkpoint serialization.
516
+ """
517
+ value: str | None = None
518
+ owner: str | None = None
519
+ conflict_count = 0
520
+ for plugin in self._plugins:
521
+ # Read type identity without invoking instance attribute access.
522
+ identity = "unknown plugin"
523
+ try:
524
+ plugin_type = type(plugin)
525
+ identity = type.__getattribute__(plugin_type, "__qualname__")
526
+ module = type.__getattribute__(plugin_type, "__module__")
527
+ if type(module) is str:
528
+ identity = ".".join((module, identity))
529
+ except Exception:
530
+ pass
531
+ try:
532
+ metadata = plugin.provide_propagation_metadata(info)
533
+ if metadata is None:
534
+ continue
535
+ if inspect.iscoroutine(metadata):
536
+ metadata.close()
537
+ raise TypeError("propagation metadata hook must be synchronous")
538
+ if not isinstance(metadata, PropagationMetadata):
539
+ raise TypeError("expected PropagationMetadata or None")
540
+ candidate = metadata.x_amzn_trace_id
541
+ if candidate is not None:
542
+ if not isinstance(candidate, str):
543
+ raise TypeError("x_amzn_trace_id must be a string or None")
544
+ # Treat str subclasses as data, not executable comparison
545
+ # hooks during first-value/conflict aggregation.
546
+ candidate = str.__str__(candidate)
547
+ except Exception:
548
+ self._log_propagation_diagnostic(
549
+ logging.ERROR,
550
+ "Plugin %s propagation metadata failure ignored",
551
+ identity,
552
+ exc_info=True,
553
+ )
554
+ continue
555
+ if candidate is None or not candidate.strip():
556
+ continue
557
+ if value is None:
558
+ value, owner = candidate, identity
559
+ elif candidate != value:
560
+ conflict_count += 1
561
+ self._log_propagation_diagnostic(
562
+ logging.WARNING,
563
+ "Propagation metadata conflict between %s and %s for "
564
+ "x_amzn_trace_id; keeping first value (conflict_count=%d)",
565
+ owner,
566
+ identity,
567
+ conflict_count,
568
+ )
569
+ return PropagationMetadata(x_amzn_trace_id=value)
570
+
571
+ @staticmethod
572
+ def _log_propagation_diagnostic(
573
+ level: int,
574
+ message: str,
575
+ *args: object,
576
+ exc_info: bool = False,
577
+ ) -> None:
578
+ """Keep ordinary diagnostic failures from changing propagation collection."""
579
+ try:
580
+ logger.log(level, message, *args, exc_info=exc_info)
581
+ except Exception:
582
+ pass
583
+
584
+ @property
585
+ def has_plugins(self) -> bool:
586
+ """Return True when at least one plugin is registered."""
587
+ return bool(self._plugins)
469
588
 
470
589
  @contextlib.contextmanager
471
590
  def run(self):
@@ -479,12 +598,14 @@ class PluginExecutor:
479
598
  finally:
480
599
  self._invocation_status = None
481
600
  self._operations_provider = None
601
+ self._startup_context = None
602
+ self._invocation_contexts.clear()
482
603
  # Shut down the thread pool, waiting for pending tasks to complete.
483
604
  if self._executor:
484
605
  self._executor.shutdown(wait=True)
485
606
 
486
607
  @staticmethod
487
- def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> None:
608
+ def _dispatch_plugin(plugin: DurableInstrumentationPlugin, info) -> bool:
488
609
  """Invoke the appropriate plugin callback. Runs inside the thread pool."""
489
610
  try:
490
611
  match info:
@@ -507,18 +628,54 @@ class PluginExecutor:
507
628
  except Exception:
508
629
  # log and ignore the exception
509
630
  logger.exception("Plugin %s exception ignored", plugin.__class__.__name__)
631
+ return False
632
+ return True
510
633
 
511
634
  def execute_plugins(self, info, sync):
512
635
  if not self._executor:
513
636
  return
514
- for plugin in self._plugins:
515
- if sync:
516
- # this is called synchronously, so plugins will be able to manipulate thread local objects
517
- self._dispatch_plugin(plugin, info)
637
+ if sync and isinstance(info, InvocationStartInfo):
638
+ self._startup_context = None
639
+ self._invocation_contexts.clear()
640
+ for index, plugin in enumerate(self._plugins):
641
+ if sync and isinstance(info, InvocationStartInfo):
642
+ owner = self._startup_context
643
+ before = (
644
+ owner.copy() if owner is not None else contextvars.copy_context()
645
+ )
646
+ self._invocation_contexts.append(owner)
647
+ succeeded = (
648
+ owner.run(self._dispatch_plugin, plugin, info)
649
+ if owner is not None
650
+ else self._dispatch_plugin(plugin, info)
651
+ )
652
+ if not succeeded:
653
+ # A failing hook may have left new bindings with no reset token.
654
+ # Continue setup and the handler in the pre-hook snapshot.
655
+ self._startup_context = before
656
+ elif sync:
657
+ # End hooks must reset tokens in the Context that created them,
658
+ # even when a failed start hook moved later setup to a snapshot.
659
+ owner = (
660
+ self._invocation_contexts[index]
661
+ if isinstance(info, InvocationEndInfo)
662
+ and index < len(self._invocation_contexts)
663
+ else None
664
+ )
665
+ if owner is not None:
666
+ owner.run(self._dispatch_plugin, plugin, info)
667
+ else:
668
+ self._dispatch_plugin(plugin, info)
518
669
  else:
519
670
  # this is called asynchronously, so plugins cannot manipulate thread local objects
520
671
  self._executor.submit(self._dispatch_plugin, plugin, info)
521
672
 
673
+ def _run_in_invocation_context(self, invoke: Callable[[], Any]) -> Any:
674
+ """Continue in the pre-hook Context only after a failed Start hook."""
675
+ if self._startup_context is not None:
676
+ return self._startup_context.run(invoke)
677
+ return invoke()
678
+
522
679
  def _snapshot_operation_infos(
523
680
  self,
524
681
  operations_provider: Callable[[], Mapping[str, Operation]] | None,
@@ -763,7 +920,9 @@ class PluginExecutor:
763
920
  operations: full operation map after the update, when available.
764
921
  previous_operations: operation map before the update, when available.
765
922
  """
766
- if operation_or_operations is None:
923
+ # Only plugins read the info built below, and the change info converts
924
+ # every operation. So without a plugin, return before building any of it.
925
+ if operation_or_operations is None or not self.has_plugins:
767
926
  return
768
927
 
769
928
  updated_operations: list[Operation] = (
@@ -836,28 +995,3 @@ class PluginExecutor:
836
995
  OperationStatus.CANCELLED,
837
996
  OperationStatus.STOPPED,
838
997
  ]
839
-
840
- @property
841
- def handle_durable_output(self):
842
- def decorator(func: Callable[[Any, LambdaContext], MutableMapping[str, Any]]):
843
- @functools.wraps(func)
844
- def wrapper(event: Any, context: LambdaContext):
845
- with self.run():
846
- try:
847
- output = func(event, context)
848
-
849
- self.on_invocation_end(
850
- output=DurableExecutionInvocationOutput.from_dict(output),
851
- )
852
- return output
853
- except Exception as e:
854
- self.on_invocation_end(
855
- output=DurableExecutionInvocationOutput.create_retry(
856
- ErrorObject.from_exception(e)
857
- ),
858
- )
859
- raise
860
-
861
- return wrapper
862
-
863
- return decorator
@@ -121,6 +121,9 @@ def _create_plugin(
121
121
  ) from error
122
122
 
123
123
  if type(plugin) is not provider.plugin_type:
124
+ # A wrong-type result may already own constructor resources, but it is
125
+ # never appended to the accepted list cleaned up by the outer boundary.
126
+ _notify_registration_result([plugin], registered=False)
124
127
  raise PluginLoadError(
125
128
  f"Durable instrumentation plugin provider '{plugin_name}' returned "
126
129
  f"{_qualified_type_name(plugin)}; expected "
@@ -130,6 +133,101 @@ def _create_plugin(
130
133
  return plugin
131
134
 
132
135
 
136
+ def _registration_api_owner(plugin_type: type[object]) -> type[object] | None:
137
+ """Find the first explicit marker in the MRO without metaclass descriptors.
138
+
139
+ A subclass inherits its declaring class's registration contract. Its legacy
140
+ same-named fields/helpers are not part of that contract unless it repeats
141
+ the marker. An explicit unsupported marker shadows any inherited opt-in.
142
+ """
143
+ mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(
144
+ plugin_type, type(plugin_type)
145
+ )
146
+ for declaring_type in mro:
147
+ namespace = type.__dict__["__dict__"].__get__(
148
+ declaring_type, type(declaring_type)
149
+ )
150
+ if "__durable_registration_api__" in namespace:
151
+ version = namespace["__durable_registration_api__"]
152
+ return declaring_type if type(version) is int and version == 1 else None
153
+ return None
154
+
155
+
156
+ def _registration_result_callback(
157
+ plugin: DurableInstrumentationPlugin, owner: type[object]
158
+ ) -> object | None:
159
+ """Bind the opted-in contract without calling a legacy subclass helper."""
160
+ if owner is type(plugin):
161
+ return getattr(plugin, "on_registration_result", None)
162
+ mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(owner, type(owner))
163
+ for declaring_type in mro:
164
+ namespace = type.__dict__["__dict__"].__get__(
165
+ declaring_type, type(declaring_type)
166
+ )
167
+ if "on_registration_result" in namespace:
168
+ callback = namespace["on_registration_result"]
169
+ bind = getattr(callback, "__get__", None)
170
+ return bind(plugin, type(plugin)) if bind is not None else callback
171
+ return None
172
+
173
+
174
+ def _exclusive_groups(plugin_type: type[DurableInstrumentationPlugin]) -> list[str]:
175
+ """Accumulate opted-in MRO contracts without interpreting legacy fields."""
176
+ mro: tuple[type[object], ...] = type.__dict__["__mro__"].__get__(
177
+ plugin_type, type(plugin_type)
178
+ )
179
+ groups: list[str] = []
180
+ for declaring_type in mro:
181
+ namespace = type.__dict__["__dict__"].__get__(
182
+ declaring_type, type(declaring_type)
183
+ )
184
+ version = namespace.get("__durable_registration_api__")
185
+ if type(version) is not int or version != 1:
186
+ continue
187
+ try:
188
+ group = getattr(declaring_type, "exclusive_group", None)
189
+ except Exception as error:
190
+ raise PluginLoadError(
191
+ f"Cannot read exclusive_group for {_qualified_class_name(plugin_type)}."
192
+ ) from error
193
+ if group is None:
194
+ continue
195
+ if type(group) is not str or not group.strip():
196
+ raise PluginLoadError(
197
+ f"Durable instrumentation plugin {_qualified_class_name(plugin_type)} "
198
+ "must declare exclusive_group as None or a non-empty string."
199
+ )
200
+ # Repeating a marker or a group does not register this one plugin twice.
201
+ if group not in groups:
202
+ groups.append(group)
203
+ return groups
204
+
205
+
206
+ def _validate_exclusive_groups(
207
+ plugin_types: Sequence[type[DurableInstrumentationPlugin]],
208
+ ) -> None:
209
+ """Reject competing instrumentation before any lifecycle hooks run."""
210
+ groups: dict[str, type[DurableInstrumentationPlugin]] = {}
211
+ for plugin_type in plugin_types:
212
+ if _registration_api_owner(plugin_type) is None:
213
+ continue
214
+ for group in _exclusive_groups(plugin_type):
215
+ if (previous := groups.get(group)) is not None:
216
+ if previous is plugin_type:
217
+ raise PluginLoadError(
218
+ f"Durable instrumentation plugin {_qualified_class_name(plugin_type)} "
219
+ f"is registered more than once in exclusive group '{group}'. "
220
+ "Register this plugin only once in plugins."
221
+ )
222
+ raise PluginLoadError(
223
+ f"Durable instrumentation plugins {_qualified_class_name(previous)} "
224
+ f"and {_qualified_class_name(plugin_type)} are mutually exclusive "
225
+ f"(group '{group}'). Keep only one plugin from this group in "
226
+ "plugins and DURABLE_EXECUTION_PLUGINS."
227
+ )
228
+ groups[group] = plugin_type
229
+
230
+
133
231
  def load_configured_plugins(
134
232
  explicit_plugins: Sequence[DurableInstrumentationPlugin] | None,
135
233
  *,
@@ -144,9 +242,45 @@ def load_configured_plugins(
144
242
  """
145
243
 
146
244
  resolved_plugins = list(explicit_plugins or [])
245
+ try:
246
+ result = _resolve_configured_plugins(resolved_plugins, environment=environment)
247
+ except PluginLoadError:
248
+ _notify_registration_result(resolved_plugins, registered=False)
249
+ raise
250
+ _notify_registration_result(result, registered=True)
251
+ return result
252
+
253
+
254
+ def _notify_registration_result(
255
+ plugins: Sequence[DurableInstrumentationPlugin],
256
+ *,
257
+ registered: bool,
258
+ ) -> None:
259
+ """Let a plugin release rejected constructor resources without failing loading."""
260
+ for plugin in plugins:
261
+ owner = _registration_api_owner(type(plugin))
262
+ if owner is None:
263
+ continue
264
+ try:
265
+ callback = _registration_result_callback(plugin, owner)
266
+ if callable(callback):
267
+ callback(registered)
268
+ except Exception:
269
+ try:
270
+ logger.exception("Plugin registration-result callback failed")
271
+ except Exception:
272
+ pass
273
+
274
+
275
+ def _resolve_configured_plugins(
276
+ resolved_plugins: list[DurableInstrumentationPlugin],
277
+ *,
278
+ environment: Mapping[str, str] | None,
279
+ ) -> list[DurableInstrumentationPlugin]:
147
280
  resolved_environment = os.environ if environment is None else environment
148
281
  plugin_names = _parse_configured_plugin_names(resolved_environment)
149
282
  if not plugin_names:
283
+ _validate_exclusive_groups([type(plugin) for plugin in resolved_plugins])
150
284
  return resolved_plugins
151
285
 
152
286
  try:
@@ -167,6 +301,9 @@ def load_configured_plugins(
167
301
  type(plugin): "the decorator's plugins argument" for plugin in resolved_plugins
168
302
  }
169
303
 
304
+ selected_providers: list[
305
+ tuple[str, metadata.EntryPoint, DurableInstrumentationPluginProvider]
306
+ ] = []
170
307
  for plugin_name in plugin_names:
171
308
  matching_entry_points = entry_points_by_name.get(plugin_name, [])
172
309
  if not matching_entry_points:
@@ -202,8 +339,15 @@ def load_configured_plugins(
202
339
  )
203
340
  continue
204
341
 
205
- plugin = _create_plugin(plugin_name, entry_point, provider)
206
- resolved_plugins.append(plugin)
342
+ selected_providers.append((plugin_name, entry_point, provider))
207
343
  registered_types[provider.plugin_type] = f"dynamic provider '{plugin_name}'"
208
344
 
345
+ # Factories can install instrumentation globally. Validate every selected
346
+ # type first so a rejected configuration leaves no discarded plugin behind.
347
+ _validate_exclusive_groups(
348
+ [type(plugin) for plugin in resolved_plugins]
349
+ + [provider.plugin_type for _, _, provider in selected_providers]
350
+ )
351
+ for plugin_name, entry_point, provider in selected_providers:
352
+ resolved_plugins.append(_create_plugin(plugin_name, entry_point, provider))
209
353
  return resolved_plugins