ADR-0018: One cURL factory creation per transfer lease 

Status

Accepted

Date

2026-10-10

Relation

Supplements ADR-0003, ADR-0007 and ADR-0010.

Context 

A new native connection must not bypass the terminal policy check. An SDK may request an internal retry while finishing a failed transfer, and body preparation callbacks can re-enter handle creation. Private SDK retry counters are not a stable boundary for these attempts.

The transfer lease in ADR-0010: Allow address memoization; isolate authorization and connection pooling therefore needs its own single-use boundary immediately before native handle creation. This decision details the controlled transport in ADR-0003: Two middleware boundaries and a controlled terminal transport and ADR-0007: Controlled cURL only; no proxies or PHP streams in v1; it does not restrict every independent use of cURL in the PHP process.

Decision 

SingleUseCurlFactory implements the public CurlFactoryInterface and decorates CurlFactory(0). It consumes its attempt before the first delegation. Every second create() request on the same instance fails with transport_unsupported before the delegate creates another handle. This includes failed and re-entrant body preparation. release() delegates cleanup without resetting the consumed instance.

TransferLease constructs a new single-use factory and its own CurlMultiHandler for each attempt. Connection and DNS state are not carried between independent leases. Fresh connections, forbidden reuse and disabled native DNS caching supplement connection-bound pinning. Policy validity is checked after DNS and body preparation and before network progress starts.

RuntimeSupport validates actual supported Guzzle 7 and 8 graphs and the public factory and handler contracts before the guarded transfer. The single-use boundary neither reads nor changes private SDK retry counters. A genuine caller retry needs a new policy check and lease; policy failures do not authorize an automatic retry.

Middleware ordering has a separate, deliberate dependency on the private HandlerStack.stack inventory. RuntimeSupport probes the installed shape with a known named entry; GuardedClientFactory inspects it to verify ordering and reject later changes. An incompatible inventory fails closed before protected transport. This qualified coupling remains even though the factory fence uses a public interface.

Alternatives considered 

  • Set a private SDK retry counter: depends on internal SDK details.
  • Middleware before the first send only: does not bound hidden handle creation within the same terminal attempt.
  • Reset the factory after release or failed preparation: reopens a consumed lease for another native attempt.
  • Share a pool or lease between attempts: carries an earlier decision into a new connection attempt.

Consequences 

A lease can call the delegate's handle creation at most once. This bounds factory attempts; it does not promise a particular number of packets or of pinned addresses tried by cURL within one handle. Callback and SDK retry failures remain controlled errors. Cleanup and cancellation still belong to the lease; independent transfers own separate resources and do not reuse connections.

Compatible SDK patches within the supported semantic ranges do not require an extension release merely because their version changes. A changed public interface or unsupported major graph is rejected before the guarded transfer and requires renewed qualification. A private middleware inventory change also requires requalification; public factory compatibility alone does not establish compatibility of the entire SDK integration.

Sources and evidence 

The implementation is in Classes/HttpGuard/Transport/SingleUseCurlFactory.php, Classes/HttpGuard/Transport/TransferLease.php and Classes/HttpGuard/Transport/RuntimeSupport.php. The separately qualified private inventory use is in Classes/HttpGuard/Client/GuardedClientFactory.php.

Tests/HttpGuard/Unit/Transport/SingleUseCurlFactoryTest.php maintains create/release, failed preparation and re-entry controls. Its genuine public CurlFactory::finish() comparison admits a second handle without the fence and rejects it with the fence. That test creates native handles but performs no handler tick or network I/O. Tests/HttpGuard/Unit/Transport/TransferLeaseLifecycleContractTest.php maintains lease option and cleanup controls; separate contact and retry probes are in Tests/HttpGuard/Integration/ProductionTransportTest.php. Tests/HttpGuard/Unit/Transport/RuntimeSupportTest.php maintains incompatible public API and private inventory shape controls. Previously executed Native and contact evidence is recorded under Verification report. Today's source inspection for this ADR is neither a new test run nor a new transport measurement.

Reconsideration trigger 

Changes to factory or handler contracts or the private middleware inventory, alternative retry paths, connection pooling or new transport interfaces require renewed single-use, lifecycle and actual-contact qualification. Composer dependency resolution alone does not establish that compatibility.