---
title: 'The HTTP Client'
source: 'https://academia.sh/en/courses/php-web/http-client'
course: 'PHP on the Web: State, Data and Security'
language: en
updated: '2026-10-21T05:03:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Model the request and the response as objects made of a method, URL, headers, body and status code'
    - 'Separate the transport as a closure and show the timeout with an exception'
    - 'Measure how retry and a fallback value bound the result'
    - 'Show that the owner of the value reported by the service is the third party, and that a defense does not always change that owner'
---

# The HTTP Client

Builds an HTTP client from request and response objects, a transport closure, a timeout exception, retry and a fallback value; measures how the price reported by the service and these defenses change who owns the result.

In the previous lesson we parsed the text the partner sent. We did not show how that text reached us. This lesson handles the delivery, and says one thing at the start: this lesson does not go out to the network. The lesson code runs in an environment that does not connect to the network; for this reason a closure that takes the request and returns a response is used instead of a real service. The question is this: what does the client do if the service does not respond, and who determines the result?

The measuring axis is again the owner of the result. The decision order does not change: client, third party, environment; if none of them can change the result, the server. The dominant party of this lesson is the third party: the service we connect to. The core file is given verbatim.

```php
<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}
```

**DA21.** The service reports the price of a product as JSON; the price is `12.5` by default. **DA22.** The third party's deviation has two forms: the service reports the price as `15.5`, or it does not respond on the first call and a timeout occurs. **DA23.** The transport is a single closure: it takes a request object and returns a response object or throws a timeout exception. **DA24.** The timeout happens only on the first call; on the second call the service responds.

## The Request, the Response and the Transport

An HTTP request has four parts: a method, a URL, headers and a body. A real response has headers as well; in this lesson the response is modeled with only a status code and a body. We can model these with two small classes. The transport, that is the part that actually sends the request over a socket, is kept separate from the client and given as a closure. This separation has two benefits: the client logic can be tried without a network, and the client does not change when the transport changes.

```php
<?php
// http-client.php — request, response, timeout and the price client
declare(strict_types=1);

final class Request {
    public function __construct(
        public readonly string $method,
        public readonly string $url,
        public readonly array $headers = [],
        public readonly string $body = '',
    ) {}
}

final class Response {
    public function __construct(
        public readonly int $status,
        public readonly string $body,
    ) {}
}

final class TimeoutException extends RuntimeException {}

function buildService(?string $deviation): callable {
    $calls = 0;
    return function (Request $request) use ($deviation, &$calls): Response {
        $calls++;
        if ($deviation === 'timeout' && $calls === 1) {
            throw new TimeoutException('the service did not respond');
        }
        $price = $deviation === 'new' ? 15.5 : 12.5;
        return new Response(200, json_encode(['price' => $price]));
    };
}

function fetchPrice(callable $transport, int $attempts = 1, ?float $fallback = null): ?float {
    $request = new Request('GET', 'https://service.test/product/k1', ['Accept' => 'application/json']);
    for ($i = 0; $i < $attempts; $i++) {
        try {
            $response = $transport($request);
            return (float) json_decode($response->body, true, flags: JSON_THROW_ON_ERROR)['price'];
        } catch (TimeoutException) {
            continue;
        }
    }
    return $fallback;
}
```

The name `service.test` is only an example domain name; the closure never resolves it. The `TimeoutException` class carries the transport's "no response came" state as an exception. The `fetchPrice()` function builds the request, hands it to the transport closure, parses the response body and retries on a timeout; when the attempts run out it returns the fallback value. First look at the three basic cases.

```php
<?php
// basics.php — the request object and three calls
declare(strict_types=1);
require 'http-client.php';

$request = new Request('GET', 'https://service.test/product/k1', ['Accept' => 'application/json']);
echo $request->method, ' ', $request->url, "\n";
echo 'header: ', json_encode($request->headers), "\n";

$response = buildService(null)($request);
echo 'status: ', $response->status, ', body: ', $response->body, "\n";

var_dump(fetchPrice(buildService(null)));
var_dump(fetchPrice(buildService('timeout')));
var_dump(fetchPrice(buildService('timeout'), 2));
var_dump(fetchPrice(buildService('timeout'), 1, 10.0));
```

```
GET https://service.test/product/k1
header: {"Accept":"application\/json"}
status: 200, body: {"price":12.5}
float(12.5)
NULL
float(12.5)
float(10)
```

When the service was healthy, the price came as `12.5`. With a service that times out on the first call, a single attempt and no fallback value gives `NULL`; the price is not known. With two attempts the second call responded and the price arrived. With a single attempt and a fallback value, the price became `10.0`, which the service never said. Three defenses give three different results; which one is right is the application's decision.

## The Price the Service Reports

If the service reports the price as `15.5`, the client passes it on as it is. The client does not compute the price, it only carries it. In this case the result changes without a single line of the code changing.

```php
<?php
// price-result.php — whose is the price the service reports?
declare(strict_types=1);
require 'party.php';
require 'http-client.php';

partyReport('price', fn(array $d) => (string) fetchPrice(buildService($d['third party'] ?? null)), ['third party' => 'new']);
```

```
price → third party
    third party changed
```

It is the service that changed the price; the client, the environment and the server did not. The owner of a value taken from outside is the party that writes the value.

## The Bounding Measurement

When a timeout occurs there are two defenses: retrying and returning to a fallback value. Both bound the result, that is, they determine its worst case. The question is this: do these defenses change the owner of the result? Measure this with the same deviation, the service not responding on the first call, in three configurations.

```php
<?php
// defense.php — the owner of the result on a timeout
declare(strict_types=1);
require 'party.php';
require 'http-client.php';

function outcome(array $d, int $attempts, ?float $fallback): string {
    return var_export(fetchPrice(buildService($d['third party'] ?? null), $attempts, $fallback), true);
}

$deviation = ['third party' => 'timeout'];
partyReport('no defense', fn(array $d) => outcome($d, 1, null), $deviation);
partyReport('fallback value', fn(array $d) => outcome($d, 1, 10.0), $deviation);
partyReport('retry', fn(array $d) => outcome($d, 2, null), $deviation);
```

```
no defense → third party
    third party changed
fallback value → third party
    third party changed
retry → server
    third party could not change
```

In the client without a defense, the timeout made the result `NULL` and its owner is the third party. The fallback value put `10.0` in place of `NULL`; the result settled on a bound, but the service not responding still changed the result, so the owner is still the third party. The bound was written, the owner did not change. With retry, the second call gave a healthy response and the result came out the same as the measurement without a deviation; this defense moved the owner to the server. The two are defenses built against the same deviation, but their effects are different. A defense bounding the result and a defense changing the owner are two separate things: the fallback value did only the first.

A warning is needed: that retry moves the owner to the server depends only on the condition that the service responds on the second call. DA24 assumes this. If the service never responds, retry does not save the result either.

## Summary

- A request is made of a method, URL, headers and a body; a response is made of a status code and a body. When the transport is separated as a closure, the client can be tried without a network.
- A timeout is an exception; the client catches it and looks at the attempts left or the fallback value.
- The owner of the price the service reports is the third party; the client only carries the value.
- A fallback value bounds the result but does not change the owner; the owner measured in this lesson is still the third party.
- As long as the service responds on the second call, retry moves the owner to the server.

## Next Step

The client now receives the response, parses it and defends against a timeout. But when the value taken from the response enters the application, and the client's own inputs are taken into account, who decides whether these values are valid? The next topic starts with this question in the Input Validation and Sanitization lesson of Security.
