Issues (3)

src/Client.php (1 issue)

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
    private $persist;
25
26
    /**
27
     * @param int  $timeout
28
     * @param bool $debug
29 12
     */
30
    public function __construct(string $projectKey, array $connections, $timeout = 10000, $debug = false, LoggerInterface $logger = null, $persist = true)
31 12
    {
32 1
        if (!$projectKey) {
33
            throw new BadConfigurationException('A project key is required.');
34
        }
35 12
36 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...
37 12
            throw new BadConfigurationException('At least one connection is required.');
38 12
        }
39
40
        foreach ($connections as $name => $connection) {
41
            $this->connections[$name] = [
42 12
                'remote_socket' => $connection,
43 12
                'retries' => 2,
44 12
            ];
45 12
        }
46
47
        $this->projectKey = $projectKey;
48
        $this->timeout = $timeout;
49
        $this->debug = $debug;
50
        $this->logger = $logger ?: new NullLogger();
51
        $this->persist = $persist;
52
    }
53
54
    public function request(Command\CommandInterface $command)
55
    {
56
        $command->setProjectKey($this->projectKey);
57
58
        try {
59
            return $this->doRequest($command);
60
        } catch (ExceptionInterface $exception) {
61
            if ($this->debug) {
62
                throw $exception;
63
            }
64
65
            return null;
66
        }
67
    }
68
69 11
    private function doRequest(Command\CommandInterface $command)
70
    {
71
        $connection = $this->getConnection();
72 11
73 4
        $toSend = $command->getName()."\0".$command->getRequest()."\0";
74 4
        $sent = $this->box('doSend', false, [$connection, $toSend]);
75 2
76
        if (false === $sent) {
77
            $this->logger->debug('Impossible to send content to the connection.', [
78 2
                'options' => $this->connections[$this->currentConnectionName],
79
            ]);
80
81
            --$this->connections[$this->currentConnectionName]['retries'];
82 11
            $this->currentConnection = null;
83
            $this->box('disconnect', null, [$connection]);
84 11
85
            return $this->doRequest($command);
86 8
        }
87 8
88
        if (!$command->hasResponse()) {
89 8
            return null;
90
        }
91
92
        $received = $this->box('doGet', false, [$connection]);
93
94
        // false: the persistent connection is stale
95
        if (false === $received) {
96
            $this->logger->debug('Impossible to get content from the connection.', [
97
                'options' => $this->connections[$this->currentConnectionName],
98
            ]);
99
100
            --$this->connections[$this->currentConnectionName]['retries'];
101 8
            $this->currentConnection = null;
102 1
            $this->box('disconnect', null, [$connection]);
103
104
            return $this->doRequest($command);
105 7
        }
106
107
        if (feof($connection)) {
108 7
            $this->box('disconnect', null, [$connection]);
109 1
            $this->currentConnection = null;
110 1
        }
111
112
        return $command->parseResponse(trim($received));
113 1
    }
114 1
115 1
    private function getConnection()
116
    {
117 1
        if (null !== $this->currentConnection) {
118
            return $this->currentConnection;
119
        }
120 7
121
        foreach ($this->connections as $name => $connection) {
122
            if ($connection['retries'] <= 0) {
123
                continue;
124
            }
125 7
126
            $this->logger->debug('New connection chosen. Trying to connect.', [
127
                'connection' => $connection,
128 11
                'name' => $name,
129
            ]);
130 11
131 2
            $connection = $this->box('doConnect', false, [$connection]);
132
133
            if (false === $connection) {
134 11
                $this->logger->debug('Impossible to connect to the connection.', [
135 11
                    'connection' => $connection,
136 1
                    'name' => $name,
137
                ]);
138
139 11
                $this->connections[$name]['retries'] = 0;
140 11
141 11
                continue;
142
            }
143
144 11
            $this->logger->debug('New connection approved.', [
145
                'connection' => $connection,
146 11
                'name' => $name,
147 5
            ]);
148 5
149 5
            stream_set_timeout($connection, 0, $this->timeout);
150
151
            $this->currentConnection = $connection;
152 5
            $this->currentConnectionName = $name;
153
154 5
            return $connection;
155
        }
156
157 8
        $this->logger->error('Can not find an agent.', [
158 8
            'connections_options' => $this->connections,
159 8
        ]);
160
161
        throw new AgentNotFoundException();
162 8
    }
163
164 8
    private function doConnect($options)
165 8
    {
166
        if (\PHP_VERSION_ID >= 70100) {
167 8
            $context = stream_context_create([
168
                'socket' => [
169
                    'tcp_nodelay' => true,
170 4
                ],
171 4
            ]);
172
        } else {
173
            $context = stream_context_create();
174 4
        }
175
176
        $flags = \STREAM_CLIENT_CONNECT;
177 11
178
        if ($this->persist) {
179 11
            $flags |= \STREAM_CLIENT_PERSISTENT;
180 11
        }
181 11
182
        $connection = stream_socket_client(
183
            $options['remote_socket'],
184
            $errNo,
185
            $errMsg,
186
            1, // This value is not used but it should not be 0
187
            $flags,
188
            $context
189 11
        );
190 11
191 11
        stream_set_blocking($connection, 0);
192 11
193 11
        return $connection;
194 11
    }
195 11
196
    private function disconnect($connection)
197
    {
198 8
        fclose($connection);
199
    }
200 8
201
    private function doSend($connection, $content)
202
    {
203 1
        return $this->fwrite($connection, $content);
204
    }
205 1
206 1
    private function doGet($connection)
207
    {
208 8
        $buffer = '';
209
210 8
        while (true) {
211
            if (feof($connection)) {
212
                return false;
213 7
            }
214
215 7
            $reads = $write = $except = [];
216
            $reads[] = $connection;
217 7
            $modified = stream_select($reads, $write, $except, 0, $this->timeout);
218 7
219 1
            // Timeout
220
            if (0 === $modified) {
221
                throw new TimeoutException('Timeout reached when trying to read stream ('.$this->timeout.'ms)');
222 7
            }
223 7
224 7
            // Error
225
            if (false === $modified) {
226
                return false;
227 7
            }
228
229
            $content = stream_get_contents($connection);
230
            $buffer .= $content;
231
            $endingPos = strpos($buffer, "\0");
232 7
233
            if (false !== $endingPos) {
234
                return substr($buffer, 0, $endingPos);
235
            }
236 7
        }
237 7
    }
238 7
239
    private function box($method, $defaultReturnValue = null, array $args = [])
240 7
    {
241 7
        set_error_handler(__CLASS__.'::handleInternalError');
242
243
        try {
244
            $returnValue = \call_user_func_array([$this, $method], $args);
245
        } catch (\ErrorException $exception) {
246 11
            $returnValue = $defaultReturnValue;
247
248 11
            $this->logger->warning('Impossible to execute a boxed call.', [
249
                'method' => $method,
250
                'default_return_value' => $defaultReturnValue,
251 11
                'args' => $args,
252 5
                'exception' => $exception,
253 5
            ]);
254
        } finally {
255 5
            restore_error_handler();
256 5
        }
257 5
258 5
        return $returnValue;
259 5
    }
260
261
    private static function handleInternalError($type, $message, $file, $line)
262
    {
263 11
        throw new \ErrorException($message, 0, $type, $file, $line);
264
    }
265 11
266
    /**
267
     * Replace fwrite behavior as API is broken in PHP.
268 5
     *
269
     * @see https://secure.phabricator.com/rPHU69490c53c9c2ef2002bc2dd4cecfe9a4b080b497
270 5
     *
271
     * @param resource $stream The stream resource
272
     * @param string   $bytes  Bytes written in the stream
273
     *
274
     * @return bool|int false if pipe is broken, number of bytes written otherwise
275
     */
276
    private function fwrite($stream, $bytes)
277
    {
278
        if (!\strlen($bytes)) {
279
            return 0;
280
        }
281
        $result = @fwrite($stream, $bytes);
282
        if (0 !== $result) {
283 8
            // In cases where some bytes are witten (`$result > 0`) or
284
            // an error occurs (`$result === false`), the behavior of fwrite() is
285 8
            // correct. We can return the value as-is.
286
            return $result;
287
        }
288 8
        // If we make it here, we performed a 0-length write. Try to distinguish
289 8
        // between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
290
        // the stream, write to it again if PHP claims that it's writable, and
291
        // consider the pipe broken if the write fails.
292
        $read = [];
293 8
        $write = [$stream];
294
        $except = [];
295
        @stream_select($read, $write, $except, 0);
296
        if (!$write) {
297
            // The stream isn't writable, so we conclude that it probably really is
298
            // blocked and the underlying error was EAGAIN. Return 0 to indicate that
299
            // no data could be written yet.
300
            return 0;
301
        }
302
        // If we make it here, PHP **just** claimed that this stream is writable, so
303
        // perform a write. If the write also fails, conclude that these failures are
304
        // EPIPE or some other permanent failure.
305
        $result = @fwrite($stream, $bytes);
306
        if (0 !== $result) {
307
            // The write worked or failed explicitly. This value is fine to return.
308
            return $result;
309
        }
310
        // We performed a 0-length write, were told that the stream was writable, and
311
        // then immediately performed another 0-length write. Conclude that the pipe
312
        // is broken and return `false`.
313
        return false;
314
    }
315
}
316