flytachi / winter-cast
PSR-18 HTTP client for PHP 8.4 — immutable, Swoole-aware: concurrent requests, streaming downloads and uploads, retries with deadlines, middleware.
Requires
- php: >=8.4
- composer-runtime-api: ^2.0
- ext-curl: *
- flytachi/winter-base: ^3.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- php-http/psr7-integration-tests: ^1.5
- phpunit/phpunit: @stable
- squizlabs/php_codesniffer: @stable
Suggests
- ext-swoole: Coroutine-based sendAll() inside Swoole workers
Provides
Conflicts
None
Replaces
None
README
The HTTP client of the Winter framework: PSR-7/17/18, built on cURL, and at home in both PHP-FPM and Swoole.
$api = CastClient::create() ->withBaseUrl('https://api.example.com/v1') ->withBearerToken($token) ->withRetry(RetryPolicy::exponential()); $user = $api->get('users/42')->send()->throw()->json();
Highlights
- Standard. Messages are PSR-7, factories PSR-17, the client PSR-18. Hand it to any library
that takes a
ClientInterface. - Immutable. A configured client is a value: keep it as a bean and share it between coroutines.
- Concurrent.
sendAll()runs requests side by side, on Swoole coroutines or on Fibers withcurl_multi, and the calling code is the same either way. - Streams. Download straight into a file with
sinkTo(), upload files without loading them, and follow both withonProgress(). - Careful by default.
- Retries are opt-in, idempotent-only, and respect
Retry-After. - Timeouts apply per attempt, with an optional deadline over the whole operation.
- Credentials are not carried across hosts on redirects, and
httpsis never downgraded. - Header injection is refused.
- The size of a response body kept in memory is capped.
- Retries are opt-in, idempotent-only, and respect
- Testable.
MockTransportanswers from a queue and records what was sent.
Installation
composer require flytachi/winter-cast
Requirements: PHP 8.4+, ext-curl. ext-swoole is optional (see Swoole).
Contents
- Quick start
- The client
- Requests
- Responses
- Errors
- Timeouts
- Retries
- Redirects
- Downloads and uploads
- Concurrent requests
- Middleware
- Logging
- Winter beans
- Testing your code
- PSR-18 interop
- Swoole
- Security
- Upgrading from 1.x
Quick start
For a script or a quick check, use the Cast facade:
use Flytachi\Winter\Cast\Cast; $ip = Cast::get('https://api.ipify.org', ['format' => 'json'])->send()->json()['ip']; $created = Cast::post('https://httpbin.org/post') ->withJsonBody(['name' => 'winter']) ->send();
In an application, configure a CastClient once and reuse it:
use Flytachi\Winter\Cast\Client\CastClient; use Flytachi\Winter\Cast\Client\RetryPolicy; $github = CastClient::create() ->withBaseUrl('https://api.github.com') ->withBearerToken($token) ->withAccept('application/vnd.github+json') ->withUserAgent('my-bot/1.0') ->withTimeout(10) ->withRetry(RetryPolicy::exponential(3)->withStatuses(502, 503, 504)); $repo = $github->get('repos/flytachi/winter-cast')->send()->throw()->json(); $issues = $github->get('repos/flytachi/winter-cast/issues', ['state' => 'open'])->send()->json();
The client
CastClient is immutable: every with*() returns a new client, and copies share one
connection pool.
| Method | Sets |
|---|---|
withBaseUrl($url) |
the address relative paths are appended to |
withHeader($name, $value), withHeaders([...]), withAddedHeader(), withoutHeader() |
default headers (withHeaders() adds; a header of the same name is replaced) |
withBearerToken($token), withBasicAuth($user, $password), withAccept(...$types) |
shortcuts for common headers |
withUserAgent($agent, exact: false) |
the application's User-Agent |
withTimeout($total, $connect = null), withTimeouts(Timeouts) |
timeouts |
withRetry(RetryPolicy) |
retries |
withRedirects(Redirects) |
redirects |
withConcurrency($limit) |
how many requests sendAll() runs at once (16) |
withMaxResponseSize($bytes) |
the largest body kept in memory (10 MiB; null — no limit) |
withThrowOnError() |
send() throws on 4xx/5xx |
withMiddleware(...$middleware), withoutMiddleware($class) |
middleware |
withCurlOptions([...]) |
raw cURL options: proxy, TLS… |
withLogger(LoggerInterface) |
logging |
withTransport(Transport) |
another transport, e.g. MockTransport in tests |
Defaults: connect timeout 5 s, 30 s per attempt, no retries, up to 10 redirects, 10 MiB in memory, HTTP/2 over TLS when the server offers it.
The base URL is appended to
A relative path is appended to the base URL. This is not RFC 3986 resolution, which drops the last segment of the base:
| Base | Path | Request |
|---|---|---|
https://api.com |
users/42 |
https://api.com/users/42 |
https://api.com/v1 |
users |
https://api.com/v1/users |
https://api.com/v1/ |
/users |
https://api.com/v1/users |
https://api.com/v1?key=1 |
users?page=2 |
https://api.com/v1/users?key=1&page=2 |
An absolute URL in a request is used as it is.
Settings: request over client
Defaults, then the client's settings, then the request's own. A header set on the request wins
over the client's header of the same name, and withTimeout() on a request wins over the
client's.
Requests
$client->get(), post(), put(), patch(), delete(), head(), options() and
request($method, $uri) create a CastRequest bound to the client; ->send() sends it
through that client. Requests are immutable too.
$response = $client->post('orders', ['dry_run' => 1]) // query parameters ->withHeader('Idempotency-Key', $key) ->withJsonBody(['sku' => 'A-1', 'qty' => 2]) ->withTimeout(5) ->send();
Headers:
withHeader(),withHeaders([...])(adds),withAddedHeader(),withoutHeader();replaceHeaders([...])replaces all of them;- shortcuts
withBearerToken(),withBasicAuth(),withAccept(),withUserAgent().
Names are case-insensitive.
Query: withQuery(['page' => 2]) merges parameters into the query; withQueryParam('page', 2) sets one.
Body:
| Method | Sends |
|---|---|
withJsonBody($data) |
JSON, Content-Type: application/json |
withFormBody([...]) |
application/x-www-form-urlencoded |
withMultipartBody(Multipart|array) |
multipart/form-data (files are streamed) |
withStringBody($body, $contentType = null) |
a string |
withFileBody($path, $contentType = null) |
a file, streamed from disk |
withBody(StreamInterface) |
any PSR-7 stream |
This send only (each overrides the client's setting):
withTimeout(),withTimeouts();withRetry(),withoutRetry(),withRedirects();withMaxResponseSize(),withThrowOnError(),withCurlOptions();sinkTo(),onProgress().
Sending:
send()uses the bound client, or the facade's default one;sendVia(ClientInterface)uses any PSR-18 client.
Responses
CastResponse is a PSR-7 response with shortcuts:
$response->getStatusCode(); // 200 $response->status(); // HttpCode::OK (winter-base) $response->isSuccess(); // 2xx — also isRedirect(), isClientError(), isServerError() $response->header('ETag'); // one line, or null when absent $response->body(); // the whole body as a string $response->json(); // array, or null when the body is not a JSON object/array $response->jsonOrFail(); // array, or CastException $response->throw(); // HttpException on 4xx/5xx, otherwise the response itself $response->info(); // TransferInfo: times, attempts, redirects, IP, protocol
Compressed responses (gzip, deflate, br) are decoded for you. Their Content-Encoding and
Content-Length describe the bytes on the wire, not the body you get. They are moved to
X-Encoded-Content-Encoding and X-Encoded-Content-Length.
info() returns totalTime, connectTime, startTransferTime, effectiveUri, redirects,
attempts, primaryIp, downloadedBytes, uploadedBytes and protocol.
Errors
A 4xx or 5xx is a response, not an exception. That is the PSR-18 rule. You choose where it becomes one:
$client->get('report')->send()->throw()->json(); // per call $client->withThrowOnError(); // for every send() of this client
sendRequest() (PSR-18) never throws on a status.
CastException root — implements Psr\Http\Client\ClientExceptionInterface
├── RequestException cannot be sent: not http(s), no base URL, too many redirects
├── NetworkException no response: DNS, connection, TLS, dropped
│ └── TimeoutException isConnect(), isDeadline()
├── TooLargeException body over withMaxResponseSize(); getLimit()
├── HttpException 4xx/5xx from throw() / withThrowOnError(); getResponse()
│ ├── ClientErrorException 4xx
│ └── ServerErrorException 5xx
└── BatchException sendAll(): some requests got no response
InvalidArgumentException a bad argument while building: a header with CR/LF,
an invalid URI or method, unencodable JSON, a reserved
cURL option (also a ClientExceptionInterface)
use Flytachi\Winter\Cast\Exception\{ClientErrorException, NetworkException, TimeoutException}; try { $data = $client->get('report')->send()->throw()->json(); } catch (ClientErrorException $e) { $problem = $e->getResponse()->json(); // the error body } catch (TimeoutException $e) { // $e->isConnect() — the connection was not established in time } catch (NetworkException $e) { // $e->isTransient() — a retry may succeed; $e->getCurlErrno() for diagnostics }
Timeouts
use Flytachi\Winter\Cast\Client\Timeouts; $client->withTimeout(10); // 10 s per attempt, connect stays 5 s $client->withTimeout(10, connect: 2); $client->withTimeouts(Timeouts::of(total: 10)->withDeadline(25));
| Setting | Limits | Default |
|---|---|---|
connect |
establishing the connection | 5 s |
total |
one attempt, start to finish | 30 s |
idle |
a transfer that stalls: slower than N bytes/s for that long | off |
deadline |
the whole operation, retries and pauses included | off |
For large downloads Timeouts::forDownload(idle: 60, minBytesPerSecond: 1024) drops the total
limit and keeps the stall limit: a slow but moving download completes, a hung one fails.
Retries
Retries are off until you ask for them:
use Flytachi\Winter\Cast\Client\RetryPolicy; $client->withRetry(RetryPolicy::exponential(attempts: 3, delayMs: 500)); // 500 ms, 1 s (±30%) $client->withRetry(RetryPolicy::fixed(5, 200)->withStatuses(429, 503));
- Methods: only idempotent ones (
GET,HEAD,PUT,DELETE,OPTIONS,TRACE) are retried.withNonIdempotent()addsPOST/PATCH;withMethods(...)sets the list. - Failures: transient network errors (could not connect, timed out, connection reset, empty
reply) are retried. Statuses are retried only when listed with
withStatuses(). - Pauses:
Retry-After(seconds or a date) is respected, capped bywithMaxDelay()(30 s). A pause never blocks other requests ofsendAll(), nor other coroutines. - What repeats: middleware runs once, and the attempts happen inside it. The body is rewound
and a
sinkTo()target is reset before each attempt. A body or sink that cannot be rewound is not retried. - Counting:
$response->info()->attemptshas the count.
Redirects
Redirects are followed by default, up to 10:
303becomesGET, and so do301/302after aPOST;307/308keep the method and body;Authorization,CookieandProxy-Authorizationare dropped when the host changes;https→httpis not followed unlessRedirects::follow(allowDowngrade: true).
Redirects::none() returns the 3xx as it is.
Downloads and uploads
use Flytachi\Winter\Cast\Client\Timeouts; use Flytachi\Winter\Cast\Message\TransferProgress; $response = $client->get('backups/latest') ->sinkTo('/var/backups/db.sql.gz') ->withTimeouts(Timeouts::forDownload()) ->onProgress(fn(TransferProgress $p) => printf("\r%.1f%%", $p->downloadPercent() ?? 0)) ->send() ->throw();
- Only 2xx is written to the target. An error response stays in memory, so you can read
->json(), and the file is not touched. - The size limit does not apply to a sink.
- Targets:
- a path is created or truncated, and removed if the transfer fails;
- an open resource or a PSR-7 stream is written from its current position. That is how
resuming works: open the file for appending and send a
Rangeheader.
- Uploads:
withFileBody($path),withBody(CastStream::fromFile($path))and file parts ofwithMultipartBody()are streamed from disk.
use Flytachi\Winter\Cast\Message\Multipart; $client->post('avatars')->withMultipartBody( Multipart::create() ->withField('user', '42') ->withFile('image', '/tmp/me.png', 'me.png', 'image/png'), )->send(); // or as an array: ['user' => '42', 'image' => new CURLFile('/tmp/me.png')]
Parts that share a name (files[] twice) are sent too. cURL cannot take them as fields, so such
a body is rendered and sent as a stream.
Concurrent requests
$responses = $client->sendAll([ 'user' => $client->get('users/42'), 'orders' => $client->get('orders', ['user' => 42]), 'stats' => $client->get('stats/42'), ]); $responses['orders']->json();
- Results: responses come back with the requests' keys, in their order. Every request passes the whole chain: middleware, retries, redirects, sink.
- Statuses: a 4xx/5xx is a response in the result. If some requests got no response,
BatchExceptionis thrown after all have finished, withresponses()andfailures(). - Generators:
$requestsmay be a generator; it is read as sending goes on. - Concurrency: at most
withConcurrency()requests (16) run at once, or what you pass as the second argument. - How it runs: inside a Swoole coroutine each request gets a coroutine. Elsewhere (FPM,
CLI) each runs in a Fiber over one shared
curl_multi, so the total time is that of the slowest request.
Middleware
A middleware sees every request of the client and its response:
use Flytachi\Winter\Cast\Message\{CastRequest, CastResponse}; use Flytachi\Winter\Cast\Middleware\CastMiddleware; final class TimingMiddleware implements CastMiddleware { public function __construct(private Metrics $metrics) {} public function process(CastRequest $request, callable $next): CastResponse { $response = $next($request); $this->metrics->observe($request->getUri()->getHost(), $response->info()->totalTime); return $response; } } $client = $client->withMiddleware(new TimingMiddleware($metrics)); // a closure works too $client = $client->withMiddleware( fn(CastRequest $request, callable $next) => $next($request->withHeader('X-Request-ID', $id)), );
The first one added runs first (outermost). Middleware runs once per send; retries happen inside it, closer to the network.
Built-in:
| Middleware | Does |
|---|---|
AuthMiddleware::bearer(string|callable $token) |
Authorization: Bearer …; a function is asked on every request |
AuthMiddleware::basic($user, $password) |
Authorization: Basic … |
AuthMiddleware::header($name, string|callable $value) |
a key in a header of its own |
new HeadersMiddleware(array|callable $headers) |
headers computed per request (X-Request-ID) |
new RefreshTokenMiddleware(callable $refresh, $maxRetries = 1) |
on 401, gets a new token and sends the request again |
$client = $client ->withMiddleware(new RefreshTokenMiddleware(fn() => $tokens->refresh())) ->withMiddleware(AuthMiddleware::bearer(fn() => $tokens->current()));
Constant headers need no middleware: use the client's withHeaders().
Logging
$client = $client->withLogger($logger); // any PSR-3 logger
debug: one line per request, e.g.GET https://api.example.com/v1/users → 200 (84 ms, 2 attempts).warning: one line per retry.error: a failure after the last attempt.
The logger never sees headers, bodies or query strings. Placeholders are filled in by the client, so loggers that do not interpolate print complete lines.
Winter beans
use Flytachi\Winter\Cast\Client\CastClient; use Flytachi\Winter\Kernel\App\Attribute\{Bean, Configuration, Value}; use Flytachi\Winter\Logger\LoggerFactory; #[Configuration] final class HttpConfiguration { #[Bean(name: 'github')] public function github(#[Value('GITHUB_TOKEN')] string $token): CastClient { return CastClient::create() ->withBaseUrl('https://api.github.com') ->withBearerToken($token) ->withLogger(LoggerFactory::getLogger('github')); } }
An API client of your own needs no base class. Wrap a CastClient:
final class WeatherApi { public function __construct(private CastClient $http) {} public static function create(string $key): self { return new self(CastClient::create() ->withBaseUrl('https://api.weather.example/v2') ->withHeader('X-Api-Key', $key)); } public function today(string $city): array { return $this->http->get('today', ['city' => $city])->send()->throw()->jsonOrFail(); } }
Testing your code
use Flytachi\Winter\Cast\Message\{CastRequest, CastResponse}; use Flytachi\Winter\Cast\Transport\MockTransport; $mock = new MockTransport( CastResponse::fromJson(['id' => 1], 201), fn(CastRequest $request) => CastResponse::fromString('', 204), MockTransport::fail(), // a dropped connection ); $client = CastClient::create()->withBaseUrl('https://api.test')->withTransport($mock); (new OrderService($client))->create(['sku' => 'A-1']); $this->assertSame('POST', $mock->lastRequest()->getMethod()); $this->assertSame('{"sku":"A-1"}', (string) $mock->lastRequest()->getBody());
Everything except the network stays real: the base URL, headers, middleware, retries, redirects
and throwOnError.
To check what was sent and how much of the queue was used, read requests() and remaining().
PSR-18 interop
use Flytachi\Winter\Cast\Factory\CastFactory; $factory = new CastFactory(); // all four PSR-17 factories $sdk = new SomeVendorSdk( httpClient: $client, // Psr\Http\Client\ClientInterface requestFactory: $factory, streamFactory: $factory, );
The vendor's requests go through your middleware, logging, retries and User-Agent. Foreign PSR-7 requests are accepted everywhere.
Swoole
Nothing to configure. With Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL), which winter-kernel
enables, and Swoole built with curl-native:
- cURL calls yield instead of blocking;
sendAll()uses coroutines;- retry pauses yield to other coroutines.
One client can be shared by any number of coroutines: it is immutable, and each transfer takes a connection from the pool for itself.
Security
- TLS: verification is always on. Disabling it takes an explicit
withCurlOptions()and is meant for development only. - Header injection: CR, LF or NUL in a header name or value is refused.
- Redirects: credentials stay on their origin, and
httpsis never downgraded. - Protocols: only
httpandhttpscan be sent. - Reserved cURL options: options the library relies on (URL, method, headers, body,
callbacks, redirects) cannot be overridden with
withCurlOptions(); the error names the method to use instead. - Logging: the logger never sees headers, bodies or query strings.
var_dump(): dumps of clients and requests maskAuthorization,Proxy-Authorization,CookieandX-Api-Key.
Upgrading from 1.x
2.0 is a new API and is not compatible with 1.x. See CHANGELOG.md for the mapping from old calls to new ones.
License
MIT — see LICENSE.