---
title: "ADR-0018: One cURL factory creation per transfer lease"
manual: "HTTP Guard"
version: "main"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-http-guard:adr-0018@main"
source: "Adr/ADR-0018-SingleUseCurlFactory.rst"
rendered: "2026-10-18T14:31:38+00:00"
---

# ADR-0018: One cURL factory creation per transfer lease {#adr-0018}

-   *Status:* Accepted
-   *Date:* 2026-10-10
-   *Relation:* Supplements ADR-0003, ADR-0007 and ADR-0010.

## Context {#adr-0018-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](https://docs.typo3.org/permalink/netresearch/nr-http-guard:adr-0010@main) 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](https://docs.typo3.org/permalink/netresearch/nr-http-guard:adr-0003@main) and [ADR-0007: Controlled cURL only; no proxies or PHP streams in v1](https://docs.typo3.org/permalink/netresearch/nr-http-guard:adr-0007@main); it does not
restrict every independent use of cURL in the PHP process.

## Decision {#adr-0018-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 {#adr-0018-alternatives}

-   **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 {#adr-0018-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 {#adr-0018-verification}

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](https://docs.typo3.org/permalink/netresearch/nr-http-guard:verification-report@main). Today's source inspection for this ADR is
neither a new test run nor a new transport measurement.

## Reconsideration trigger {#adr-0018-reassessment-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.
