PHP SOAP Client with timeout

Introduction

PHP has a built-in SoapClient, which works quite well for SOAP requests. SOAP is a protocol that can be used to do remote method calls, for example to request address information from Webservices.nl.

As most webservices give a fairly quick response, the user does not have to wait very long for the application to react. However, this may become different when the SOAP webservice fails. For example, if the webserver serving the WSDL is down, the SoapClient constructor will block for a minute and only then throw a SoapFault. This article will explain common problems and how to reduce the annoyance to the user.

Connect timeout

When the host serving the webservice is down, this can result in a connect timeout. This happens when the SoapClient tries to connect to a port at which no webserver is running. In most cases, the remote host will immediately return an error response. In some cases, however, the SoapClient will wait for a response until the socket timeout expires.

There are two settings which influence the timeout of SoapClient:

The SoapClient option connection_timeout, which is passed to the constructor, only influences the connect timeouts on requests. It does not influence the timeout which is used when retrieving the WSDL (PHP bug #48584). In contrast, the setting default_socket_timeout affects both SOAP requests and the retrieval of the WSDL. If both settings are specified, the default_socket_timeout is used when retrieving the WSDL and the connection_timeout is used for requests.

<?php
// Try connecting for one second
ini_set('default_socket_timeout', 1);
$soapClient = new SoapClient('http://www.example.com:1234/');
?>

The setting default_socket_timeout does not only affect the connect timeout, but also the read timeout on the WSDL.

Read timeout

If the connection succeeds, the remote webservice may still be so slow that you want to abort the request. If you send a request and do not get a reply within some time, you may want to inform the user that you have given up. The setting default_socket_timeout configures this: if there is no activity on the socket within this timeout, the request is aborted. This works both for the WSDL as the SOAP requests, but only for HTTP and not for HTTPS (PHP bug #41631).

HTTPS read timeout

There is no timeout setting on a HTTPS connection, not even a default 60 seconds. This means that if the remote server does not answer for several minutes, the SOAP call also blocks several minutes. If we want a timeout on a HTTPS connection, we have to implement it ourselves.

To implement our own HTTPS client, we make use of the SSL functionality in fsockopen(). The function fsockopen() allows to set up a SSL connection and use it just like a normal connection. By connecting to port 443, the default HTTPS port, we can send HTTP commands to the remote server:

<?php
$socket = fsockopen('ssl://www.google.com', 443);
fwrite($socket, "GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n");
while ($data = fread($socket, 2000))
{
    echo $data;
} 
fclose($socket);
?>

The good thing is that fsockopen respects the default_socket_timeout setting, even in SSL mode. Alternatively, the timeout parameter to fsockopen can be used. However, we have to implement our own read timeout, because fread() will block forever if the remote server does not respond. Neither stream_set_timeout or default_socket_timeout will affect the reading of an SSL connection.

To implement timeout functionality, we can either use the stream_select() function or set the socket to non-blocking.

Stream_select()

The function stream_select() waits for a socket to become readable. It also allows a timeout, so it can be used for our purposes. The interface to stream_select() is somewhat strange: it modifies its arguments so that the arrays contain just the streams on which something happened.

$write = $error = array();
$read = array($socket);
stream_select($read, $write, $error, 4);
if (in_array($socket, $read))
{
    while ($data = fread($socket, 2000))
    {
        echo $data;
    }
}
else
{
    echo "Read timeout\n";
}

The function stream_select() waits until one of the streams becomes readable, or the timeout has expired. Stream_select() will block for at most 4 seconds. If the timeout expired, the socket resource will not be in the $read array. If the socket became readable, the resource is in $read.

Note that this example only introduces a read timeout on the first read. If the remote server sends some bytes and then stalls, stream_select() will consider the stream readable, but the read will still block.

Non-blocking sockets

Instead of waiting for the socket to become readable, it is also possible to continuously poll the socket to see if it has any data. By setting the socket to non-blocking mode with stream_set_blocking, fread() will never block. If there is data available, fread() will return it. If it isn't, fread() will return an empty string.

stream_set_blocking($socket, false);

$stop = microtime(true) + 4;
while (microtime(true) < $stop) 
{
    if (feof($socket))
    {
        break;
    }

    echo fread($socket, 2000);
}

This loop may use a lot of processing power if there is nothing to read. To prevent this, put an usleep() in the loop or combine this method with stream_select(): wait until there is something to read, and then read it in a non-blocking way.

SoapClient with HTTPS timeout

The examples above implement a minimal version of HTTPS with timeout. We can use this code in our own version of the SoapClient, so that we can do SOAP requests over HTTPS using the timeout functionality.

To do this, we extend the built-in SoapClient class to use our HTTP method instead of the default one. This can be done by overriding the __doRequest() method.

public string SoapClient::__doRequest ( string $request , string $location , string $action , int $version [, int $one_way= 0 ] )

The __doRequest method has the task of submitting the request to the remote server. This is exactly what we would like to do ourselves. It has the following parameters:

  • $request, the XML request to post to the remote location
  • $location, the URL to post the request to
  • $action, the SOAP action which we will send using the SoapAction header
  • $version, not used right now
  • $one_way, whether a response is expected from the server

The __doRequest method only handles actual SOAP requests, not the retrieving of the WSDL. There is no method which can be overridden to retrieve the WSDL using a timeout. If the WSDL does not change often, the easiest way is to retrieve it once, save it to disk and pass the filename to the SoapClient. If it changes often, you can retrieve it with the code shown above. This can be done automatically in the SoapClient constructor, so that it is transparent to users of the SoapClient.

Here is a SoapClient with our new HTTP implementation to do SOAP requests using a timeout:

class TimeoutSoapClient extends SoapClient
{
    const TIMEOUT = 4;

    public function __doRequest($request, $location, $action, $version, $one_way = 0)
    {
        $url_parts = parse_url($location);
        $host = $url_parts['host'];
        $http_req = 'POST '.$location.' HTTP/1.0'."\r\n";
        $http_req .= 'Host: '.$host."\r\n";
        $http_req .= 'SoapAction: '.$action."\r\n";
        $http_req .= "\r\n";
        $http_req .= $request;
        $port = 80;
        if ($url_parts['scheme'] == 'https')
        {
            $port = 443;
            $host = 'ssl://'.$host;
        }
        $socket = fsockopen($host, $port);
        fwrite($socket, $request);
        stream_set_blocking($socket, false);
        $response = '';
        $stop = microtime(true) + self::TIMEOUT;
        while (!feof($socket))
        {
            $response .= fread($socket, 2000);
            if (microtime(true) > $stop)
            {
                throw new SoapFault('Client', 'HTTP timeout');
            }
        }
        return $response;
    }
}

We parse the location URL to determine to which host to connect. Then, we construct a request specifying the Host header, which is required for HTTP, and the SoapAction header, which is required for SOAP. If HTTPS is used, we use the HTTPS port and prepend "ssl://" to the host to let fsockopen() know that we want a SSL connection. We write the request and listen for a response in non-blocking mode, as shown earlier.

Conclusion

It is desirable to use a timeout for SOAP requests, so that you can inform the user that something is wrong within a reasonable time. When using HTTP, this can be configured using socket_default_timeout. When using HTTPS, it is quite hard to configure a timeout. In fact, you have to implement a subset of HTTP yourself, something which PHP should take care of. Our example code shows how to send the request and wait for a response, using a timeout, while still using most of the functionality of the built-in SoapClient class.