Completed
Push — master ( 2c318d...02344d )
by Joel
07:05
created

Client::doGet()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 33
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 16
nc 7
nop 1
dl 0
loc 33
ccs 18
cts 18
cp 1
crap 7
rs 8.8333
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\HttpMessage\Request;
13
use RedirectionIO\Client\Sdk\HttpMessage\Response;
14
15
class Client
16
{
17
    private $connections;
18
    private $timeout;
19
    private $debug;
20
    private $logger;
21
    private $currentConnection;
22
    private $currentConnectionName;
23
24
    /**
25
     * @param int  $timeout
26 13
     * @param bool $debug
27
     */
28 13
    public function __construct(array $connections, $timeout = 10000, $debug = false, LoggerInterface $logger = null)
29 1
    {
30
        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...
31
            throw new BadConfigurationException('At least one connection is required.');
32 13
        }
33 13
34 13
        foreach ($connections as $name => $connection) {
35 13
            $this->connections[$name] = [
36
                'remote_socket' => $connection,
37 13
                'retries' => 2,
38
            ];
39 13
        }
40 13
41 13
        $this->timeout = $timeout;
42 13
        $this->debug = $debug;
43
        $this->logger = $logger ?: new NullLogger();
44 9
    }
45
46
    /**
47 9
     * @deprecated findRedirect() is deprecated since version 0.2 and will be removed in 0.3. Use request(new MatchCommand()) instead.
48 9
     */
49 9
    public function findRedirect(Request $request)
50 9
    {
51 9
        @trigger_error('findRedirect() is deprecated since version 0.2 and will be removed in 0.3. Use request(new MatchCommand()) instead.', E_USER_DEPRECATED);
52 9
53 9
        return $this->request(new MatchCommand($request));
54
    }
55
56 9
    /**
57 9
     * @deprecated log() is deprecated since version 0.2 and will be removed in 0.3. Use request(new LogCommand()) instead.
58 3
     */
59 1
    public function log(Request $request, Response $response)
60
    {
61
        @trigger_error('log() is deprecated since version 0.2 and will be removed in 0.3. Use request(new LogCommand()) instead.', E_USER_DEPRECATED);
62 2
63
        $this->request(new LogCommand($request, $response));
64
65 7
        return true;
66 1
    }
67
68
    public function request(Command\CommandInterface $command)
69 6
    {
70
        try {
71 6
            return $this->doRequest($command);
72
        } catch (ExceptionInterface $exception) {
73
            if ($this->debug) {
74
                throw $exception;
75
            }
76
77
            return null;
78
        }
79 6
    }
80 6
81
    private function doRequest(Command\CommandInterface $command)
82 6
    {
83
        $connection = $this->getConnection();
84
85
        $toSend = $command->getName()."\0".$command->getRequest()."\0";
86 6
        $sent = $this->box('doSend', false, [$connection, $toSend]);
87 6
88 6
        if (false === $sent) {
89
            $this->logger->debug('Impossible to send content to the connection.', [
90 6
                'options' => $this->connections[$this->currentConnectionName],
91
            ]);
92
93 3
            --$this->connections[$this->currentConnectionName]['retries'];
94
            $this->currentConnection = null;
95
            $this->box('disconnect', null, [$connection]);
96 3
97 3
            return $this->doRequest($command);
98 3
        }
99 3
100 3
        if (!$command->hasResponse()) {
101 3
            return null;
102 3
        }
103 3
104
        $received = $this->box('doGet', false, [$connection]);
105 3
106
        // false: the persistent connection is stale
107
        if (false === $received) {
108
            $this->logger->debug('Impossible to get content from the connection.', [
109 3
                'options' => $this->connections[$this->currentConnectionName],
110
            ]);
111
112
            --$this->connections[$this->currentConnectionName]['retries'];
113
            $this->currentConnection = null;
114 3
            $this->box('disconnect', null, [$connection]);
115 2
116 2
            return $this->doRequest($command);
117 1
        }
118 1
119
        return $command->parseResponse(trim($received));
120 1
    }
121
122
    private function getConnection()
123
    {
124 12
        if (null !== $this->currentConnection) {
125
            return $this->currentConnection;
126 12
        }
127
128 8
        foreach ($this->connections as $name => $connection) {
129 8
            if ($connection['retries'] <= 0) {
130
                continue;
131
            }
132 8
133
            $this->logger->debug('New connection chosen. Trying to connect.', [
134
                'connection' => $connection,
135
                'name' => $name,
136
            ]);
137
138
            $connection = $this->box('doConnect', false, [$connection]);
139
140
            if (false === $connection) {
141
                $this->logger->debug('Impossible to connect to the connection.', [
142
                    'connection' => $connection,
143 8
                    'name' => $name,
144
                ]);
145
146 8
                $this->connections[$name]['retries'] = 0;
147 1
148 1
                continue;
149 1
            }
150
151 1
            $this->logger->debug('New connection approved.', [
152 1
                'connection' => $connection,
153
                'name' => $name,
154 1
            ]);
155
156
            stream_set_timeout($connection, 0, $this->timeout);
157 8
158
            $this->currentConnection = $connection;
159
            $this->currentConnectionName = $name;
160 12
161
            return $connection;
162 12
        }
163 2
164
        $this->logger->error('Can not find an agent.', [
165
            'connections_options' => $this->connections,
166 12
        ]);
167 12
168 1
        throw new AgentNotFoundException();
169
    }
170
171 12
    private function doConnect($options)
172 12
    {
173 12
        if (\PHP_VERSION_ID >= 70100) {
174 12
            $context = stream_context_create([
175
                'socket' => [
176 12
                    'tcp_nodelay' => true,
177
                ],
178 12
            ]);
179 6
        } else {
180 6
            $context = stream_context_create();
181 6
        }
182 6
183
        $connection = stream_socket_client(
184 6
            $options['remote_socket'],
185
            $errNo,
186 6
            $errMsg,
187
            1, // This value is not used but it should not be 0
188
            STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT,
189 8
            $context
190 8
        );
191 8
192 8
        stream_set_blocking($connection, 1);
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

192
        stream_set_blocking(/** @scrutinizer ignore-type */ $connection, 1);
Loading history...
193
194 8
        return $connection;
195
    }
196 8
197 8
    private function disconnect($connection)
198
    {
199 8
        fclose($connection);
200 5
    }
201
202 5
    private function doSend($connection, $content)
203 5
    {
204 5
        return $this->fwrite($connection, $content);
205
    }
206 5
207
    private function doGet($connection)
208
    {
209 12
        $buffer = '';
210
211 12
        $retries = 10;
212 12
213 12
        while (true) {
214 12
            if (feof($connection)) {
215 12
                return false;
216 12
            }
217 12
218
            $char = fread($connection, 1);
219
220 8
            if (false === $char) {
221
                return false;
222 8
            }
223
224
            // On timeout char is empty. But it's also possible PHP did not
225 8
            // "see" the data yet, so let's retry few times
226
            if ('' === $char) {
227 8
                if (!$retries) {
228
                    return false;
229
                }
230 12
                --$retries;
231
232 12
                continue;
233
            }
234
235 12
            if ("\0" === $char) {
236 12
                return $buffer;
237 6
            }
238
239 6
            $buffer .= $char;
240 6
        }
241 6
    }
242 6
243 6
    private function box($method, $defaultReturnValue = null, array $args = [])
244 6
    {
245
        set_error_handler(__CLASS__.'::handleInternalError');
246
247 12
        try {
248
            $returnValue = \call_user_func_array([$this, $method], $args);
249 12
        } catch (\ErrorException $exception) {
250
            $returnValue = $defaultReturnValue;
251
252 6
            $this->logger->warning('Impossible to execute a boxed call.', [
253
                'method' => $method,
254 6
                'default_return_value' => $defaultReturnValue,
255
                'args' => $args,
256
                'exception' => $exception,
257
            ]);
258
        }
259
260
        restore_error_handler();
261
262
        return $returnValue;
263
    }
264
265
    private static function handleInternalError($type, $message, $file, $line)
266
    {
267 8
        throw new \ErrorException($message, 0, $type, $file, $line);
268
    }
269 8
270
    /**
271
     * Replace fwrite behavior as API is broken in PHP.
272 8
     *
273 8
     * @see https://secure.phabricator.com/rPHU69490c53c9c2ef2002bc2dd4cecfe9a4b080b497
274
     *
275
     * @param resource $stream The stream resource
276
     * @param string   $bytes  Bytes written in the stream
277 8
     *
278
     * @return bool|int false if pipe is broken, number of bytes written otherwise
279
     */
280
    private function fwrite($stream, $bytes)
281
    {
282
        if (!\strlen($bytes)) {
283
            return 0;
284
        }
285
        $result = @fwrite($stream, $bytes);
286
        if (0 !== $result) {
287
            // In cases where some bytes are witten (`$result > 0`) or
288
            // an error occurs (`$result === false`), the behavior of fwrite() is
289
            // correct. We can return the value as-is.
290
            return $result;
291
        }
292
        // If we make it here, we performed a 0-length write. Try to distinguish
293
        // between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
294
        // the stream, write to it again if PHP claims that it's writable, and
295
        // consider the pipe broken if the write fails.
296
        $read = [];
297
        $write = [$stream];
298
        $except = [];
299
        @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

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