Completed
Push — master ( 55031c...1c37a0 )
by Joel
07:31
created

Client::findRedirect()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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

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