Search by

waffle-commons / http-client

LesliePetrimaux

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.

Package info

github.com/waffle-commons/http-client

pkg:composer/waffle-commons/http-client

Statistics

Installs: 145

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

0.1.0-beta7 2026-11-05 01:39 UTC

README

Discord PHP Version Require PHP CI codecov Latest Stable Version Latest Unstable Version Total Downloads Packagist License

Waffle HTTP Client Component

Release: 0.1.0-beta7 Β |Β  CHANGELOG.md PSR 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\RetryingClient wraps any PSR-18 client. It retries transport failures and 429 / 502 / 503 / 504, idempotent methods only unless opted in, with exponential backoff and full jitter, and honours a numeric Retry-After beyond the cap within the total budget.
  • Circuit breaker. Resilience\CircuitBreakerClient keeps one circuit per origin in the shared CircuitStateStorageInterface (Redis, with waffle-commons/data). An open circuit fails fast with Exception\CircuitOpenException (503, never retried), and every transition is published as a CircuitStateChanged event and as metrics. Compose them as RetryingClient(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 an SsrfException, 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-Length no longer goes out next to Transfer-Encoding: chunked, which nginx refused with 400. Transport failures are NetworkExceptions again on every path; since 0.1.0-beta5 they read as RequestException.

How-to: Make outbound calls resilient.

πŸ†• Beta-1 foundations (still current)

  • Persistent socket reuse. A single \CurlHandle and a single \CurlMultiHandle live 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 blocking curl_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 (pulling CHUNK_SIZE = 8 * 1024 bytes at a time from the PSR-7 request stream) instead of buffering the whole payload with CURLOPT_POSTFIELDS. The length is the stream's size, else the request's single well-formed Content-Length; only an unknown length goes out Transfer-Encoding: chunked, and libcurl writes the framing (Beta-7, RFC 9112 Β§6.1). Response bodies stream via CURLOPT_WRITEFUNCTION. A multi-gigabyte multipart upload is proxied without a RAM spike.
  • SEC-03 SSRF allowlist. Client::applyRequest() sets both CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS to CURLPROTO_HTTP | CURLPROTO_HTTPS (verbatim, Client.php:178-179), blocking SSRF pivots via file://, gopher://, dict://, ldap://, etc. β€” even when a caller-supplied URL or a server-supplied Location header 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 Client with 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.
  • match expression 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\** β€” itself
  • Waffle\Commons\Contracts\** β€” the shared contracts package, the only Waffle dependency permitted
  • Psr\** β€” PSR interfaces (PSR-7 / PSR-17 / PSR-18)
  • @global + Psl\** β€” PHP core (including ext-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:

πŸ“„ License

MIT β€” see LICENSE.md.