feat: improved timeout handling for api client

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent be90248fc7
commit 37924841e1
14 changed files with 139 additions and 14 deletions
+3
View File
@@ -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/"
+2 -1
View File
@@ -10,4 +10,5 @@ htmx.config.historyEnabled = false
htmx.config.historyCacheSize = 0
htmx.config.allowScriptTags = false
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
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)
+3
View File
@@ -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:
+9 -3
View File
@@ -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
{
}
+65 -7
View File
@@ -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');
}
-1
View File
@@ -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;
-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\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;
+1 -1
View File
@@ -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.
*