feat: improved timeout handling for api client
This commit is contained in:
@@ -45,6 +45,9 @@ APP_BPN_PASSWORD=
|
|||||||
APP_BPN_IP=
|
APP_BPN_IP=
|
||||||
APP_BPN_PORT=
|
APP_BPN_PORT=
|
||||||
APP_BPN_DEBUG=false
|
APP_BPN_DEBUG=false
|
||||||
|
APP_BPN_CONNECTION_TIMEOUT=5
|
||||||
|
APP_BPN_STREAM_TIMEOUT=30
|
||||||
|
APP_BPN_TOTAL_TIMEOUT=45
|
||||||
|
|
||||||
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
|
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
|
||||||
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ htmx.config.historyCacheSize = 0
|
|||||||
htmx.config.allowScriptTags = false
|
htmx.config.allowScriptTags = false
|
||||||
htmx.config.withCredentials = true
|
htmx.config.withCredentials = true
|
||||||
htmx.config.selfRequestsOnly = false
|
htmx.config.selfRequestsOnly = false
|
||||||
|
htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s)
|
||||||
@@ -12,16 +12,19 @@ export default class extends Controller {
|
|||||||
// Bind event handlers to preserve context
|
// Bind event handlers to preserve context
|
||||||
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
|
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
|
||||||
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
|
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
|
||||||
|
this.boundHandleTimeout = this.handleTimeout.bind(this)
|
||||||
|
|
||||||
// Listen to HTMX events
|
// Listen to HTMX events
|
||||||
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
||||||
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
||||||
|
document.body.addEventListener('htmx:timeout', this.boundHandleTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
// Clean up event listeners
|
// Clean up event listeners
|
||||||
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
||||||
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
||||||
|
document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout)
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
if (this.debounceTimeout) {
|
if (this.debounceTimeout) {
|
||||||
@@ -65,6 +68,14 @@ export default class extends Controller {
|
|||||||
this.hide()
|
this.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTimeout(event) {
|
||||||
|
// Hide loading indicator
|
||||||
|
this.hide()
|
||||||
|
|
||||||
|
// Show user-friendly error message
|
||||||
|
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.')
|
||||||
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.isVisible = true
|
this.isVisible = true
|
||||||
this.indicatorTarget.classList.remove(this.hiddenClass)
|
this.indicatorTarget.classList.remove(this.hiddenClass)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ services:
|
|||||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
bpn_api_ip: '%env(APP_BPN_IP)%'
|
||||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||||
debug: '%env(bool:APP_BPN_DEBUG)%'
|
debug: '%env(bool:APP_BPN_DEBUG)%'
|
||||||
|
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
|
||||||
|
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||||
|
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||||
|
|
||||||
App\Twig\AppRuntime:
|
App\Twig\AppRuntime:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -436,10 +436,13 @@ class ApiClient
|
|||||||
$socket = $this->connect(
|
$socket = $this->connect(
|
||||||
$this->config['bpn_api_ip'],
|
$this->config['bpn_api_ip'],
|
||||||
$this->config['bpn_api_port'],
|
$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);
|
$this->send($socket, $body, $this->config['total_timeout']);
|
||||||
$response = $this->receive($socket);
|
$response = $this->receive($socket, $this->config['total_timeout']);
|
||||||
$this->disconnect($socket);
|
$this->disconnect($socket);
|
||||||
|
|
||||||
// message length (10 bytes) is prepended to actual message
|
// message length (10 bytes) is prepended to actual message
|
||||||
@@ -489,6 +492,9 @@ class ApiClient
|
|||||||
$optionsResolver->setDefaults([
|
$optionsResolver->setDefaults([
|
||||||
'max_retries' => 25,
|
'max_retries' => 25,
|
||||||
'debug' => false,
|
'debug' => false,
|
||||||
|
'connection_timeout' => 5,
|
||||||
|
'stream_timeout' => 30,
|
||||||
|
'total_timeout' => 45,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $optionsResolver->resolve($options);
|
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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -3,14 +3,20 @@
|
|||||||
namespace App\BusProNet\Traits;
|
namespace App\BusProNet\Traits;
|
||||||
|
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
|
|
||||||
trait ApiClientTrait
|
trait ApiClientTrait
|
||||||
{
|
{
|
||||||
|
private float $operationStartTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws ApiClientException
|
* @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;
|
$tries = 1;
|
||||||
$errNo = $errStr = '';
|
$errNo = $errStr = '';
|
||||||
$errorCodesForRetry = [
|
$errorCodesForRetry = [
|
||||||
@@ -18,22 +24,32 @@ trait ApiClientTrait
|
|||||||
SOCKET_EBADF,
|
SOCKET_EBADF,
|
||||||
];
|
];
|
||||||
|
|
||||||
$openSocket = function (&$errNo, &$errStr) use ($host, $port) {
|
$openSocket = function (&$errNo, &$errStr) use ($host, $port, $connectionTimeout) {
|
||||||
return @fsockopen(
|
return @fsockopen(
|
||||||
$host,
|
$host,
|
||||||
$port,
|
$port,
|
||||||
$errNo,
|
$errNo,
|
||||||
$errStr,
|
$errStr,
|
||||||
10
|
$connectionTimeout
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
$socket = $openSocket($errNo, $errStr);
|
$socket = $openSocket($errNo, $errStr);
|
||||||
|
|
||||||
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $maxRetries > $tries) {
|
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', [
|
$this->logger->warning('Could not connect to socket, retrying', [
|
||||||
'error_message' => $errStr,
|
'error_message' => $errStr,
|
||||||
'error_number' => $errNo,
|
'error_number' => $errNo,
|
||||||
|
'attempt' => $tries,
|
||||||
]);
|
]);
|
||||||
++$tries;
|
++$tries;
|
||||||
sleep(1);
|
sleep(1);
|
||||||
@@ -41,11 +57,12 @@ trait ApiClientTrait
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (false !== $socket) {
|
if (false !== $socket) {
|
||||||
stream_set_timeout($socket, 60);
|
stream_set_timeout($socket, $streamTimeout);
|
||||||
} else {
|
} else {
|
||||||
$this->logger->error('Unable to open socket', [
|
$this->logger->error('Unable to open socket', [
|
||||||
'error_message' => $errStr,
|
'error_message' => $errStr,
|
||||||
'error_number' => $errNo,
|
'error_number' => $errNo,
|
||||||
|
'elapsed_time' => microtime(true) - $this->operationStartTime,
|
||||||
]);
|
]);
|
||||||
throw new ApiClientException('Unable to open socket');
|
throw new ApiClientException('Unable to open socket');
|
||||||
}
|
}
|
||||||
@@ -53,19 +70,60 @@ trait ApiClientTrait
|
|||||||
return $socket;
|
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
|
// message length is prepended to actual message
|
||||||
$send = sprintf('%010s', strlen($data)).$data;
|
$send = sprintf('%010s', strlen($data)).$data;
|
||||||
fwrite($socket, $send);
|
fwrite($socket, $send);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function receive($socket): string
|
/**
|
||||||
|
* @throws TimeoutException
|
||||||
|
*/
|
||||||
|
private function receive($socket, int $totalTimeout): string
|
||||||
{
|
{
|
||||||
$response = '';
|
$response = '';
|
||||||
|
$readAttempts = 0;
|
||||||
|
|
||||||
while (false === feof($socket)) {
|
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;
|
return $response;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||||
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||||
@@ -116,6 +117,17 @@ class Step3Controller extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4'));
|
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4'));
|
||||||
|
} catch (TimeoutException $e) {
|
||||||
|
return $this->handleApiError(
|
||||||
|
'Booking inquiry timeout',
|
||||||
|
[
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
],
|
||||||
|
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.',
|
||||||
|
$bookingCreateDto,
|
||||||
|
$form
|
||||||
|
);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->handleApiError(
|
return $this->handleApiError(
|
||||||
'Booking inquiry exception',
|
'Booking inquiry exception',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller\Booking\Create;
|
namespace App\Controller\Booking\Create;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||||
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||||
@@ -94,6 +95,17 @@ class Step4Controller extends AbstractController
|
|||||||
$this->bookingService->clearBookingCreateDto($request);
|
$this->bookingService->clearBookingCreateDto($request);
|
||||||
|
|
||||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success'));
|
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success'));
|
||||||
|
} catch (TimeoutException $e) {
|
||||||
|
return $this->handleApiError(
|
||||||
|
'Booking creation timeout',
|
||||||
|
[
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
],
|
||||||
|
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.',
|
||||||
|
$bookingCreateDto,
|
||||||
|
$form
|
||||||
|
);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->handleApiError(
|
return $this->handleApiError(
|
||||||
'Booking creation exception',
|
'Booking creation exception',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Controller\Booking\Edit;
|
|||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
use App\BusProNet\Exception\ApiClientException;
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Exception\TimeoutException;
|
||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\Controller\Booking\Traits;
|
use App\Controller\Booking\Traits;
|
||||||
use App\Controller\Booking\Traits\BookingDataTrait;
|
use App\Controller\Booking\Traits\BookingDataTrait;
|
||||||
@@ -146,6 +147,13 @@ class IndexController extends AbstractController
|
|||||||
|
|
||||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
|
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
|
||||||
}
|
}
|
||||||
|
} catch (TimeoutException $e) {
|
||||||
|
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.');
|
||||||
|
$this->logger->error('Booking update timeout', [
|
||||||
|
'email' => $email,
|
||||||
|
'booking_id' => $id,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
|
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Form\Model;
|
namespace App\Form\Model;
|
||||||
|
|
||||||
use App\Validator\Constraints as AppAssert;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|||||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||||
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
|
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class CmsDataService
|
|||||||
'tx_epproducts_api[code]' => $code,
|
'tx_epproducts_api[code]' => $code,
|
||||||
'tx_epproducts_api[action]' => $mode,
|
'tx_epproducts_api[action]' => $mode,
|
||||||
'tx_epproducts_api[controller]' => 'Api',
|
'tx_epproducts_api[controller]' => 'Api',
|
||||||
]
|
],
|
||||||
]);
|
]);
|
||||||
} catch (ExceptionInterface $e) {
|
} catch (ExceptionInterface $e) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ParticipantEligibilityService
|
|||||||
{
|
{
|
||||||
/** @var array<string, bool> Request-scoped cache for participant eligibility */
|
/** @var array<string, bool> Request-scoped cache for participant eligibility */
|
||||||
private array $eligibilityCache = [];
|
private array $eligibilityCache = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a participant is eligible for booking.
|
* Checks if a participant is eligible for booking.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user