fix: make BusProNet response failures diagnosable and non-fatal

This commit is contained in:
fromme committed 2026-09-19 11:36:14 +02:00
1 parent 29cedd5916
commit a05143bfbe
8 files changed
+207 -19

No files matched your search

+24 -5
View File
@@ -162,17 +162,36 @@ class ApiClient
$this->config['max_retries']
);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
try {
// the length header is consumed by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
return $this->responseParser->parseXmlString($type, $xml);
try {
return $this->responseParser->parseXmlString($type, $xml);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if (true !== $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
$this->logger->error('Unable to parse BusProNet response', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw $e;
}
}
private function dumpXmlToFile(string $type, string $requestId, string $body): void
+47 -4
View File
@@ -58,15 +58,58 @@ trait ApiClientTrait
fwrite($socket, $send);
}
/**
* Reads a single response message. The protocol prepends the payload length as a
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
* a peer that closes mid-stream would otherwise yield a silently truncated body.
*
* @throws ApiClientException
*/
private function receive($socket): string
{
$response = '';
$header = $this->readBytes($socket, 10);
while (false === feof($socket)) {
$response .= fread($socket, 4096);
if (10 !== strlen($header)) {
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
}
return $response;
$expectedLength = (int) trim($header);
if (1 > $expectedLength) {
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
$body = $this->readBytes($socket, $expectedLength);
if (strlen($body) !== $expectedLength) {
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
}
return $body;
}
/**
* @throws ApiClientException
*/
private function readBytes($socket, int $length): string
{
$buffer = '';
while (strlen($buffer) < $length && false === feof($socket)) {
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
if (false === $chunk || '' === $chunk) {
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
}
break;
}
$buffer .= $chunk;
}
return $buffer;
}
private function disconnect($socket): void
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class CountryDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
$countries = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
@@ -36,7 +37,8 @@ class HotelDataProvider
return $response->getItems();
});
} catch (ApiClientException|InvalidArgumentException $e) {
} catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
$hotels = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class PickupDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
$pickups = [];
}
+46 -5
View File
@@ -30,10 +30,7 @@ class ResponseParser
*/
public function parseXmlString(string $type, string $content): mixed
{
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException('Unable to parse XML response');
}
$xml = $this->loadXml($content);
// Override type when present in XML to catch error responses
$responseType = $type;
@@ -65,7 +62,7 @@ class ResponseParser
return $this->createHotelsResponse($xml);
}
throw new ResponseParserException('Unable to parse XML response');
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
}
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
@@ -302,6 +299,50 @@ class ResponseParser
return new BaseDataResponse($hotels);
}
/**
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
* reason instead of collapsing every shape of broken response into one message.
*
* @throws ResponseParserException
*/
private function loadXml(string $content): \SimpleXMLElement
{
if ('' === trim($content)) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
}
return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
private function describeLibxmlErrors(): string
{
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return 'unknown XML error';
}
return implode('; ', array_unique($messages));
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();