From cf970d2b49450189bf2ad0ea7ba7769d9c887815 Mon Sep 17 00:00:00 2001 From: team2 Date: Sat, 25 Apr 2026 20:16:40 +0200 Subject: [PATCH] fix sse error handling if shop api error part 1 --- src/Commerce/ShopSearchService.php | 67 ++++++++++ src/Shopware/ShopwareCriteriaBuilder.php | 148 ++++++++++++++++------- src/Shopware/StoreApiClient.php | 59 +++++---- 3 files changed, 204 insertions(+), 70 deletions(-) diff --git a/src/Commerce/ShopSearchService.php b/src/Commerce/ShopSearchService.php index 306d07c..2523381 100644 --- a/src/Commerce/ShopSearchService.php +++ b/src/Commerce/ShopSearchService.php @@ -383,6 +383,18 @@ final class ShopSearchService if ($safeResults !== null) { return $safeResults; } + + $minimalResults = $this->retryWithMinimalCriteria( + query: $query, + commerceIntent: $commerceIntent, + originalPrompt: $originalPrompt, + usesHistoryContext: $usesHistoryContext, + previousException: $e + ); + + if ($minimalResults !== null) { + return $minimalResults; + } } $this->recordFailedSearch($e); @@ -458,6 +470,61 @@ final class ShopSearchService } } + /** + * @return ShopProductResult[]|null + */ + private function retryWithMinimalCriteria( + CommerceSearchQuery $query, + string $commerceIntent, + string $originalPrompt, + bool $usesHistoryContext, + StoreApiException $previousException + ): ?array { + $this->logger->warning('Shop search retrying with minimal UTF-8-safe criteria', [ + 'commerceIntent' => $commerceIntent, + 'originalPrompt' => $originalPrompt, + 'normalizedPrompt' => $query->normalizedPrompt, + 'searchText' => $query->searchText, + 'usesHistoryContext' => $usesHistoryContext, + 'previousStatusCode' => $previousException->getStatusCode(), + 'previousUtf8Failure' => $previousException->isUtf8Failure(), + 'previousExceptionMessage' => $previousException->getMessage(), + ]); + + try { + $minimalCriteria = $this->criteriaBuilder->buildMinimal($query, $this->maxResults); + $response = $this->storeApiClient->searchProducts($minimalCriteria); + + return $this->mapAndLogSearchResponse( + response: $response, + query: $query, + commerceIntent: $commerceIntent, + originalPrompt: $originalPrompt, + usesHistoryContext: $usesHistoryContext, + usedSafeCriteria: true + ); + } catch ( + ClientExceptionInterface | + RedirectionExceptionInterface | + ServerExceptionInterface | + TransportExceptionInterface | + \RuntimeException $minimalException + ) { + $this->recordFailedSearch($minimalException); + $this->logger->warning('Shop search minimal UTF-8-safe criteria retry failed', [ + 'commerceIntent' => $commerceIntent, + 'originalPrompt' => $originalPrompt, + 'normalizedPrompt' => $query->normalizedPrompt, + 'searchText' => $query->searchText, + 'usesHistoryContext' => $usesHistoryContext, + 'exceptionClass' => $minimalException::class, + 'exceptionMessage' => $minimalException->getMessage(), + ]); + + return null; + } + } + /** * @param array $response * @return ShopProductResult[] diff --git a/src/Shopware/ShopwareCriteriaBuilder.php b/src/Shopware/ShopwareCriteriaBuilder.php index 4eaf16e..614754c 100644 --- a/src/Shopware/ShopwareCriteriaBuilder.php +++ b/src/Shopware/ShopwareCriteriaBuilder.php @@ -36,7 +36,32 @@ final class ShopwareCriteriaBuilder query: $query, limit: $limit, grouping: $grouping, - includeRichTextFields: false + includeRichTextFields: false, + includeMediaFields: false, + includeSeoUrls: true, + includeManufacturer: true + ); + } + + /** + * Builds the smallest useful Store API payload for UTF-8 recovery. + * It intentionally omits rich text, custom fields and media metadata, + * because those fields are the most common source of invalid response encoding. + */ + public function buildMinimal( + CommerceSearchQuery $query, + ?int $limit = 25, + ?bool $grouping = true + ): array + { + return $this->buildCriteria( + query: $query, + limit: $limit, + grouping: $grouping, + includeRichTextFields: false, + includeMediaFields: false, + includeSeoUrls: true, + includeManufacturer: false ); } @@ -44,7 +69,10 @@ final class ShopwareCriteriaBuilder CommerceSearchQuery $query, ?int $limit, ?bool $grouping, - bool $includeRichTextFields + bool $includeRichTextFields, + bool $includeMediaFields = true, + bool $includeSeoUrls = true, + bool $includeManufacturer = true ): array { $productIncludes = [ 'id', @@ -52,61 +80,91 @@ final class ShopwareCriteriaBuilder 'productNumber', 'available', 'calculatedPrice', - 'seoUrls', - 'manufacturer', 'translated.name', - 'cover', ]; + if ($includeSeoUrls) { + $productIncludes[] = 'seoUrls'; + } + + if ($includeManufacturer) { + $productIncludes[] = 'manufacturer'; + } + + if ($includeMediaFields) { + $productIncludes[] = 'cover'; + } + if ($includeRichTextFields) { $productIncludes[] = 'description'; $productIncludes[] = 'customFields'; } + $includes = [ + 'product' => $productIncludes, + 'calculated_price' => [ + 'unitPrice', + 'totalPrice', + 'referencePrice', + 'listPrice', + 'regulationPrice' + ], + ]; + + if ($includeManufacturer) { + $includes['product_manufacturer'] = [ + 'name', + ]; + } + + if ($includeSeoUrls) { + $includes['seo_url'] = [ + 'seoPathInfo', + ]; + } + + if ($includeMediaFields) { + $includes['product_media'] = [ + 'id', + 'media' + ]; + $includes['media'] = [ + 'id', + 'url', + 'thumbnails', + 'alt', + 'title' + ]; + } + + $associations = []; + + if ($includeManufacturer) { + $associations['manufacturer'] = new \stdClass(); + } + + if ($includeSeoUrls) { + $associations['seoUrls'] = new \stdClass(); + } + + if ($includeMediaFields) { + $associations['cover'] = [ + 'associations' => [ + 'media' => [ + 'associations' => [ + 'thumbnails' => new \stdClass() + ] + ] + ] + ]; + } + $criteria = [ 'page' => 1, 'limit' => max(1, $limit), 'total-count-mode' => 0, - 'includes' => [ - 'product' => $productIncludes, - 'product_manufacturer' => [ - 'name', - ], - 'calculated_price' => [ - 'unitPrice', - 'totalPrice', - 'referencePrice', - 'listPrice', - 'regulationPrice' - ], - 'seo_url' => [ - 'seoPathInfo', - ], - 'product_media' => [ - 'id', - 'media' - ], - 'media' => [ - 'id', - 'url', - 'thumbnails', - 'alt', - 'title' - ] - ], - 'associations' => [ - 'manufacturer' => new \stdClass(), - 'seoUrls' => new \stdClass(), - 'cover' => [ - 'associations' => [ - 'media' => [ - 'associations' => [ - 'thumbnails' => new \stdClass() - ] - ] - ] - ] - ] + 'includes' => $includes, + 'associations' => $associations, ]; if ($grouping) { diff --git a/src/Shopware/StoreApiClient.php b/src/Shopware/StoreApiClient.php index 09dd357..5bf26ed 100644 --- a/src/Shopware/StoreApiClient.php +++ b/src/Shopware/StoreApiClient.php @@ -33,13 +33,13 @@ final readonly class StoreApiClient $url = rtrim($this->baseUrl, '/') . '/store-api/search'; $sanitizedCriteria = $this->sanitizeValue($criteria); - $body = json_encode( - $sanitizedCriteria, - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE - ); - - if (!is_string($body)) { - throw new RuntimeException('Failed to encode Store API criteria.'); + try { + $body = json_encode( + $sanitizedCriteria, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE + ); + } catch (\JsonException $e) { + throw new RuntimeException('Failed to encode Store API criteria as valid JSON.', 0, $e); } $response = $this->httpClient->request('POST', $url, [ @@ -83,9 +83,10 @@ final readonly class StoreApiClient return new StoreApiException( sprintf( - 'Shopware Store API request failed with status %d. Response: %s', + 'Shopware Store API request failed with status %d. Response: %s%s', $statusCode, - $preview + $preview, + $utf8Failure ? ' Hint: The request body was valid JSON; this Shopware error usually means Shopware failed while encoding response/product data as UTF-8.' : '' ), $statusCode, $serverFailure, @@ -126,26 +127,34 @@ final readonly class StoreApiClient private function sanitizeString(string $value): string { - if (preg_match('//u', $value) === 1) { - return $value; - } + if (preg_match('//u', $value) !== 1) { + if (function_exists('mb_convert_encoding')) { + $value = mb_convert_encoding($value, 'UTF-8', 'UTF-8'); + } - if (function_exists('mb_convert_encoding')) { - $value = mb_convert_encoding($value, 'UTF-8', 'UTF-8'); - } + if (preg_match('//u', $value) !== 1 && function_exists('iconv')) { + $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value); - if (preg_match('//u', $value) === 1) { - return $value; - } - - if (function_exists('iconv')) { - $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value); - - if (is_string($converted) && $converted !== '') { - return $converted; + if (is_string($converted)) { + $value = $converted; + } } } - return ''; + if (preg_match('//u', $value) !== 1) { + return ''; + } + + if (class_exists('Normalizer')) { + $normalized = \Normalizer::normalize($value, \Normalizer::FORM_C); + + if (is_string($normalized)) { + $value = $normalized; + } + } + + // Keep tab/newline/carriage-return, strip other control characters that can + // make downstream JSON handling and logs harder to diagnose. + return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value); } }