Current design
Policy composition is built on a public, per-instance mutable link. Policy (and its async twin) declare next: Policy as plain instance state:
pipeline/policy.py:41 — next: Policy on the ABC; pipeline/async_policy.py:30 — the same for AsyncPolicy.
Pipeline.__init__ wires the chain by assigning each node's .next in place: _wire_chain walks the policy list and does current.next = following, ending with wrapped[-1].next = terminal (pipeline/pipeline.py:135-162).
- Single ownership is a runtime convention layered on that freely-mutable field:
_wire_chain first inspects getattr(policy, "next", None) is not None and raises ValueError if a policy is already wired (pipeline/pipeline.py:152-158).
StagedPipelineBuilder.from_pipeline harvests policies out of an existing pipeline by walking .next and then detaching each one with del policy.next so they look freshly constructed to the guard (pipeline/staged_builder.py:168-191, _detach at 235-246). The docstring states the source pipeline "is consumed by this call ... must not be run again."
So the composition graph lives in mutable public state on objects the rest of the design treats as single-owner.
Trade-off / concern
This is the one place in the SDK where structural state is held in a freely-mutable public field, and the codebase then spends real machinery defending against the hazards that creates:
- Sharing a policy instance across two pipelines silently re-points the first chain unless the guard fires; the guard exists precisely because the field is writable.
from_pipeline cannot be a pure read — harvesting mutates .next, so it must consume (destroy) the source pipeline. Re-running a from_pipeline-sourced pipeline is undefined.
- A consumer can reassign
policy.next at runtime and bypass every invariant.
This sits awkwardly against the project's stated philosophy. CLAUDE.md frames the port as one that "elsewhere rejects Java/.NET idioms" and makes "immutable data with slots" the default for models. The .next model is the inherited Azure corehttp shape (acknowledged in policy.py:28-30), and it is the natural seam to reconsider in a Python port. Two already-filed defects touch exactly this seam (the replace corruption and from_pipeline leaving the source broken), which is some evidence that the mutable-.next model has more than a one-off sharp edge.
Proposed direction
Move the chain link out of the policy and into the pipeline runtime. Two concrete options:
-
Stateless onion / middleware model. Change send to send(request, ctx, next_call), where next_call is a per-invocation closure the pipeline supplies (the ASGI/Starlette pattern). Policies then hold no chain state at all: sharing a policy across pipelines is free, re-running is free, from_pipeline becomes a pure read, and both the single-ownership guard and the detach machinery disappear. Trade-off: this changes the send signature (breaking for existing Policy subclasses) and policies loop on next_call(...) instead of self.next.send(...) — arguably clearer, but a migration.
-
Pipeline-owned wrapper node. Keep the send(request, ctx) signature but stop storing the link on the user's policy. The pipeline wraps each policy in a private _Node(policy, next) that it owns; the user's Policy carries no chain state. Trade-off: one extra indirection per hop, and the runner has to thread the node rather than the policy, but the public Policy surface stays as-is.
Either option also dovetails with unifying the sync/async dispatch loops, since the link would live in one place (the runner) rather than being duplicated on Policy and AsyncPolicy.
Acknowledging the current rationale
The docstring is explicit that this is a deliberate port: "Modelled on Azure's corehttp.runtime.policies.HTTPPolicy: .next is a per-instance attribute wired up by the pipeline constructor; the terminal node is a transport runner" (policy.py:28-30). That model is well-understood and the guard plus from_pipeline consume-semantics make the current behaviour at least defined. It is worth noting that this particular choice is not listed among the "Honest scope boundaries" in the changelog, so unlike (say) the deliberate absence of an IoProvider seam, it does not read as a consciously-defended trade-off so much as an inherited shape. Given that the SDK otherwise leans hard into immutability and rejects Java/.NET idioms where Python offers something cleaner, moving the link off the policy seems worth a deliberate decision rather than carrying it by inheritance.
Opening this as a discussion: is the corehttp .next model something we want to keep for familiarity, or is one of the above worth the migration cost before the API ossifies?
Current design
Policy composition is built on a public, per-instance mutable link.
Policy(and its async twin) declarenext: Policyas plain instance state:pipeline/policy.py:41—next: Policyon the ABC;pipeline/async_policy.py:30— the same forAsyncPolicy.Pipeline.__init__wires the chain by assigning each node's.nextin place:_wire_chainwalks the policy list and doescurrent.next = following, ending withwrapped[-1].next = terminal(pipeline/pipeline.py:135-162)._wire_chainfirst inspectsgetattr(policy, "next", None) is not Noneand raisesValueErrorif a policy is already wired (pipeline/pipeline.py:152-158).StagedPipelineBuilder.from_pipelineharvests policies out of an existing pipeline by walking.nextand then detaching each one withdel policy.nextso they look freshly constructed to the guard (pipeline/staged_builder.py:168-191,_detachat235-246). The docstring states the source pipeline "is consumed by this call ... must not be run again."So the composition graph lives in mutable public state on objects the rest of the design treats as single-owner.
Trade-off / concern
This is the one place in the SDK where structural state is held in a freely-mutable public field, and the codebase then spends real machinery defending against the hazards that creates:
from_pipelinecannot be a pure read — harvesting mutates.next, so it must consume (destroy) the source pipeline. Re-running afrom_pipeline-sourced pipeline is undefined.policy.nextat runtime and bypass every invariant.This sits awkwardly against the project's stated philosophy. CLAUDE.md frames the port as one that "elsewhere rejects Java/.NET idioms" and makes "immutable data with slots" the default for models. The
.nextmodel is the inherited Azurecorehttpshape (acknowledged inpolicy.py:28-30), and it is the natural seam to reconsider in a Python port. Two already-filed defects touch exactly this seam (thereplacecorruption andfrom_pipelineleaving the source broken), which is some evidence that the mutable-.nextmodel has more than a one-off sharp edge.Proposed direction
Move the chain link out of the policy and into the pipeline runtime. Two concrete options:
Stateless onion / middleware model. Change
sendtosend(request, ctx, next_call), wherenext_callis a per-invocation closure the pipeline supplies (the ASGI/Starlette pattern). Policies then hold no chain state at all: sharing a policy across pipelines is free, re-running is free,from_pipelinebecomes a pure read, and both the single-ownership guard and the detach machinery disappear. Trade-off: this changes thesendsignature (breaking for existingPolicysubclasses) and policies loop onnext_call(...)instead ofself.next.send(...)— arguably clearer, but a migration.Pipeline-owned wrapper node. Keep the
send(request, ctx)signature but stop storing the link on the user's policy. The pipeline wraps each policy in a private_Node(policy, next)that it owns; the user'sPolicycarries no chain state. Trade-off: one extra indirection per hop, and the runner has to thread the node rather than the policy, but the publicPolicysurface stays as-is.Either option also dovetails with unifying the sync/async dispatch loops, since the link would live in one place (the runner) rather than being duplicated on
PolicyandAsyncPolicy.Acknowledging the current rationale
The docstring is explicit that this is a deliberate port: "Modelled on Azure's
corehttp.runtime.policies.HTTPPolicy:.nextis a per-instance attribute wired up by the pipeline constructor; the terminal node is a transport runner" (policy.py:28-30). That model is well-understood and the guard plusfrom_pipelineconsume-semantics make the current behaviour at least defined. It is worth noting that this particular choice is not listed among the "Honest scope boundaries" in the changelog, so unlike (say) the deliberate absence of anIoProviderseam, it does not read as a consciously-defended trade-off so much as an inherited shape. Given that the SDK otherwise leans hard into immutability and rejects Java/.NET idioms where Python offers something cleaner, moving the link off the policy seems worth a deliberate decision rather than carrying it by inheritance.Opening this as a discussion: is the
corehttp.nextmodel something we want to keep for familiarity, or is one of the above worth the migration cost before the API ossifies?