Calling Another API

cURL and stream contexts, a timeout on every call, checking the status before the body, and retrying only what is safe.

Syntaxcurl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10]);

Sometimes your script is the client. It asks another server for data and waits. Plain PHP offers two ways to do that, and neither puts a timeout on the call for you.

cURL

cURL is a bundled extension for making HTTP requests. curl_init() returns a CurlHandle object (it was a resource before PHP 8.0), curl_setopt_array() configures it, and curl_exec() performs the request. Set CURLOPT_RETURNTRANSFER => true or the response is printed into your own output instead of being returned.

A timeout on every call

CURLOPT_TIMEOUT defaults to 0, which means no limit at all, and CURLOPT_CONNECTTIMEOUT defaults to 300 seconds. Without both, one slow third party holds your PHP worker until the web server gives up, and a handful of those take the whole site down. Set them on every call. The stream equivalent is the timeout context option, which falls back to the default_socket_timeout setting of 60 seconds.

Check the status before the body

curl_exec() returns false only when the request never completed. A 404 page or a 500 error is a successful transfer, so it comes back as a string. Check curl_errno(), then curl_getinfo($ch, CURLINFO_RESPONSE_CODE), and only then decode.

Retry only what is safe

Repeating a GET, HEAD, PUT or DELETE has the same effect as calling it once. Repeating a POST can create a second order. Retry on a timeout, a 429 or a 5xx, wait longer between attempts, and honour a Retry-After header when there is one.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Look up a postcode or a VAT number against a government service during checkout.
  • Push a new customer into a mailing list provider after they sign up.
  • Pull yesterday's figures from a reporting API in a nightly cron script.
  • Verify a payment with the provider rather than trusting the browser's redirect.

More examples

One GET function you can trust

The shape to copy: timeouts first, then the transport check, then the status check, then the decode. Wrapping the three failure modes in one exception type means a caller writes a single catch instead of inspecting three different return values. SSL_VERIFYPEER appears because turning it off is the usual answer found online for a certificate error, and it removes the only guarantee that you are talking to the server you named.

Example · php
<?php

declare(strict_types=1);

final class HttpError extends RuntimeException
{
    public function __construct(string $message, public readonly ?int $status = null)
    {
        parent::__construct($message);
    }
}

/**
 * @param array<string, string> $query
 * @return array<string, mixed>
 */
function getJson(string $url, array $query = [], int $timeout = 10): array
{
    if ($query !== []) {
        $url .= '?' . http_build_query($query);
    }

    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // return the body, do not print it
        CURLOPT_CONNECTTIMEOUT => 5,      // default is 300 seconds
        CURLOPT_TIMEOUT => $timeout,      // default is 0, meaning no limit
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
        CURLOPT_SSL_VERIFYPEER => true,   // never turn this off to fix an error
    ]);

    $body = curl_exec($ch);

    if ($body === false) {
        throw new HttpError('Request to ' . $url . ' failed: ' . curl_error($ch));
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    if ($status >= 400) {
        throw new HttpError('Upstream answered ' . $status, $status);
    }

    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}

The same call without cURL

Useful when cURL is not compiled in, or for a one-off script where a handle feels heavy. Two traps come with it. Without ignore_errors a 404 makes file_get_contents emit a warning and return false, so you lose the error body the API sent you. And the status arrives only in the magic $http_response_header variable, which exists in the scope of the call and nowhere else.

Example · php
<?php

declare(strict_types=1);

$payload = json_encode(['email' => '[email protected]'], JSON_THROW_ON_ERROR);

$context = stream_context_create([
    'http' => [
        'method' => 'POST',
        'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
        'content' => $payload,
        'timeout' => 10,          // otherwise default_socket_timeout, 60s
        'ignore_errors' => true,  // without this, a 4xx returns false and warns
    ],
]);

$body = file_get_contents('https://api.example.com/v1/subscribers', false, $context);

if ($body === false) {
    throw new RuntimeException('The request could not be sent.');
}

// $http_response_header is set by the request above. The first line is the
// status line, for example "HTTP/1.1 201 Created".
$status = (int) (explode(' ', $http_response_header[0])[1] ?? 0);

if ($status >= 400) {
    throw new RuntimeException('Upstream answered ' . $status);
}

$data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

A retry loop that does not create duplicates

The key is generated once, before the first attempt, and reused for every retry of that one logical charge. Generate it inside the loop and you have rebuilt the duplicate-charge bug you were avoiding. Cap the attempts too: an uncapped retry loop against a struggling upstream is how a small outage becomes your outage.

Example · php
<?php

declare(strict_types=1);

/**
 * POST is not repeatable on its own. An idempotency key makes it so: the
 * server records the key with the result, and a repeat of the same key
 * returns that first result instead of charging the card again.
 * Most payment APIs support this; check the provider's documentation.
 */
function chargeWithRetries(string $url, string $json, string $idempotencyKey, string $token): array
{
    $attempt = 0;

    while (true) {
        $attempt++;

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 15,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $token,
                'Idempotency-Key: ' . $idempotencyKey,
            ],
        ]);

        $body = curl_exec($ch);
        $status = $body === false ? null : curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

        if ($status !== null && $status < 400) {
            return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
        }

        $worthRetrying = $status === null || $status === 429 || $status >= 500;

        if (!$worthRetrying || $attempt >= 3) {
            throw new RuntimeException('Charge failed with status ' . ($status ?? 'none'));
        }

        error_log("charge attempt {$attempt} got " . ($status ?? 'timeout') . ', retrying');
        sleep(2 ** ($attempt - 1));   // 1s, then 2s
    }
}

Discussion

  • Be the first to comment on this lesson.