Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 66 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Generic `Request<TResponse>` ensures `$client->send(request: $request)` returns
- **Fiber-compatible** — synchronous API that transparently supports async via AMP/Revolt fibers
- **Flexible hydration** — default `ResponseHydrator` on the client, per-request override via `HasResponseHydrator`
- **Typed error handling** — map `4xx/5xx` responses into typed exceptions with reusable error handlers
- **Per-request timeouts** — set total and connect timeouts globally, per client, or per endpoint

## Installation

Expand Down Expand Up @@ -327,6 +328,60 @@ try {
}
```

## Timeouts

Timeouts travel with the request as a `TransportOptions` value. `null` means "not set here" and never overwrites a value set earlier, so precedence is always the same: **request > client > global default**.

```php
use Zlodes\Http\Client\Factory\Option\WithTimeout;

// Global default, applied to every client from this factory
$factory = new ClientFactory(defaults: [
new WithTransport($transport),
new WithResponseHydrator($hydrator),
new WithTimeout(timeout: 10, connectTimeout: 3),
]);

// Per client: overrides only the values it sets
$slowClient = $factory->make(
new WithTimeout(timeout: 60),
);
```

For a single endpoint, implement `HasTransportOptions`. Those values win over everything else:

```php
use Zlodes\Http\Client\Contract\HasTransportOptions;
use Zlodes\Http\Client\TransportOptions;

final readonly class SlowReportRequest implements RequestContract, HasTransportOptions
{
public function getTransportOptions(): TransportOptions
{
return new TransportOptions(timeout: 120);
}

// ... getName(), buildRequest(), getResponseClass()
}
```

`WithTimeout` only adds a `TimeoutMiddleware` to the pipeline, so the same behaviour is available without the factory:

```php
use Zlodes\Http\Client\Middleware\TimeoutMiddleware;

$client = new HttpClient(
transport: $transport,
responseHydrator: $responseHydrator,
middlewares: [new TimeoutMiddleware(timeout: 10, connectTimeout: 3)],
);
```

Which transport actually applies the options:

- `GuzzleTransport` maps them to Guzzle's `timeout` and `connect_timeout`, and sends with `http_errors` disabled. A cURL timeout (error 28) is thrown as `TransportTimeoutException`, which extends `TransportException`, so a timeout is distinguishable from a refused connection.
- `Psr18Transport` ignores them. PSR-18 has no per-request options, so configure timeouts on the wrapped client instead.

## Middleware

Middleware follows an onion model. Each middleware receives a `RequestContext` and a `RequestHandler $next`:
Expand Down Expand Up @@ -485,13 +540,14 @@ $billingClient = $factory->make(

### Available options

| Option | Behavior |
|------------------------------------------------------|---------------------------------------|
| `WithTransport(Transport)` | Sets the transport (last-writer-wins) |
| `WithResponseHydrator(ResponseHydrator)` | Sets the hydrator (last-writer-wins) |
| `WithMiddleware(Middleware ...)` | Appends middlewares (additive) |
| `WithErrorResponseHandler(ErrorResponseHandler ...)` | Appends error handlers (additive) |
| `WithBaseUri(UriInterface)` | Sets the base URI (last-writer-wins) |
| Option | Behavior |
|------------------------------------------------------|------------------------------------------|
| `WithTransport(Transport)` | Sets the transport (last-writer-wins) |
| `WithResponseHydrator(ResponseHydrator)` | Sets the hydrator (last-writer-wins) |
| `WithMiddleware(Middleware ...)` | Appends middlewares (additive) |
| `WithErrorResponseHandler(ErrorResponseHandler ...)` | Appends error handlers (additive) |
| `WithBaseUri(UriInterface)` | Sets the base URI (last-writer-wins) |
| `WithTimeout(?timeout, ?connectTimeout)` | Appends a `TimeoutMiddleware` (additive) |

Defaults are applied first, then `make()` options on top. Last-writer-wins options can be overridden per client; additive options accumulate across defaults and `make()` calls.

Expand Down Expand Up @@ -519,11 +575,12 @@ $factory = new ClientFactory(

```
HttpClient::send(Request<T>)
├── builds RequestContext (PSR-7 request + name + factory)
├── builds RequestContext (PSR-7 request + name + factory + TransportOptions)
├── MiddlewarePipeline (onion chain)
│ ├── Middleware 1
│ ├── Middleware 2
│ └── Transport::send() (innermost)
│ ├── request-level TransportOptions (innermost, wins)
│ └── Transport::send(request, options)
└── ResponseHydrator::hydrate() → T
```

Expand Down
56 changes: 56 additions & 0 deletions UPGRADE-1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Upgrading to 1.0

## Breaking change: `Transport::send()` takes options

```php
// Before
public function send(RequestInterface $request): ResponseInterface;

// After
public function send(RequestInterface $request, TransportOptions $options): ResponseInterface;
```

Every `Transport` implementation must accept the second argument. Apply the options you support and document the ones you ignore.

`TransportOptions` holds `timeout` and `connectTimeout`, in seconds, both nullable. `null` means "not set" — leave the transport's own default in place. It never means "no limit".

```php
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zlodes\Http\Client\Contract\Transport;
use Zlodes\Http\Client\TransportOptions;

final readonly class MyTransport implements Transport
{
public function send(RequestInterface $request, TransportOptions $options): ResponseInterface
{
$timeout = $options->timeout ?? 30;

// ...
}
}
```

## Test doubles

Anonymous transports and mocks need the new parameter. Closures passed to `withArgs()` keep working, because an extra argument is ignored. A single `with()` matcher needs a second one:

```php
// Before
$transport->shouldReceive('send')->once()->with(Mockery::on($matcher));

// After
$transport->shouldReceive('send')->once()->with(Mockery::on($matcher), Mockery::any());
```

## `RequestContext`

`RequestContext` now carries `transportOptions`. The constructor parameter is optional and defaults to nothing set, so existing `new RequestContext(...)` calls keep working. `withHttpRequest()` and `withFreshHttpRequest()` keep the options, so a retry or token refresh does not drop the timeout.

## Timeouts

Set them with `WithTimeout` on a `ClientFactory`, with `TimeoutMiddleware` directly, or per endpoint by implementing `HasTransportOptions`. Precedence is request > client > global default.

`Psr18Transport` ignores `TransportOptions`. PSR-18 has no per-request options; configure timeouts on the wrapped client.

`GuzzleTransport` applies them. It requires `guzzlehttp/guzzle`, which this library now depends on.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "library",
"require": {
"php": "^8.4",
"guzzlehttp/guzzle": "^7.9",
"psr/http-client": "^1.0",
"psr/http-message": "^2.0"
},
Expand Down
12 changes: 12 additions & 0 deletions src/Contract/HasTransportOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Zlodes\Http\Client\Contract;

use Zlodes\Http\Client\TransportOptions;

interface HasTransportOptions
{
public function getTransportOptions(): TransportOptions;
}
5 changes: 4 additions & 1 deletion src/Contract/Transport.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zlodes\Http\Client\Exception\TransportException;
use Zlodes\Http\Client\TransportOptions;

interface Transport
{
/**
* Implementations MUST apply every option they support and MUST document the ones they ignore.
*
* @throws TransportException
*/
public function send(RequestInterface $request): ResponseInterface;
public function send(RequestInterface $request, TransportOptions $options): ResponseInterface;
}
9 changes: 9 additions & 0 deletions src/Exception/TransportTimeoutException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Zlodes\Http\Client\Exception;

class TransportTimeoutException extends TransportException
{
}
27 changes: 27 additions & 0 deletions src/Factory/Option/WithTimeout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Zlodes\Http\Client\Factory\Option;

use Zlodes\Http\Client\Factory\HttpClientConfig;
use Zlodes\Http\Client\Factory\Option;
use Zlodes\Http\Client\Middleware\TimeoutMiddleware;

final readonly class WithTimeout implements Option
{
/**
* @param positive-int|float|null $timeout
* @param positive-int|float|null $connectTimeout
*/
public function __construct(
private int|float|null $timeout = null,
private int|float|null $connectTimeout = null,
) {
}

public function apply(HttpClientConfig $config): void
{
$config->middlewares[] = new TimeoutMiddleware($this->timeout, $this->connectTimeout);
}
}
18 changes: 17 additions & 1 deletion src/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Zlodes\Http\Client\Contract\ErrorResponseHandler;
use Zlodes\Http\Client\Contract\HasErrorResponseHandlers;
use Zlodes\Http\Client\Contract\HasResponseHydrator;
use Zlodes\Http\Client\Contract\HasTransportOptions;
use Zlodes\Http\Client\Contract\Middleware;
use Zlodes\Http\Client\Contract\Request;
use Zlodes\Http\Client\Contract\Response;
Expand All @@ -20,6 +21,7 @@
use Zlodes\Http\Client\Exception\HttpClientException;
use Zlodes\Http\Client\Exception\HttpErrorException;
use Zlodes\Http\Client\Exception\HydrationException;
use Zlodes\Http\Client\Middleware\RequestTransportOptionsMiddleware;

final readonly class HttpClient implements Client
{
Expand Down Expand Up @@ -58,7 +60,7 @@ public function send(Request $request): Response
requestFactory: fn(): RequestInterface => $this->applyBaseUri($request->buildRequest()),
);

$httpResponse = $this->pipeline->handle($context);
$httpResponse = $this->pipeline->handle($context, $this->requestOptionsStep($request));

if ($httpResponse->getStatusCode() >= 400) {
$this->throwForErrorResponse($httpResponse, $request);
Expand Down Expand Up @@ -145,6 +147,20 @@ private function throwForErrorResponse(ResponseInterface $response, Request $req
throw new HttpErrorException($request, $response);
}

/**
* Innermost pipeline step, so request options win over every middleware.
*
* @param Request<Response> $request
*/
private function requestOptionsStep(Request $request): ?Middleware
{
if (! $request instanceof HasTransportOptions) {
return null;
}

return new RequestTransportOptionsMiddleware($request->getTransportOptions());
}

private function applyBaseUri(RequestInterface $httpRequest): RequestInterface
{
if ($this->baseUri === null) {
Expand Down
27 changes: 27 additions & 0 deletions src/Middleware/RequestTransportOptionsMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Zlodes\Http\Client\Middleware;

use Psr\Http\Message\ResponseInterface;
use Zlodes\Http\Client\Contract\Middleware;
use Zlodes\Http\Client\Contract\RequestHandler;
use Zlodes\Http\Client\RequestContext;
use Zlodes\Http\Client\TransportOptions;

/** @internal */
final readonly class RequestTransportOptionsMiddleware implements Middleware
{
public function __construct(
private TransportOptions $options,
) {
}

public function process(RequestContext $context, RequestHandler $next): ResponseInterface
{
return $next->handle($context->withTransportOptions(
$context->transportOptions->merge($this->options),
));
}
}
31 changes: 31 additions & 0 deletions src/Middleware/TimeoutMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Zlodes\Http\Client\Middleware;

use Psr\Http\Message\ResponseInterface;
use Zlodes\Http\Client\Contract\Middleware;
use Zlodes\Http\Client\Contract\RequestHandler;
use Zlodes\Http\Client\RequestContext;
use Zlodes\Http\Client\TransportOptions;

final readonly class TimeoutMiddleware implements Middleware
{
/**
* @param positive-int|float|null $timeout
* @param positive-int|float|null $connectTimeout
*/
public function __construct(
private int|float|null $timeout = null,
private int|float|null $connectTimeout = null,
) {
}

public function process(RequestContext $context, RequestHandler $next): ResponseInterface
{
return $next->handle($context->withTransportOptions(
$context->transportOptions->merge(new TransportOptions($this->timeout, $this->connectTimeout)),
));
}
}
6 changes: 5 additions & 1 deletion src/MiddlewarePipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ public function __construct(
) {
}

public function handle(RequestContext $context): ResponseInterface
public function handle(RequestContext $context, ?Middleware $innermost = null): ResponseInterface
{
$handler = new TransportHandler($this->transport);

if ($innermost !== null) {
$handler = new MiddlewareHandler($innermost, $handler);
}

foreach (array_reverse($this->middlewares) as $middleware) {
$handler = new MiddlewareHandler($middleware, $handler);
}
Expand Down
10 changes: 8 additions & 2 deletions src/RequestContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@ public function __construct(
public RequestInterface $httpRequest,
public string $requestName,
public Closure $requestFactory,
public TransportOptions $transportOptions = new TransportOptions(),
) {
}

public function withHttpRequest(RequestInterface $httpRequest): self
{
return new self($httpRequest, $this->requestName, $this->requestFactory);
return new self($httpRequest, $this->requestName, $this->requestFactory, $this->transportOptions);
}

public function withFreshHttpRequest(): self
{
return new self(($this->requestFactory)(), $this->requestName, $this->requestFactory);
return new self(($this->requestFactory)(), $this->requestName, $this->requestFactory, $this->transportOptions);
}

public function withTransportOptions(TransportOptions $options): self
{
return new self($this->httpRequest, $this->requestName, $this->requestFactory, $options);
}
}
Loading
Loading