feat: improved timeout handling for api client

This commit is contained in:
Björn Fromme
2025-11-13 11:15:31 +01:00
parent aeb25f3b7e
commit bcded403be
14 changed files with 139 additions and 14 deletions
+9 -3
View File
@@ -436,10 +436,13 @@ class ApiClient
$socket = $this->connect(
$this->config['bpn_api_ip'],
$this->config['bpn_api_port'],
$this->config['max_retries']
$this->config['max_retries'],
$this->config['connection_timeout'],
$this->config['stream_timeout'],
$this->config['total_timeout']
);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->send($socket, $body, $this->config['total_timeout']);
$response = $this->receive($socket, $this->config['total_timeout']);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
@@ -489,6 +492,9 @@ class ApiClient
$optionsResolver->setDefaults([
'max_retries' => 25,
'debug' => false,
'connection_timeout' => 5,
'stream_timeout' => 30,
'total_timeout' => 45,
]);
return $optionsResolver->resolve($options);
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Exception;
/**
* Thrown when an API operation exceeds its timeout limit.
*/
class TimeoutException extends ApiClientException
{
}
+65 -7
View File
@@ -3,14 +3,20 @@
namespace App\BusProNet\Traits;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\TimeoutException;
trait ApiClientTrait
{
private float $operationStartTime;
/**
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect(string $host, int $port, int $maxRetries = 25)
private function connect(string $host, int $port, int $maxRetries = 25, int $connectionTimeout = 5, int $streamTimeout = 30, int $totalTimeout = 45)
{
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
@@ -18,22 +24,32 @@ trait ApiClientTrait
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) use ($host, $port) {
$openSocket = function (&$errNo, &$errStr) use ($host, $port, $connectionTimeout) {
return @fsockopen(
$host,
$port,
$errNo,
$errStr,
10
$connectionTimeout
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $maxRetries > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $totalTimeout,
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
]);
++$tries;
sleep(1);
@@ -41,11 +57,12 @@ trait ApiClientTrait
}
if (false !== $socket) {
stream_set_timeout($socket, 60);
stream_set_timeout($socket, $streamTimeout);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new ApiClientException('Unable to open socket');
}
@@ -53,19 +70,60 @@ trait ApiClientTrait
return $socket;
}
private function send($socket, string $data): void
/**
* @throws TimeoutException
*/
private function send($socket, string $data, int $totalTimeout): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
private function receive($socket): string
/**
* @throws TimeoutException
*/
private function receive($socket, int $totalTimeout): string
{
$response = '';
$readAttempts = 0;
while (false === feof($socket)) {
$response .= fread($socket, 4096);
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
return $response;