feat: retry api requests when closed immediately due to overload

This commit is contained in:
Björn Fromme
2026-03-16 12:01:09 +01:00
parent b3cace3b43
commit 249e326de4
3 changed files with 113 additions and 2 deletions
+85 -2
View File
@@ -4,17 +4,18 @@ namespace App\BusProNet;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
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
* 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 bool $debug Enable debug mode (XML dumps)
@@ -502,6 +504,42 @@ class ApiClient
* @throws ApiClientException If the request fails or XML is invalid
*/
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();
@@ -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
*/
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();
@@ -650,6 +731,8 @@ class ApiClient
'connection_timeout' => 5,
'stream_timeout' => 30,
'total_timeout' => 45,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
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
{
}
+13
View File
@@ -3,6 +3,7 @@
namespace App\BusProNet\Traits;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
trait ApiClientTrait
@@ -90,6 +91,7 @@ trait ApiClientTrait
/**
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket, int $totalTimeout): string
{
@@ -115,6 +117,17 @@ trait ApiClientTrait
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
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', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),