From bcded403bea2ee7594f5527810df638a2b3babd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 13 Nov 2025 11:15:31 +0100 Subject: [PATCH] feat: improved timeout handling for api client --- .env | 3 + assets/app.js | 3 +- assets/controllers/loading_controller.js | 11 +++ config/services.yaml | 3 + src/BusProNet/ApiClient.php | 12 +++- src/BusProNet/Exception/TimeoutException.php | 12 ++++ src/BusProNet/Traits/ApiClientTrait.php | 72 +++++++++++++++++-- .../Booking/Create/Step3Controller.php | 12 ++++ .../Booking/Create/Step4Controller.php | 12 ++++ .../Booking/Edit/IndexController.php | 8 +++ src/Form/Model/ParticipantEditDto.php | 1 - src/Security/BpnAuthenticator.php | 1 - src/Service/CmsDataService.php | 2 +- src/Service/ParticipantEligibilityService.php | 1 + 14 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 src/BusProNet/Exception/TimeoutException.php diff --git a/.env b/.env index 2d683e2..6dc1148 100644 --- a/.env +++ b/.env @@ -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/" diff --git a/assets/app.js b/assets/app.js index f65d63f..a0cf73a 100644 --- a/assets/app.js +++ b/assets/app.js @@ -10,4 +10,5 @@ htmx.config.historyEnabled = false htmx.config.historyCacheSize = 0 htmx.config.allowScriptTags = false htmx.config.withCredentials = true -htmx.config.selfRequestsOnly = false \ No newline at end of file +htmx.config.selfRequestsOnly = false +htmx.config.timeout = 50000 // 50 seconds - slightly higher than backend timeout (45s) \ No newline at end of file diff --git a/assets/controllers/loading_controller.js b/assets/controllers/loading_controller.js index 8b0cab1..96a483e 100644 --- a/assets/controllers/loading_controller.js +++ b/assets/controllers/loading_controller.js @@ -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) diff --git a/config/services.yaml b/config/services.yaml index 0a1540a..00a9814 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index eae914e..32a940f 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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); diff --git a/src/BusProNet/Exception/TimeoutException.php b/src/BusProNet/Exception/TimeoutException.php new file mode 100644 index 0000000..a82e521 --- /dev/null +++ b/src/BusProNet/Exception/TimeoutException.php @@ -0,0 +1,12 @@ +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; diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index b2f4132..42fac61 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -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', diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 678ac50..7afdb3e 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -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', diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 822bd3f..b3baa63 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -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'); } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 1e23862..5c3a4d5 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -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; diff --git a/src/Security/BpnAuthenticator.php b/src/Security/BpnAuthenticator.php index 125cdcf..b6ee59d 100644 --- a/src/Security/BpnAuthenticator.php +++ b/src/Security/BpnAuthenticator.php @@ -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; diff --git a/src/Service/CmsDataService.php b/src/Service/CmsDataService.php index 18fd708..e4fb326 100644 --- a/src/Service/CmsDataService.php +++ b/src/Service/CmsDataService.php @@ -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 [ diff --git a/src/Service/ParticipantEligibilityService.php b/src/Service/ParticipantEligibilityService.php index f9824c0..6a46cc6 100644 --- a/src/Service/ParticipantEligibilityService.php +++ b/src/Service/ParticipantEligibilityService.php @@ -24,6 +24,7 @@ class ParticipantEligibilityService { /** @var array Request-scoped cache for participant eligibility */ private array $eligibilityCache = []; + /** * Checks if a participant is eligible for booking. *