Appendix: HTTP client contracts

The exact request, response and transport contracts behind HTTP Client. Start with the guide to install the client, send a request and handle failures.

What the client validates

The client owns the rules its guarantees rest on, and checks them before a transport object exists: the URL and origin a credential is confined to, the headers it owns, the bounds an operation runs under, and the set of per-call options. Input it refuses throws HttpRequestException with the InvalidRequest category and reaches no network.

Everything else — the request method, and the value types inside json, body, and query — is Symfony’s HTTP client’s to validate, where that grammar is already defined. When it refuses to construct a request, the failure is also InvalidRequest, carrying neither the value it refused nor the vendor’s message. That failure is never retried: the same request would be refused again.

Base URLs and request URLs

A base URL is an absolute http/https URL with no userinfo, no query string, and no fragment. Its path is a prefix that a relative target extends, never one a rooted target replaces:

$api = $http->withBaseUrl('https://api.example.com/v1');

$api->get('/orders');  // https://api.example.com/v1/orders
$api->get('orders');   // the same URL — the slash is normalized either way

Once a base URL is set, a request URL must be relative to it. An absolute or scheme-relative URL there is refused. A client with no base URL takes absolute URLs, and only absolute ones.

A . or .. segment is refused rather than resolved, in both — written plainly or percent-encoded, so %2e%2e is the same refusal as ... So is a percent-encoded / or \ inside a segment: a separator that appears only after decoding hides the segments behind it, which is what makes %2e%2e%2fadmin one segment here and two wherever it is resolved. The URL that goes on the wire is the URL you wrote.

Two more shapes are refused in a base URL and a request URL alike: a backslash anywhere in it, which is not a URL character and which the readers that accept it read as /; and any byte outside printable ASCII, which has to be percent-encoded before it can be sent.

Why a credential has one destination

A client carrying an Authorization or Cookie header, set for the client or for one call, requires withBaseUrl(). Without a base URL the call site chooses the whole URL, which would mean the call site chooses who receives the credential. With one, every rule above holds — the target is relative, an absolute or scheme-relative URL is refused, a scheme downgrade is refused because it would be an absolute URL, and a 3xx is never followed — so the credential has one reachable destination. There is no list of sensitive headers to strip on the way out, because nothing this client sends leaves the origin it was configured for.

The validated URL alone names where a request goes. Host is refused wherever you write it, in any casing. A Host of your own would send this client’s credentials to one URL while naming a different server to a shared proxy in front of it, and there is no second authority here for a proxy to be told about.

Proxy-Authorization is the credential this cannot cover, since it is addressed to a proxy and not to the origin the base URL names. It is refused rather than pinned to something it is not sent to.

A transport you inject can carry credentials or a base URI of its own, in its default options. Those are invisible here and therefore unpinned; see Injected transports and tests.

Redirects

Every request is sent with max_redirects set to 0, so a 3xx is a terminal response: status() reports it and header('Location') is there to read. Following a redirect means deciding, per response, whether a new origin may see this client’s Authorization header, its cookies, and its body — including a plain-HTTP origin. That decision belongs to the caller who knows what the credential is for, so the client makes the redirect visible instead of acting on it.

Headers

Header input has one shape: a string name, and a value that is a string or a non-empty list of strings.

$http->withHeaders([
    'X-Tenant' => 'acme',                // one value
    'X-Feature' => ['beta', 'preview'],  // several values for one name
]);

A name is an RFC 9110 token. Anything else as a value is refused rather than converted — a number, a boolean, null, a Stringable, a resource, an iterator. What a cast would produce is not what you wrote, and a header is not the place to find that out. A value carrying CR, LF, NUL, or another control character is refused outright: that is response splitting, not a header.

Within one array a name appears once. Two spellings of one field name in the same array — 'Authorization' and 'authorization' — carry no order that HTTP itself would honour, so the array is refused rather than one spelling being picked for you. Repetition is expressed by the list form.

Precedence lives between arrays: a later withHeaders() overrides an earlier one for the same name, case-insensitively, and a per-call header overrides a configured one the same way — never sending both as ambiguous duplicates:

$api = $http->withHeaders(['Authorization' => 'Bearer old-token']);

// Overrides the configured Authorization, casing and all — only one
// Authorization header is ever sent.
$api->send('GET', '/orders', ['headers' => ['authorization' => 'Bearer new-token']]);

Three names are this client’s own and are refused wherever you write them: Host and Proxy-Authorization, for the reasons above, and Accept-Encoding, because the response-byte ceiling depends on the identity encoding this client asks for — see Response byte ceiling.

Query parameters and bodies

Query parameters are passed as an array, written into an absolute URL, or both. withQuery() adds parameters to every request, and a verb’s or send()’s own array is merged over them. A URL’s own query string is passed through byte for byte when no array accompanies it, which is what a signed URL needs; an array alongside it is merged and re-encoded by the transport, so pick one for a URL whose exact bytes matter.

Bodies are the transport’s to encode. post()/put()/patch()/ delete() send JSON; asForm() switches them to application/x-www-form-urlencoded. Either way the Content-Type is a default a header of your own overrides. A value that cannot be encoded is refused as InvalidRequest, without the value or the vendor message in it.

send() options and streamed bodies

send() is the general form — any method, a raw body, an upload, a header only this call needs:

$http->withBaseUrl('https://api.example.com')->send('POST', '/documents', [
    'headers' => ['Content-Type' => 'application/pdf'],
    'body' => fopen($path, 'r'),
]);

Its $options is an exact map of what this client can check: headers, query, json, body, and timeout. json and body are exclusive, and timeout replaces the client’s total budget for this call. Anything else is refused, and the transport’s own retry, redirect, duration, credential and buffering options are among them: each belongs to a with* method, so a per-call setting can never sit alongside, and disagree with, the client’s own policy.

body is the one place you can hand over something this package cannot inspect: a stream resource or a Closure. Those are sent as they are, and a stream cannot be made replayable: it is consumed as it is read. A client with retries configured refuses one outright on a method it retries rather than sending a second request with a body that is already gone. A method it never retries, such as the POST above, is sent once and takes one on any client; a streamed PUT is sent from a client without retries.

Deadline and retries

The deadline

withTimeout() is the budget for the whole operation — every attempt, every backoff between them, and every read of the response that comes out of it — not a fresh allowance per attempt. It is a finite number of seconds greater than zero, and defaults to Http::DEFAULT_TIMEOUT_SECONDS, 30. Running out throws HttpRequestException with the Timeout category.

It is measured on a monotonic clock, so a clock correction during a long request cannot shorten or extend it, and it is enforced by this client rather than only handed to the transport. Every attempt is given what is left of the budget as timeout and max_duration, and a transport is free to ignore both — so the deadline is checked again before and after every read, and from inside the transfer through the same progress hook the byte ceiling uses. A transport that blocks past the budget and then answers gets a Timeout, not a late success. What cannot be done from here is interrupting it mid-block; nothing in PHP does that.

Retries

withRetries() is the only retry layer there is, and it retries only a request whose method is exactly GET, HEAD, OPTIONS, TRACE, PUT, or DELETE — the methods RFC 9110 defines as idempotent. It sends such a request again, up to $times more times (at most 10), with backoff doubling from 100 ms, for:

  • a transport failure — DNS, a refused connection, a dropped socket;

  • a status the server itself marks as worth repeating: 429, 500, 502, 503, 504.

Every other status is an answer, returned as it is — repeating the request cannot change a 404. Running out of retries is not itself a failure either: the last answer the server gave is the answer you get. A transport failure that outlives them has no answer to give back, so it throws. A request this client or the transport refused, a timeout, and a response past the byte ceiling are never retried.

Backoff waits inside the one deadline. When the next one would not fit, the last response received is returned rather than waited past the budget; with no response in hand, the transport failure propagates.

Every other method is sent once, POST and PATCH included, even by a client with retries configured. Neither a transport failure nor a retryable status proves such a request was not applied: the first leaves its outcome unknown, and a 503 can follow work the server already did. The method is matched exactly, and nothing else about the request — an idempotency-key header included — changes the decision.

A client with retries waits for the response status inside send() on a method it retries, since that status is what the decision is made on. Every other request — any method on a client without retries, and a method this client never retries on one with them — returns from send() as soon as it is issued, and every read stays deferred: the path that lets concurrently() overlap requests, and the one where a POST’s transport failure raises from the read that meets it.

Every response an attempt abandons is released as the loop abandons it, so a retried request costs one connection rather than one per attempt.

Response byte ceiling

withMaxResponseBytes() is the ceiling one response body may reach, in bytes. It defaults to Http::DEFAULT_MAX_RESPONSE_BYTES — 8 MiB — because an upstream you do not control decides how much it sends, and a worker that buffers whatever arrives is a worker one reply can exhaust.

Every request asks for identity encoding, and Accept-Encoding is not yours to set. That is what makes the ceiling a bound on memory rather than on bytes off the wire: given no Accept-Encoding of its own, the Amp-backed Symfony transport asks for gzip and inflates the body transparently, so a kilobyte of gzip becomes a megabyte held before anything can measure it. Asking for identity turns that inflation off, and the bytes counted are the bytes kept. A server is free to answer with a compressed body anyway — it then arrives, and is bounded, as the compressed bytes it is, and Content-Encoding is on the response for you to read. The cost is plain: this client trades compressed transfers for a ceiling that means what it says.

Within that, the ceiling is checked at each of the three points a body can pass it, so no path ends with the whole of an untrusted reply in memory:

  • a Content-Length larger than the ceiling fails before any body is fetched;

  • a transfer that passes the ceiling as it arrives is aborted there, which is what covers a response declaring no length or declaring one it exceeds;

  • what did arrive is measured before it is handed back, so a transport that ignored the first two checks is caught by the one that needs nothing from it.

Exactly the ceiling is a body like any other; one byte past it throws with the ResponseTooLarge category, and the response is released rather than left holding a connection nothing will read.

The refusal surfaces from whichever read reaches it. Usually that is body(), json(), or jsonPath(). It can also be status() — a transport delivers body bytes while it answers a status wait, and a client with retries waits for the status inside send() on every method it retries. What the ceiling never does is fetch a body nobody asked for: a HEAD request, or a status that arrives before any body does, costs nothing.

The ceiling owns the transport’s progress hook, which is why on_progress is not a per-call option: a hook of your own would replace the one enforcing this. The same hook enforces the deadline, so a transfer still arriving after the budget is spent is stopped there. A transport is free to wrap what that hook raises; which failure you get is decided from the budget’s own state rather than from the exception’s type, so a wrapped abort is still reported as the ceiling or the deadline it was.

Reading a response

Reading is deferred until something asks for the body, status, or headers. The body is read once and kept, so body() and json() together fetch once. header() returns the first value of a header, and headers() every value, keyed by lowercase name.

json()/jsonPath() expect a JSON object or array — anything a JSON API returns for a resource. A body that is valid JSON but whose top-level value is a bare string, number, boolean, or null throws with the Conversion category the same way invalid JSON does, reporting the decoded value’s type rather than the value. jsonPath() returns its $default, null unless given, when nothing is at the path.

An integer too large for PHP’s own int type is decoded as a string rather than rounded into a float, so an API that keys resources by ids beyond JavaScript’s safe integer range hands back its digits exactly as they were sent.

Giving a response back

HttpResponse owns the underlying transport response for as long as it lives. discard() is how you end that early, for a response whose status was all you wanted:

$response = $http->send('HEAD', 'https://api.example.com/large-report');
$exists = $response->successful();
$response->discard();

It never throws and never blocks: cancelling is a local operation, and a transport that raises while being cancelled has nothing left to tell a caller who already said they were done. Calling it twice does nothing. Every read after it fails with the Discarded category, an earlier full read included, rather than returning an undefined result.

A response nobody discards releases the same way when PHP collects the object, without blocking and without raising from wherever the collection happened. That fallback is what keeps an ignored response from holding a connection for as long as the object happens to live; it is not the API to reach for, because when a collection happens is PHP’s decision and not yours. discard() is the one that releases at a moment you chose. A body read to its end leaves nothing to release, and neither path cancels a response that is already complete.

Failures

One exception type, Kinetis\RevoltHttpClient\Exception\HttpRequestException, covers everything this client throws, across validation, transport, timeout, status, and decoding. Its category is an HttpFailure chosen at the point of failure from the fixed list in the HTTP Client guide, which also states what Transport and Timeout mean for a write. getMessage() is prose. status is the HTTP status for ErrorStatus and Conversion, and 0 otherwise.

InvalidRequest covers everything this client or the transport refused to send — a misconfigured client, a per-call option, a body value — and always means the same thing: nothing reached the network, and a repeat would be refused the same way.

Warning

An exception from this package carries the request method, the origin (scheme, host, and non-default port), an HTTP status, and a category — and nothing else. No path, no query string, no userinfo, no header, no credential, no request or response body. A vendor exception is never chained and its message is never copied either, because a lower-level HTTP or DNS client routinely names the full URI it failed on, userinfo and all, and an exception message is the one thing a logging pipeline records by default.

getMessage(), (string) $e, and getTraceAsString() all stay within that: parameters that forward your input are marked #[\SensitiveParameter], so a rendered trace shows a redaction marker where an argument would have been. What PHP puts there is a SensitiveParameterValue object that still holds the value, so getTrace() and serialize($e) — anything reading trace arguments rather than rendering them — are not safe to forward.

The upstream’s own error payload is where an API explains itself, and it is read from the response — the one place where taking it is a decision:

if ($response->failed()) {
    $log->warning('upstream said', ['body' => $response->body()]);
}

Injected transports and tests

Http takes any Symfony HttpClientInterface, so a test substitutes one without touching the network:

use Kinetis\RevoltHttpClient\Http;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$http = new Http(new MockHttpClient([
    new MockResponse('{"id": 42}', ['http_code' => 200]),
]));

self::assertSame(42, $http->get('https://api.example.com/orders/42')->jsonPath('id'));

The guarantees in the guide and on this page are this client’s, and three of them need the transport’s cooperation:

  • It must make one wire attempt per request. A retry layer under this one multiplies the attempts and spends the total timeout outside it, where nothing counts it. The default transport AmpHttpClientFactory::create() builds makes one attempt; a transport you supply is yours to keep to one.

  • It must not carry credentials or a base URI of its own. Default options set on the transport are invisible here, so origin pinning cannot pin them. A transport that carries them answers for where they go.

  • It suspends, or it blocks. Only the Revolt-backed default suspends the calling Fiber. A synchronous Symfony transport — CurlHttpClient, NativeHttpClient — is accepted and blocks the process for the length of the request, so nothing else on the event loop runs meanwhile. The timeouts, retries, and ceiling still apply to it; the concurrency does not.

The transport on its own

AmpHttpClientFactory::create() returns the underlying Symfony\Contracts\HttpClient\HttpClientInterface — a thin factory around Symfony\Component\HttpClient\AmpHttpClient, backed by the current, Revolt-based amphp/http-client generation. Use it where a library wants to be handed a client of its own:

use Kinetis\RevoltHttpClient\AmpHttpClientFactory;

$client = AmpHttpClientFactory::create(
    defaultOptions: ['timeout' => 5],
    maxHostConnections: 10,
);

$response = $client->request('GET', 'https://example.com/');

It takes Symfony’s default request options, a client configurator, and connection limits (maxHostConnections defaults to 6). One request through it is one wire attempt: the Amp delegate is the connection pool itself, with no interceptor above it to repeat a failed request, where Symfony’s own AmpHttpClient default installs AMPHP’s RetryRequests. That leaves the retry decision with whoever can count it — withRetries() for Http, which is built on this same transport, or an SDK’s own retry policy for a client handed one. Pass a $clientConfigurator to build the delegate yourself, and the interceptors it installs are yours.

Warning

This is a plain Symfony client, not the boundary Http puts in front of one. Nothing else on this page applies to it. Symfony’s full option grammar, its streaming API, its own exception types, its redirect following (up to 20 redirects by default), and its own size and lifecycle behavior are what you get, and whatever you hand it is what it does: no preflight validation, no origin pinning, no owned retry layer, no total deadline, no response-byte ceiling.

Outside Kinetis

This package depends on nothing beyond symfony/http-client (and its symfony/http-client-contracts), amphp/http-client, and revolt/event-loop — no kinetis/framework, and no Kinetis-specific class anywhere in it. composer require kinetis/revolt-http-client in an unrelated project is a complete install, of Http and of the transport.

The transport is what an AsyncAws client accepts: every service client extends AsyncAws\Core\AbstractApi, whose constructor takes an optional ?HttpClientInterface $httpClient as its third argument:

use AsyncAws\S3\S3Client;
use Kinetis\RevoltHttpClient\AmpHttpClientFactory;

$s3 = new S3Client(['region' => 'us-east-1'], null, AmpHttpClientFactory::create());

Any other library that accepts an injectable HttpClientInterface — Symfony’s own components, or a third-party SDK built the same way — takes it the same way.

See also

  • HTTP Client — send a request, configure a client and handle failures.

  • Concurrency — overlapping requests with concurrently().

  • Telemetry — the tracing transport for outgoing requests.