config = $this->resolveOptions($options); } /** * @throws ApiClientException */ public function getPersonalData(string $email, string $password): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adressdaten', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function register(RegistrationDto $registrationData): Notification|RegistrationResponse { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => $registrationData->toPayload(), ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function resetPassword(string $email): Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Passwort_Anfrage', 'email' => $email, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function updatePersonalData( string $email, string $password, PersonalData $personalData, bool $debug = false, ): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adressdaten_Ändern', 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, 'adressdaten' => $personalData->toPayload(), ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } /** * @throws ApiClientException */ public function createAddress( PersonalData $personalData, bool $debug = false, ): RegistrationResponse|Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Adresse_Neu', 'adressdaten' => $personalData->toPayload(false), 'ohnemailversand' => 'True', ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data, [], $debug); } /** * @throws ApiClientException */ public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Newsletter', 'email' => $email, 'passwort' => $password, 'idadresse' => $personalData->addressId, 'newsletter' => [ 'email' => $email, 'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False', ], ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBookings(string $email, string $password): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Vorgänge', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBooking(string $email, string $password, int $id): Notification|Booking { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'Vorgang_Details', 'email' => $email, 'passwort' => $password, 'idbuchung' => $id, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate { $payload = $this->bookingDataProcessor->createUpdateRequestPayload($formData); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE), 'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE], 'buchungsart' => Constants::BOOKING_TYPE_BOOKING, ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING_UPDATE, $data, [], $debug); } /** * Submits a booking inquiry for validation. * * First phase of the two-phase booking process. Validates all booking data * and returns pricing information without creating an actual booking. * * @param BookingDto $bookingDto The booking creation form data * @param bool $debug Enable debug mode (XML dumps) * * @return Notification|BookingResponse Notification on error, BookingResponse on success * * @throws ApiClientException If the API request fails */ public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); } /** * Submits the final booking request. * * Second phase of the two-phase booking process. Creates the actual booking * after successful inquiry validation. * * @param BookingDto $bookingDto The booking creation form data * @param bool $debug Enable debug mode (XML dumps) * * @return Notification|BookingResponse Notification on error, BookingResponse with booking number on success * * @throws ApiClientException If the API request fails */ public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse { $payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_BOOKING); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING), 'satz' => ['@typ' => static::TYPE_BOOKING], ...$payload, ]; return $this->sendRequest(static::TYPE_BOOKING, $data, [], $debug); } /** * @throws ApiClientException */ public function getMutableData(int $dateId): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_MUTABLE_DATA), 'satz' => ['@typ' => static::TYPE_MUTABLE_DATA], 'idreise' => $dateId, ]; return $this->sendRequest(static::TYPE_MUTABLE_DATA, $data); } /** * @throws ApiClientException */ public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY), 'satz' => ['@typ' => static::TYPE_AVAILABILITY], 'idreise' => $dateId, ]; return $this->sendRequest(static::TYPE_AVAILABILITY, $data); } /** * @throws ApiClientException */ public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AVAILABILITY_HOTEL), 'satz' => ['@typ' => static::TYPE_AVAILABILITY_HOTEL], 'idreise' => $dateId, 'idpartner' => $hotelId, 'terminbis' => $dateTo->format('d.m.Y'), ]; return $this->sendRequest(static::TYPE_AVAILABILITY_HOTEL, $data); } /** * @throws ApiClientException */ public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => 'SelektionCRM', 'email' => $email, 'passwort' => $password, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getBaseData(string $type): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], $type), 'satz' => ['@typ' => $type], ]; return $this->sendRequest($type, $data); } /** * @throws ApiClientException */ public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_CUSTOMER_DATA), 'satz' => ['@typ' => static::TYPE_CUSTOMER_DATA], 'art' => $type, 'email' => $email, 'passwort' => $password, 'idbuchung' => $bookingId, ]; return $this->sendRequest(static::TYPE_CUSTOMER_DATA, $data); } /** * @throws ApiClientException */ public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA), 'satz' => ['@typ' => static::TYPE_PRODUCT_DATA], 'idprodukt' => $travelId, ]; return $this->sendRequest(static::TYPE_PRODUCT_DATA, $data, ['hotelId' => $hotelId]); } /** * Fetches raw XML travel data from the BusProNet API. * * Returns the unprocessed XML response for direct storage in the XML export directory. * The response format matches the XML export structure from BusPro. * * @param int $travelId The travel product ID to fetch * * @return string The raw XML response * * @throws ApiClientException If the API request fails */ public function getTravelDataXml(int $travelId): string { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCT_DATA), 'satz' => ['@typ' => static::TYPE_PRODUCT_DATA], 'idprodukt' => $travelId, ]; return $this->sendRequestRaw($data); } /** * @throws ApiClientException */ public function getProducts(): Notification|BaseData { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PRODUCTS), 'satz' => ['@typ' => static::TYPE_PRODUCTS], ]; return $this->sendRequest(static::TYPE_PRODUCTS, $data); } /** * Fetches all available agencies from the BusProNet API. * * Returns a list of all agencies with their contact information. * This data is typically cached for long periods as it changes infrequently. * * @return Agency[]|Notification Array of Agency objects on success, Notification on error * * @throws ApiClientException If the API request fails */ public function getAgencies(): array|Notification { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_AGENCIES), 'satz' => ['@typ' => static::TYPE_AGENCIES], ]; return $this->sendRequest(static::TYPE_AGENCIES, $data); } /** * Validates a purchase voucher by redemption code. * * Purchase vouchers (Gutscheine) have a remaining balance that reduces with each redemption. * They can be regular purchased vouchers or goodwill vouchers (Kulanz). * Returns the voucher with remaining balance, or a Notification if not found or invalid. * * @param string $redemptionCode The voucher redemption code (einloesecode) * * @return Notification|PurchaseVoucher Notification on error, PurchaseVoucher on success * * @throws ApiClientException If the API request fails */ public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher { $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PURCHASE_VOUCHER), 'satz' => ['@typ' => static::TYPE_PURCHASE_VOUCHER], 'einloesecode' => $redemptionCode, ]; return $this->sendRequest(static::TYPE_PURCHASE_VOUCHER, $data); } /** * Validates a promo voucher for a specific travel and participant price. * * Promo vouchers (Aktionsgutscheine) provide absolute discounts per person or per booking. * Applicability is indicated by the pro_buchung_person field: "P" = per person, "B" = per booking. * * @param string $promoCode The promo voucher code * @param int $travelId The travel ID this voucher applies to * @param float $participantPrice Current participant price for validation * * @return Notification|PromoVoucher Notification on error, PromoVoucher on success * * @throws ApiClientException If the API request fails */ public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher { // Format price to German format (comma decimal separator) $priceFormatted = number_format($participantPrice, 2, ',', ''); $data = [ 'user' => $this->config['bpn_username'], 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PROMO_VOUCHER), 'satz' => ['@typ' => static::TYPE_PROMO_VOUCHER], 'aktionsgutschein' => $promoCode, 'idreise' => $travelId, 'preis' => $priceFormatted, ]; return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); } /** * Sends raw XML to the BPN API with key regeneration and automatic retry. * * Parses the XML to extract the request type, regenerates the authentication key * with the current date, and sends the request. Returns the raw XML response. * Automatically retries on immediate connection close (server busy). * * @param string $xml The raw XML request body * @param bool $debug Enable debug mode (XML dumps) * * @return string The raw XML response * * @throws ApiClientException If the request fails or XML is invalid */ public function sendRawXml(string $xml, bool $debug = false): string { return $this->executeWithRetry(fn () => $this->doSendRawXml($xml, $debug)); } /** * Performs the actual raw XML request to the BPN API. * * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doSendRawXml(string $xml, bool $debug = false): string { $requestId = $this->getRequestId(); $doc = new \DOMDocument(); if (false === @$doc->loadXML($xml)) { throw new ApiClientException('Invalid XML provided'); } $satzNode = $doc->getElementsByTagName('satz')->item(0); if (null === $satzNode) { throw new ApiClientException('Missing element in XML'); } $type = $satzNode->getAttribute('typ'); if ('' === $type) { throw new ApiClientException('Missing typ attribute on element'); } $keyNode = $doc->getElementsByTagName('key')->item(0); if (null === $keyNode) { throw new ApiClientException('Missing element in XML'); } $newKey = $this->createKey( $this->config['bpn_username'], $this->config['bpn_password'], $type ); $keyNode->nodeValue = $newKey; $body = $doc->saveXML(); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect(); $this->logger->info('Sending raw XML request to BPN API', [ 'requestId' => $requestId, 'type' => $type, 'port' => $this->selectedPort, ]); $this->send($socket, $body); $response = $this->receive($socket); $this->disconnect($socket); $responseXml = substr($response, 10); if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('response', $requestId, $responseXml); } return $responseXml; } /** * @throws ApiClientException */ private function sendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed { return $this->executeWithRetry(fn () => $this->doSendRequest($type, $data, $additionalArgs, $debug), $type); } /** * @throws ApiClientException */ private function sendRequestRaw(array $data): string { return $this->executeWithRetry(fn () => $this->doSendRequestRaw($data)); } /** * Executes an operation with automatic retry on immediate connection close. * * When the BPN server is busy, it may close connections immediately without responding. * This method detects such conditions and automatically retries after a short delay. * * @throws ImmediateConnectionCloseException If all retry attempts fail */ private function executeWithRetry(callable $operation, ?string $type = null): mixed { $maxAttempts = $this->config['busy_retry_attempts']; $retryDelay = $this->config['busy_retry_delay']; $lastException = null; for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { try { return $operation(); } catch (ImmediateConnectionCloseException $e) { $lastException = $e; if ($attempt < $maxAttempts) { $context = [ 'attempt' => $attempt, 'maxAttempts' => $maxAttempts, 'retryDelay' => $retryDelay, ]; if (null !== $type) { $context['type'] = $type; } $this->logger->warning('BPN server busy, retrying request', $context); sleep($retryDelay); } } } $context = ['attempts' => $maxAttempts]; if (null !== $type) { $context['type'] = $type; } $this->logger->error('BPN server busy after all retry attempts', $context); throw $lastException; } /** * Performs the actual request to the BPN API. * * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doSendRequest(string $type, array $data, array $additionalArgs = [], bool $debug = false): mixed { $requestId = $this->getRequestId(); $body = $this ->serializer ->serialize($data, 'xml', [ XmlEncoder::ROOT_NODE_NAME => 'anfrage', XmlEncoder::ENCODING => 'UTF-8', ]) ; if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect(); $this->logger->info('Sending request to BPN API', [ 'requestId' => $requestId, 'type' => $data['satz']['@typ'], 'port' => $this->selectedPort, ]); $this->send($socket, $body); $response = $this->receive($socket); $this->disconnect($socket); // message length (10 bytes) is prepended to actual message $xml = substr($response, 10); if ('' === $xml) { $this->logger->error('Empty response body received from API (header-only response)', [ 'request_id' => $requestId, 'raw_length' => strlen($response), ]); throw new ApiClientException('Empty response body received from API'); } if (true === $debug || true === $this->config['debug']) { $this->dumpXmlToFile('response', $requestId, $xml); } try { return $this->responseParser->parseXmlString($type, $xml, $additionalArgs); } catch (ResponseParserException $e) { $this->dumpXmlToFile('response', $requestId, $xml); } $this->logger->error('Unexpected response received from API', [ 'request_id' => $requestId, ]); throw new ApiClientException('Unexpected response received from API'); } /** * @throws ApiClientException * @throws ImmediateConnectionCloseException */ private function doSendRequestRaw(array $data): string { $requestId = $this->getRequestId(); $body = $this->serializer->serialize($data, 'xml', [ XmlEncoder::ROOT_NODE_NAME => 'anfrage', XmlEncoder::ENCODING => 'UTF-8', ]); if (true === $this->config['debug']) { $this->dumpXmlToFile('request', $requestId, $body); } $socket = $this->connect(); $this->logger->info('Sending raw request to BPN API', [ 'requestId' => $requestId, 'type' => $data['satz']['@typ'], 'port' => $this->selectedPort, ]); $this->send($socket, $body); $response = $this->receive($socket); $this->disconnect($socket); $xml = substr($response, 10); if (true === $this->config['debug']) { $this->dumpXmlToFile('response', $requestId, $xml); } return $xml; } private function dumpXmlToFile(string $type, string $requestId, string $body): void { try { $this->xmlDump->write($requestId.'_'.$type.'.xml', $body); } catch (FilesystemException $e) { } } private function getRequestId(): string { $baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id') ?? $this->requestIdGenerator->generateBaseId(); return $baseId.'_'.++$this->requestCounter; } private function createKey(string $username, string $password, string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); return md5($username.$password.$date.$type); } /** * @throws ApiClientException * @throws TimeoutException */ private function connect() { // Randomly select a port from the available ports for load balancing $this->selectedPort = $this->config['bpn_api_ports'][array_rand($this->config['bpn_api_ports'])]; $this->operationStartTime = microtime(true); $tries = 1; $errNo = $errStr = ''; $errorCodesForRetry = [ SOCKET_ECONNREFUSED, SOCKET_EBADF, ]; $openSocket = function (&$errNo, &$errStr) { return @fsockopen( $this->config['bpn_api_ip'], $this->selectedPort, $errNo, $errStr, $this->config['connection_timeout'] ); }; $socket = $openSocket($errNo, $errStr); while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->config['max_retries'] > $tries) { // Check if we've exceeded total timeout during retries if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) { $this->logger->error('Connection retry timeout exceeded', [ 'elapsed_time' => microtime(true) - $this->operationStartTime, 'total_timeout' => $this->config['total_timeout'], ]); 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); $socket = $openSocket($errNo, $errStr); } if (false !== $socket) { stream_set_timeout($socket, $this->config['stream_timeout']); } 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'); } return $socket; } /** * @throws TimeoutException */ private function send($socket, string $data): void { // Check total timeout before sending if (microtime(true) - $this->operationStartTime > $this->config['total_timeout']) { $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); } /** * @throws TimeoutException * @throws ImmediateConnectionCloseException */ private function receive($socket): string { $response = ''; $readAttempts = 0; $totalTimeout = $this->config['total_timeout']; while (false === feof($socket)) { // 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']) { // Check if this is an immediate rejection (0 bytes, < 1 second) // This indicates the server is busy rather than a true timeout if (0 === strlen($response) && $elapsedTime < 1.0) { $this->logger->warning('Server closed connection immediately', [ 'elapsed_time' => $elapsedTime, 'bytes_received' => 0, 'read_attempts' => $readAttempts, ]); throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy'); } $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; } if (0 === strlen($response)) { $elapsedTime = microtime(true) - $this->operationStartTime; $this->logger->warning('Server closed connection without sending data', [ 'elapsed_time' => $elapsedTime, 'read_attempts' => $readAttempts, ]); throw new ImmediateConnectionCloseException('Server closed connection without sending data'); } return $response; } private function disconnect($socket): void { @fclose($socket); } private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); $optionsResolver->setRequired([ 'bpn_username', 'bpn_password', 'bpn_api_ip', 'bpn_api_ports', ]); $optionsResolver->setAllowedTypes('bpn_api_ports', ['array']); $optionsResolver->setDefaults([ 'max_retries' => 25, 'debug' => false, 'connection_timeout' => 5, 'stream_timeout' => 30, 'total_timeout' => 45, 'busy_retry_attempts' => 3, 'busy_retry_delay' => 1, ]); return $optionsResolver->resolve($options); } }