feat: retry api requests when closed immediately due to overload
This commit is contained in:
@@ -4,17 +4,18 @@ namespace App\BusProNet;
|
|||||||
|
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
use App\BusProNet\Exception\ResponseParserException;
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
use App\BusProNet\Model\BaseData;
|
use App\BusProNet\Model\BaseData;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\BookingResponse;
|
use App\BusProNet\Model\BookingResponse;
|
||||||
use App\BusProNet\Model\BookingUpdate;
|
use App\BusProNet\Model\BookingUpdate;
|
||||||
use App\BusProNet\Model\RegistrationResponse;
|
|
||||||
use App\BusProNet\Model\CrmAttributes;
|
use App\BusProNet\Model\CrmAttributes;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\BusProNet\Model\PromoVoucher;
|
use App\BusProNet\Model\PromoVoucher;
|
||||||
use App\BusProNet\Model\PurchaseVoucher;
|
use App\BusProNet\Model\PurchaseVoucher;
|
||||||
|
use App\BusProNet\Model\RegistrationResponse;
|
||||||
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\BusProNet\Traits\ApiClientTrait;
|
use App\BusProNet\Traits\ApiClientTrait;
|
||||||
@@ -489,10 +490,11 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends raw XML to the BPN API with key regeneration.
|
* Sends raw XML to the BPN API with key regeneration and automatic retry.
|
||||||
*
|
*
|
||||||
* Parses the XML to extract the request type, regenerates the authentication key
|
* Parses the XML to extract the request type, regenerates the authentication key
|
||||||
* with the current date, and sends the request. Returns the raw XML response.
|
* with the current date, and sends the request. Returns the raw XML response.
|
||||||
|
* Automatically retries on immediate connection close (server busy).
|
||||||
*
|
*
|
||||||
* @param string $xml The raw XML request body
|
* @param string $xml The raw XML request body
|
||||||
* @param bool $debug Enable debug mode (XML dumps)
|
* @param bool $debug Enable debug mode (XML dumps)
|
||||||
@@ -502,6 +504,42 @@ class ApiClient
|
|||||||
* @throws ApiClientException If the request fails or XML is invalid
|
* @throws ApiClientException If the request fails or XML is invalid
|
||||||
*/
|
*/
|
||||||
public function sendRawXml(string $xml, bool $debug = false): string
|
public function sendRawXml(string $xml, bool $debug = false): string
|
||||||
|
{
|
||||||
|
$maxAttempts = $this->config['busy_retry_attempts'];
|
||||||
|
$retryDelay = $this->config['busy_retry_delay'];
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
|
||||||
|
try {
|
||||||
|
return $this->doSendRawXml($xml, $debug);
|
||||||
|
} catch (ImmediateConnectionCloseException $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
|
||||||
|
if ($attempt < $maxAttempts) {
|
||||||
|
$this->logger->warning('BPN server busy, retrying raw XML request', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'maxAttempts' => $maxAttempts,
|
||||||
|
'retryDelay' => $retryDelay,
|
||||||
|
]);
|
||||||
|
sleep($retryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('BPN server busy after all retry attempts (raw XML)', [
|
||||||
|
'attempts' => $maxAttempts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $lastException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the actual raw XML request to the BPN API.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
private function doSendRawXml(string $xml, bool $debug = false): string
|
||||||
{
|
{
|
||||||
$requestId = date(DATE_ATOM).uniqid();
|
$requestId = date(DATE_ATOM).uniqid();
|
||||||
|
|
||||||
@@ -565,9 +603,52 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Sends a request to the BPN API with automatic retry on immediate connection close.
|
||||||
|
*
|
||||||
|
* When the BPN server is busy, it may close connections immediately without responding.
|
||||||
|
* This method detects such conditions and automatically retries after a short delay.
|
||||||
|
*
|
||||||
* @throws ApiClientException
|
* @throws ApiClientException
|
||||||
*/
|
*/
|
||||||
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||||
|
{
|
||||||
|
$maxAttempts = $this->config['busy_retry_attempts'];
|
||||||
|
$retryDelay = $this->config['busy_retry_delay'];
|
||||||
|
$lastException = null;
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
|
||||||
|
try {
|
||||||
|
return $this->doSendRequest($type, $data, $additionalArgs, $debug);
|
||||||
|
} catch (ImmediateConnectionCloseException $e) {
|
||||||
|
$lastException = $e;
|
||||||
|
|
||||||
|
if ($attempt < $maxAttempts) {
|
||||||
|
$this->logger->warning('BPN server busy, retrying request', [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'maxAttempts' => $maxAttempts,
|
||||||
|
'retryDelay' => $retryDelay,
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
sleep($retryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->error('BPN server busy after all retry attempts', [
|
||||||
|
'attempts' => $maxAttempts,
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $lastException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the actual request to the BPN API.
|
||||||
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
|
*/
|
||||||
|
private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed
|
||||||
{
|
{
|
||||||
$requestId = date(DATE_ATOM).uniqid();
|
$requestId = date(DATE_ATOM).uniqid();
|
||||||
|
|
||||||
@@ -650,6 +731,8 @@ class ApiClient
|
|||||||
'connection_timeout' => 5,
|
'connection_timeout' => 5,
|
||||||
'stream_timeout' => 30,
|
'stream_timeout' => 30,
|
||||||
'total_timeout' => 45,
|
'total_timeout' => 45,
|
||||||
|
'busy_retry_attempts' => 3,
|
||||||
|
'busy_retry_delay' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $optionsResolver->resolve($options);
|
return $optionsResolver->resolve($options);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\BusProNet\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the BPN server closes the connection immediately without sending data.
|
||||||
|
*
|
||||||
|
* This typically indicates the server is busy processing other requests.
|
||||||
|
* Unlike a true timeout, this is a transient condition that may succeed on retry.
|
||||||
|
*/
|
||||||
|
class ImmediateConnectionCloseException extends ApiClientException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\BusProNet\Traits;
|
namespace App\BusProNet\Traits;
|
||||||
|
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
use App\BusProNet\Exception\TimeoutException;
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
|
|
||||||
trait ApiClientTrait
|
trait ApiClientTrait
|
||||||
@@ -90,6 +91,7 @@ trait ApiClientTrait
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws TimeoutException
|
* @throws TimeoutException
|
||||||
|
* @throws ImmediateConnectionCloseException
|
||||||
*/
|
*/
|
||||||
private function receive($socket, int $totalTimeout): string
|
private function receive($socket, int $totalTimeout): string
|
||||||
{
|
{
|
||||||
@@ -115,6 +117,17 @@ trait ApiClientTrait
|
|||||||
// Check if stream timed out on this specific read
|
// Check if stream timed out on this specific read
|
||||||
$metadata = stream_get_meta_data($socket);
|
$metadata = stream_get_meta_data($socket);
|
||||||
if (true === $metadata['timed_out']) {
|
if (true === $metadata['timed_out']) {
|
||||||
|
// Check if this is an immediate rejection (0 bytes, < 1 second)
|
||||||
|
// This indicates the server is busy rather than a true timeout
|
||||||
|
if (0 === strlen($response) && $elapsedTime < 1.0) {
|
||||||
|
$this->logger->warning('Server closed connection immediately', [
|
||||||
|
'elapsed_time' => $elapsedTime,
|
||||||
|
'bytes_received' => 0,
|
||||||
|
'read_attempts' => $readAttempts,
|
||||||
|
]);
|
||||||
|
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->error('Stream read timeout detected', [
|
$this->logger->error('Stream read timeout detected', [
|
||||||
'elapsed_time' => $elapsedTime,
|
'elapsed_time' => $elapsedTime,
|
||||||
'bytes_received' => strlen($response),
|
'bytes_received' => strlen($response),
|
||||||
|
|||||||
Reference in New Issue
Block a user