waffle-commons / http-client
PSR-18 HTTP client for Waffle Commons: default-on SSRF protection, bounded 8 KiB streaming in both directions, timeout ceilings, and concurrent promise-based fan-out.
Requires
- php: ^8.5
- ext-curl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^3.0
- waffle-commons/contracts: 0.1.0-beta7
- waffle-commons/utils: 0.1.0-beta7
Requires (Dev)
- carthage-software/mago: ^1.59
- cyclonedx/cyclonedx-php-composer: ^6.2
- igor-php/igor-php: ^0.10.1
- nyholm/psr7: ^1.8
- php-mock/php-mock-phpunit: ^2.16
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.20
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 02:29:57 UTC
README
Waffle HTTP Client Component
Release:
0.1.0-beta7Β |ΒCHANGELOG.mdPSR Compliance: PSR-18 (Psr\Http\Client\ClientInterface), PSR-7 messages, PSR-17 factories
A high-performance PSR-18 HTTP client tuned for FrankenPHP resident-worker proxying. Holds a single persistent \CurlHandle plus a \CurlMultiHandle, reused via curl_reset() across every sendRequest() so libcurl's DNS cache and keep-alive pool stay warm. The transfer is driven through the multi interface, so the worker parks on curl_multi_select() (a socket-level wait) instead of blocking inside curl_exec(). Bodies stream in 8 KiB chunks in both directions β request bodies are pulled from the PSR-7 request stream and response bodies are pushed into a PSR-7 stream β so neither side is ever materialised whole in worker memory.
π Beta-7 β outbound resilience (NET-02, NET-03)
- Bounded timeouts.
Client::withTimeouts(TimeoutPolicy)returns a client with per-request timeouts (an immutable wither). The defaults are the former ceilings, and no infinite timeout can be expressed. - Retries.
Resilience\RetryingClientwraps any PSR-18 client. It retries transport failures and429/502/503/504, idempotent methods only unless opted in, with exponential backoff and full jitter, and honours a numericRetry-Afterbeyond the cap within the total budget. - Circuit breaker.
Resilience\CircuitBreakerClientkeeps one circuit per origin in the sharedCircuitStateStorageInterface(Redis, withwaffle-commons/data). An open circuit fails fast withException\CircuitOpenException(503, never retried), and every transition is published as aCircuitStateChangedevent and as metrics. Compose them asRetryingClient(CircuitBreakerClient(Client)), so every attempt is checked and re-runs the SSRF guard. - Settled fan-out.
Client::settleRequests()(SettlingClientInterface) maps every request of a batch to its response or to the exception its transfer failed with, and throws nothing. Both decorators cover this concurrent path. - Breaking: an unresolvable host is now a
NetworkException(cURL code 6), no longer anSsrfException, so the retry policy retries it and the breaker counts it. - Fixed: one framing per request body (RFC 9112 Β§6.1): the caller's
Content-Lengthno longer goes out next toTransfer-Encoding: chunked, which nginx refused with400. Transport failures areNetworkExceptions again on every path; since0.1.0-beta5they read asRequestException.
How-to: Make outbound calls resilient.
π Beta-1 foundations (still current)
- Persistent socket reuse. A single
\CurlHandleand a single\CurlMultiHandlelive for the worker's lifetime;curl_reset()between requests keeps the connection pool, DNS cache, and TLS session cache warm. Source:Client.php(// Holds a single persistent CurlHandle plus a CurlMultiHandle that are reused via curl_reset() ...). - Non-blocking transfer. The transfer runs through the persistent
\CurlMultiHandle(curl_multi_exec+curl_multi_select) rather than the blockingcurl_exec(). The worker never busy-spins, and a slow legacy backend can no longer pin it on a blocking syscall beyond the hard timeout ceiling. The multi handle is also the building block for future concurrent fan-out. - Bidirectional bounded streaming (8 KiB chunks). Request bodies are streamed via
CURLOPT_READFUNCTION+CURLOPT_UPLOAD(pullingCHUNK_SIZE = 8 * 1024bytes at a time from the PSR-7 request stream) instead of buffering the whole payload withCURLOPT_POSTFIELDS. The length is the stream's size, else the request's single well-formedContent-Length; only an unknown length goes outTransfer-Encoding: chunked, and libcurl writes the framing (Beta-7, RFC 9112 Β§6.1). Response bodies stream viaCURLOPT_WRITEFUNCTION. A multi-gigabyte multipart upload is proxied without a RAM spike. - SEC-03 SSRF allowlist.
Client::applyRequest()sets bothCURLOPT_PROTOCOLSandCURLOPT_REDIR_PROTOCOLStoCURLPROTO_HTTP | CURLPROTO_HTTPS(verbatim,Client.php:178-179), blocking SSRF pivots viafile://,gopher://,dict://,ldap://, etc. β even when a caller-supplied URL or a server-suppliedLocationheader tries to switch protocols mid-flight.
π¦ Installation
composer require waffle-commons/http-client
You also need PSR-17 factory implementations for ResponseFactoryInterface and StreamFactoryInterface. The framework defaults to waffle-commons/http; nyholm/psr7 works equally well.
π§± Surface
| Class | Role |
|---|---|
Waffle\Commons\HttpClient\Client |
final readonly PSR-18 client. Persistent cURL easy + multi handles, non-blocking transfer, 1 s connect / 10 s total timeouts by default (per request through withTimeouts(TimeoutPolicy)), request and response bodies streamed in 8 KiB chunks. |
Waffle\Commons\HttpClient\Resilience\RetryingClient |
Bounded retries with backoff and jitter around any PSR-18 client, single and settled batches (Beta-7, NET-02). |
Waffle\Commons\HttpClient\Resilience\CircuitBreakerClient |
Per-origin circuit breaker over the shared CircuitStateStorageInterface (Beta-7, NET-03). |
Waffle\Commons\HttpClient\Exception\CircuitOpenException |
Thrown, or settled, while a circuit is open: 503, never retried. |
Waffle\Commons\HttpClient\Exception\HttpClientException |
Base class for client errors (e.g. handle init failure). Implements Psr\Http\Client\ClientExceptionInterface. |
Waffle\Commons\HttpClient\Exception\NetworkException |
Transport-layer failures (DNS, connect/read timeout, TLS, reset). Implements Psr\Http\Client\NetworkExceptionInterface. |
Waffle\Commons\HttpClient\Exception\RequestException |
Protocol-level failures or empty responses. Implements Psr\Http\Client\RequestExceptionInterface. |
π Usage
use Nyholm\Psr7\Factory\Psr17Factory; use Waffle\Commons\HttpClient\Client; $psr17 = new Psr17Factory(); $client = new Client( responseFactory: $psr17, streamFactory: $psr17, ); $request = $psr17->createRequest('GET', 'https://api.example.com/users'); $response = $client->sendRequest($request); echo $response->getStatusCode(); // 200 echo (string) $response->getBody(); // streamed body
π‘οΈ Security defaults
The client enforces a minimum security baseline that callers cannot lower:
| Option | Value | Why |
|---|---|---|
CURLOPT_PROTOCOLS |
CURLPROTO_HTTP | CURLPROTO_HTTPS |
SEC-03 SSRF allowlist on the request URL. |
CURLOPT_REDIR_PROTOCOLS |
CURLPROTO_HTTP | CURLPROTO_HTTPS |
SEC-03 SSRF allowlist on any redirect target. |
CURLOPT_SSL_VERIFYPEER |
true |
Forces full certificate validation. |
CURLOPT_SSL_VERIFYHOST |
2 |
Forces hostname match against the certificate. |
CURLOPT_FOLLOWLOCATION |
false |
The client never silently follows redirects β callers must handle them explicitly. |
CURLOPT_CONNECTTIMEOUT_MS |
1_000 |
Hard 1-second ceiling. Cannot be raised. |
CURLOPT_TIMEOUT_MS |
10_000 |
Hard 10-second ceiling. Cannot be raised. A hung legacy backend must never lock a worker thread. |
π PHP 8.5 features used
final readonly class Clientwith promoted constructor properties.- Typed integer constants for every timeout/chunk-size value (
CONNECT_TIMEOUT_MS,TIMEOUT_MS,CHUNK_SIZE). #[\Override]on the PSR-18 implementation method.matchexpression for HTTP-version negotiation.
π§ Architectural boundary (mago guard)
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] β a forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\HttpClient may depend only on:
Waffle\Commons\HttpClient\**β itselfWaffle\Commons\Contracts\**β the shared contracts package, the only Waffle dependency permittedPsr\**β PSR interfaces (PSR-7 / PSR-17 / PSR-18)@global+Psl\**β PHP core (includingext-curl) and the PHP Standard Library
Test code under WaffleTests\Commons\HttpClient is unrestricted (@all); the tests/src/ClientTest.php php-mock fixture is listed in [guard].excludes because it re-declares the production namespace to stub libcurl. Structural rules are guarded too: interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\** namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts, never directly through one another.
π§ͺ Testing
docker exec -w /waffle-commons/http-client waffle-dev composer tests
The test suite uses php-mock/php-mock-phpunit to stub libcurl entry points (curl_init, curl_setopt_array, curl_multi_exec, curl_multi_info_read, β¦), so PHPUnit runs hermetically without network I/O. Dedicated tests assert the SEC-03 protocol allowlist, the chunked request-body upload, and the non-blocking multi-handle transfer.
π Documentation
Full guides live in the central DiΓ‘taxis documentation tree:
- Reference β
waffle-commons/http-client - How-to β Make outbound calls resilient
- Documentation home
π License
MIT β see LICENSE.md.