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
+3
View File
@@ -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/"
+1
View File
@@ -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)
+11
View File
@@ -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)
+3
View File
@@ -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:
+9 -3
View File
@@ -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
{
}
+65 -7
View File
@@ -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');
} }
-1
View File
@@ -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;
-1
View File
@@ -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;
+1 -1
View File
@@ -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.
* *