feat: improved timeout handling for api client
This commit is contained in:
@@ -45,6 +45,9 @@ APP_BPN_PASSWORD=
|
||||
APP_BPN_IP=
|
||||
APP_BPN_PORT=
|
||||
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_CMS_API_BASE_URL="https://www.ep-reisen.de/"
|
||||
|
||||
@@ -11,3 +11,4 @@ htmx.config.historyCacheSize = 0
|
||||
htmx.config.allowScriptTags = false
|
||||
htmx.config.withCredentials = true
|
||||
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
|
||||
this.boundHandleBeforeRequest = this.handleBeforeRequest.bind(this)
|
||||
this.boundHandleAfterRequest = this.handleAfterRequest.bind(this)
|
||||
this.boundHandleTimeout = this.handleTimeout.bind(this)
|
||||
|
||||
// Listen to HTMX events
|
||||
document.body.addEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
||||
document.body.addEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
||||
document.body.addEventListener('htmx:timeout', this.boundHandleTimeout)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listeners
|
||||
document.body.removeEventListener('htmx:beforeRequest', this.boundHandleBeforeRequest)
|
||||
document.body.removeEventListener('htmx:afterRequest', this.boundHandleAfterRequest)
|
||||
document.body.removeEventListener('htmx:timeout', this.boundHandleTimeout)
|
||||
|
||||
// Clear any pending timeout
|
||||
if (this.debounceTimeout) {
|
||||
@@ -65,6 +68,14 @@ export default class extends Controller {
|
||||
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() {
|
||||
this.isVisible = true
|
||||
this.indicatorTarget.classList.remove(this.hiddenClass)
|
||||
|
||||
@@ -37,6 +37,9 @@ services:
|
||||
bpn_api_ip: '%env(APP_BPN_IP)%'
|
||||
bpn_api_port: '%env(APP_BPN_PORT)%'
|
||||
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:
|
||||
arguments:
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||
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'));
|
||||
} 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) {
|
||||
return $this->handleApiError(
|
||||
'Booking inquiry exception',
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||
@@ -94,6 +95,17 @@ class Step4Controller extends AbstractController
|
||||
$this->bookingService->clearBookingCreateDto($request);
|
||||
|
||||
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) {
|
||||
return $this->handleApiError(
|
||||
'Booking creation exception',
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller\Booking\Edit;
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Booking\Traits;
|
||||
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]));
|
||||
}
|
||||
} 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) {
|
||||
$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;
|
||||
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
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\Http\Authenticator\AbstractLoginFormAuthenticator;
|
||||
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\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
@@ -37,7 +37,7 @@ class CmsDataService
|
||||
'tx_epproducts_api[code]' => $code,
|
||||
'tx_epproducts_api[action]' => $mode,
|
||||
'tx_epproducts_api[controller]' => 'Api',
|
||||
]
|
||||
],
|
||||
]);
|
||||
} catch (ExceptionInterface $e) {
|
||||
return [
|
||||
|
||||
@@ -24,6 +24,7 @@ class ParticipantEligibilityService
|
||||
{
|
||||
/** @var array<string, bool> Request-scoped cache for participant eligibility */
|
||||
private array $eligibilityCache = [];
|
||||
|
||||
/**
|
||||
* Checks if a participant is eligible for booking.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user