Passed
Push — master ( 511107...9c3e11 )
by Joel
03:54
created

Client::doRequest()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 44
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 5.8191

Importance

Changes 0
Metric Value
cc 5
eloc 24
nc 5
nop 1
dl 0
loc 44
ccs 17
cts 25
cp 0.68
crap 5.8191
rs 9.2248
c 0
b 0
f 0
1
<?php
2
3
namespace RedirectionIO\Client\Sdk;
4
5
use Psr\Log\LoggerInterface;
6
use Psr\Log\NullLogger;
7
use RedirectionIO\Client\Sdk\Command\LogCommand;
8
use RedirectionIO\Client\Sdk\Command\MatchCommand;
9
use RedirectionIO\Client\Sdk\Exception\AgentNotFoundException;
10
use RedirectionIO\Client\Sdk\Exception\BadConfigurationException;
11
use RedirectionIO\Client\Sdk\Exception\ExceptionInterface;
12
use RedirectionIO\Client\Sdk\Exception\TimeoutException;
13
use RedirectionIO\Client\Sdk\HttpMessage\Request;
14
use RedirectionIO\Client\Sdk\HttpMessage\Response;
15
16
class Client
17
{
18
    private $connections;
19
    private $timeout;
20
    private $debug;
21
    private $logger;
22
    private $currentConnection;
23
    private $currentConnectionName;
24
25
    /**
26
     * @param int  $timeout
27
     * @param bool $debug
28
     */
29 12
    public function __construct(array $connections, $timeout = 10000, $debug = false, LoggerInterface $logger = null)
30
    {
31 12
        if (!$connections) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $connections of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
32 1
            throw new BadConfigurationException('At least one connection is required.');
33
        }
34
35 12
        foreach ($connections as $name => $connection) {
36 12
            $this->connections[$name] = [
37 12
                'remote_socket' => $connection,
38 12
                'retries' => 2,
39
            ];
40
        }
41
42 12
        $this->timeout = $timeout;
43 12
        $this->debug = $debug;
44 12
        $this->logger = $logger ?: new NullLogger();
45 12
    }
46
47
    /**
48
     * @deprecated findRedirect() is deprecated since version 0.2 and will be removed in 0.3. Use request(new MatchCommand()) instead.
49
     */
50
    public function findRedirect(Request $request)
51
    {
52
        @trigger_error('findRedirect() is deprecated since version 0.2 and will be removed in 0.3. Use request(new MatchCommand()) instead.', E_USER_DEPRECATED);
53
54
        return $this->request(new MatchCommand($request));
55
    }
56
57
    /**
58
     * @deprecated log() is deprecated since version 0.2 and will be removed in 0.3. Use request(new LogCommand()) instead.
59
     */
60
    public function log(Request $request, Response $response)
61
    {
62
        @trigger_error('log() is deprecated since version 0.2 and will be removed in 0.3. Use request(new LogCommand()) instead.', E_USER_DEPRECATED);
63
64
        $this->request(new LogCommand($request, $response));
65
66
        return true;
67
    }
68
69 11
    public function request(Command\CommandInterface $command)
70
    {
71
        try {
72 11
            return $this->doRequest($command);
73 4
        } catch (ExceptionInterface $exception) {
74 4
            if ($this->debug) {
75 2
                throw $exception;
76
            }
77
78 2
            return null;
79
        }
80
    }
81
82 11
    private function doRequest(Command\CommandInterface $command)
83
    {
84 11
        $connection = $this->getConnection();
85
86 8
        $toSend = $command->getName()."\0".$command->getRequest()."\0";
87 8
        $sent = $this->box('doSend', false, [$connection, $toSend]);
88
89 8
        if (false === $sent) {
90
            $this->logger->debug('Impossible to send content to the connection.', [
91
                'options' => $this->connections[$this->currentConnectionName],
92
            ]);
93
94
            --$this->connections[$this->currentConnectionName]['retries'];
95
            $this->currentConnection = null;
96
            $this->box('disconnect', null, [$connection]);
97
98
            return $this->doRequest($command);
99
        }
100
101 8
        if (!$command->hasResponse()) {
102 1
            return null;
103
        }
104
105 7
        $received = $this->box('doGet', false, [$connection]);
106
107
        // false: the persistent connection is stale
108 7
        if (false === $received) {
109 1
            $this->logger->debug('Impossible to get content from the connection.', [
110 1
                'options' => $this->connections[$this->currentConnectionName],
111
            ]);
112
113 1
            --$this->connections[$this->currentConnectionName]['retries'];
114 1
            $this->currentConnection = null;
115 1
            $this->box('disconnect', null, [$connection]);
116
117 1
            return $this->doRequest($command);
118
        }
119
120 7
        if (feof($connection)) {
121
            $this->box('disconnect', null, [$connection]);
122
            $this->currentConnection = null;
123
        }
124
125 7
        return $command->parseResponse(trim($received));
126
    }
127
128 11
    private function getConnection()
129
    {
130 11
        if (null !== $this->currentConnection) {
131 2
            return $this->currentConnection;
132
        }
133
134 11
        foreach ($this->connections as $name => $connection) {
135 11
            if ($connection['retries'] <= 0) {
136 1
                continue;
137
            }
138
139 11
            $this->logger->debug('New connection chosen. Trying to connect.', [
140 11
                'connection' => $connection,
141 11
                'name' => $name,
142
            ]);
143
144 11
            $connection = $this->box('doConnect', false, [$connection]);
145
146 11
            if (false === $connection) {
147 5
                $this->logger->debug('Impossible to connect to the connection.', [
148 5
                    'connection' => $connection,
149 5
                    'name' => $name,
150
                ]);
151
152 5
                $this->connections[$name]['retries'] = 0;
153
154 5
                continue;
155
            }
156
157 8
            $this->logger->debug('New connection approved.', [
158 8
                'connection' => $connection,
159 8
                'name' => $name,
160
            ]);
161
162 8
            stream_set_timeout($connection, 0, $this->timeout);
163
164 8
            $this->currentConnection = $connection;
165 8
            $this->currentConnectionName = $name;
166
167 8
            return $connection;
168
        }
169
170 4
        $this->logger->error('Can not find an agent.', [
171 4
            'connections_options' => $this->connections,
172
        ]);
173
174 4
        throw new AgentNotFoundException();
175
    }
176
177 11
    private function doConnect($options)
178
    {
179 11
        if (\PHP_VERSION_ID >= 70100) {
180 11
            $context = stream_context_create([
181 11
                'socket' => [
182
                    'tcp_nodelay' => true,
183
                ],
184
            ]);
185
        } else {
186
            $context = stream_context_create();
187
        }
188
189 11
        $connection = stream_socket_client(
190 11
            $options['remote_socket'],
191 11
            $errNo,
192 11
            $errMsg,
193 11
            1, // This value is not used but it should not be 0
194 11
            STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT,
195 11
            $context
196
        );
197
198 8
        stream_set_blocking($connection, 0);
0 ignored issues
show
Bug introduced by
It seems like $connection can also be of type false; however, parameter $stream of stream_set_blocking() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

198
        stream_set_blocking(/** @scrutinizer ignore-type */ $connection, 0);
Loading history...
199
200 8
        return $connection;
201
    }
202
203 1
    private function disconnect($connection)
204
    {
205 1
        fclose($connection);
206 1
    }
207
208 8
    private function doSend($connection, $content)
209
    {
210 8
        return $this->fwrite($connection, $content);
211
    }
212
213 7
    private function doGet($connection)
214
    {
215 7
        $buffer = '';
216
217 7
        while (true) {
218 7
            if (feof($connection)) {
219 1
                return false;
220
            }
221
222 7
            $reads = $write = $except = [];
223 7
            $reads[] = $connection;
224 7
            $modified = stream_select($reads, $write, $except, 0, $this->timeout);
225
226
            // Timeout
227 7
            if (0 === $modified) {
228
                throw new TimeoutException('Timeout reached when trying to read stream ('.$this->timeout.'ms)');
229
            }
230
231
            // Error
232 7
            if (false === $modified) {
233
                return false;
234
            }
235
236 7
            $content = stream_get_contents($connection);
237 7
            $buffer .= $content;
238 7
            $endingPos = strpos($buffer, "\0");
239
240 7
            if (false !== $endingPos) {
241 7
                return substr($buffer, 0, $endingPos);
242
            }
243
        }
244
    }
245
246 11
    private function box($method, $defaultReturnValue = null, array $args = [])
247
    {
248 11
        set_error_handler(__CLASS__.'::handleInternalError');
249
250
        try {
251 11
            $returnValue = \call_user_func_array([$this, $method], $args);
252 5
        } catch (\ErrorException $exception) {
253 5
            $returnValue = $defaultReturnValue;
254
255 5
            $this->logger->warning('Impossible to execute a boxed call.', [
256 5
                'method' => $method,
257 5
                'default_return_value' => $defaultReturnValue,
258 5
                'args' => $args,
259 5
                'exception' => $exception,
260
            ]);
261
        }
262
263 11
        restore_error_handler();
264
265 11
        return $returnValue;
266
    }
267
268 5
    private static function handleInternalError($type, $message, $file, $line)
269
    {
270 5
        throw new \ErrorException($message, 0, $type, $file, $line);
271
    }
272
273
    /**
274
     * Replace fwrite behavior as API is broken in PHP.
275
     *
276
     * @see https://secure.phabricator.com/rPHU69490c53c9c2ef2002bc2dd4cecfe9a4b080b497
277
     *
278
     * @param resource $stream The stream resource
279
     * @param string   $bytes  Bytes written in the stream
280
     *
281
     * @return bool|int false if pipe is broken, number of bytes written otherwise
282
     */
283 8
    private function fwrite($stream, $bytes)
284
    {
285 8
        if (!\strlen($bytes)) {
286
            return 0;
287
        }
288 8
        $result = @fwrite($stream, $bytes);
289 8
        if (0 !== $result) {
290
            // In cases where some bytes are witten (`$result > 0`) or
291
            // an error occurs (`$result === false`), the behavior of fwrite() is
292
            // correct. We can return the value as-is.
293 8
            return $result;
294
        }
295
        // If we make it here, we performed a 0-length write. Try to distinguish
296
        // between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
297
        // the stream, write to it again if PHP claims that it's writable, and
298
        // consider the pipe broken if the write fails.
299
        $read = [];
300
        $write = [$stream];
301
        $except = [];
302
        @stream_select($read, $write, $except, 0);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for stream_select(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

302
        /** @scrutinizer ignore-unhandled */ @stream_select($read, $write, $except, 0);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
303
        if (!$write) {
304
            // The stream isn't writable, so we conclude that it probably really is
305
            // blocked and the underlying error was EAGAIN. Return 0 to indicate that
306
            // no data could be written yet.
307
            return 0;
308
        }
309
        // If we make it here, PHP **just** claimed that this stream is writable, so
310
        // perform a write. If the write also fails, conclude that these failures are
311
        // EPIPE or some other permanent failure.
312
        $result = @fwrite($stream, $bytes);
313
        if (0 !== $result) {
314
            // The write worked or failed explicitly. This value is fine to return.
315
            return $result;
316
        }
317
        // We performed a 0-length write, were told that the stream was writable, and
318
        // then immediately performed another 0-length write. Conclude that the pipe
319
        // is broken and return `false`.
320
        return false;
321
    }
322
}
323