fix sse error handling if shop api error part 1
This commit is contained in:
@@ -383,6 +383,18 @@ final class ShopSearchService
|
|||||||
if ($safeResults !== null) {
|
if ($safeResults !== null) {
|
||||||
return $safeResults;
|
return $safeResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$minimalResults = $this->retryWithMinimalCriteria(
|
||||||
|
query: $query,
|
||||||
|
commerceIntent: $commerceIntent,
|
||||||
|
originalPrompt: $originalPrompt,
|
||||||
|
usesHistoryContext: $usesHistoryContext,
|
||||||
|
previousException: $e
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($minimalResults !== null) {
|
||||||
|
return $minimalResults;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->recordFailedSearch($e);
|
$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<mixed> $response
|
* @param array<mixed> $response
|
||||||
* @return ShopProductResult[]
|
* @return ShopProductResult[]
|
||||||
|
|||||||
@@ -36,7 +36,32 @@ final class ShopwareCriteriaBuilder
|
|||||||
query: $query,
|
query: $query,
|
||||||
limit: $limit,
|
limit: $limit,
|
||||||
grouping: $grouping,
|
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,
|
CommerceSearchQuery $query,
|
||||||
?int $limit,
|
?int $limit,
|
||||||
?bool $grouping,
|
?bool $grouping,
|
||||||
bool $includeRichTextFields
|
bool $includeRichTextFields,
|
||||||
|
bool $includeMediaFields = true,
|
||||||
|
bool $includeSeoUrls = true,
|
||||||
|
bool $includeManufacturer = true
|
||||||
): array {
|
): array {
|
||||||
$productIncludes = [
|
$productIncludes = [
|
||||||
'id',
|
'id',
|
||||||
@@ -52,26 +80,28 @@ final class ShopwareCriteriaBuilder
|
|||||||
'productNumber',
|
'productNumber',
|
||||||
'available',
|
'available',
|
||||||
'calculatedPrice',
|
'calculatedPrice',
|
||||||
'seoUrls',
|
|
||||||
'manufacturer',
|
|
||||||
'translated.name',
|
'translated.name',
|
||||||
'cover',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($includeSeoUrls) {
|
||||||
|
$productIncludes[] = 'seoUrls';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeManufacturer) {
|
||||||
|
$productIncludes[] = 'manufacturer';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeMediaFields) {
|
||||||
|
$productIncludes[] = 'cover';
|
||||||
|
}
|
||||||
|
|
||||||
if ($includeRichTextFields) {
|
if ($includeRichTextFields) {
|
||||||
$productIncludes[] = 'description';
|
$productIncludes[] = 'description';
|
||||||
$productIncludes[] = 'customFields';
|
$productIncludes[] = 'customFields';
|
||||||
}
|
}
|
||||||
|
|
||||||
$criteria = [
|
$includes = [
|
||||||
'page' => 1,
|
|
||||||
'limit' => max(1, $limit),
|
|
||||||
'total-count-mode' => 0,
|
|
||||||
'includes' => [
|
|
||||||
'product' => $productIncludes,
|
'product' => $productIncludes,
|
||||||
'product_manufacturer' => [
|
|
||||||
'name',
|
|
||||||
],
|
|
||||||
'calculated_price' => [
|
'calculated_price' => [
|
||||||
'unitPrice',
|
'unitPrice',
|
||||||
'totalPrice',
|
'totalPrice',
|
||||||
@@ -79,25 +109,46 @@ final class ShopwareCriteriaBuilder
|
|||||||
'listPrice',
|
'listPrice',
|
||||||
'regulationPrice'
|
'regulationPrice'
|
||||||
],
|
],
|
||||||
'seo_url' => [
|
];
|
||||||
|
|
||||||
|
if ($includeManufacturer) {
|
||||||
|
$includes['product_manufacturer'] = [
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeSeoUrls) {
|
||||||
|
$includes['seo_url'] = [
|
||||||
'seoPathInfo',
|
'seoPathInfo',
|
||||||
],
|
];
|
||||||
'product_media' => [
|
}
|
||||||
|
|
||||||
|
if ($includeMediaFields) {
|
||||||
|
$includes['product_media'] = [
|
||||||
'id',
|
'id',
|
||||||
'media'
|
'media'
|
||||||
],
|
];
|
||||||
'media' => [
|
$includes['media'] = [
|
||||||
'id',
|
'id',
|
||||||
'url',
|
'url',
|
||||||
'thumbnails',
|
'thumbnails',
|
||||||
'alt',
|
'alt',
|
||||||
'title'
|
'title'
|
||||||
]
|
];
|
||||||
],
|
}
|
||||||
'associations' => [
|
|
||||||
'manufacturer' => new \stdClass(),
|
$associations = [];
|
||||||
'seoUrls' => new \stdClass(),
|
|
||||||
'cover' => [
|
if ($includeManufacturer) {
|
||||||
|
$associations['manufacturer'] = new \stdClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeSeoUrls) {
|
||||||
|
$associations['seoUrls'] = new \stdClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($includeMediaFields) {
|
||||||
|
$associations['cover'] = [
|
||||||
'associations' => [
|
'associations' => [
|
||||||
'media' => [
|
'media' => [
|
||||||
'associations' => [
|
'associations' => [
|
||||||
@@ -105,8 +156,15 @@ final class ShopwareCriteriaBuilder
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
];
|
||||||
]
|
}
|
||||||
|
|
||||||
|
$criteria = [
|
||||||
|
'page' => 1,
|
||||||
|
'limit' => max(1, $limit),
|
||||||
|
'total-count-mode' => 0,
|
||||||
|
'includes' => $includes,
|
||||||
|
'associations' => $associations,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($grouping) {
|
if ($grouping) {
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ final readonly class StoreApiClient
|
|||||||
$url = rtrim($this->baseUrl, '/') . '/store-api/search';
|
$url = rtrim($this->baseUrl, '/') . '/store-api/search';
|
||||||
$sanitizedCriteria = $this->sanitizeValue($criteria);
|
$sanitizedCriteria = $this->sanitizeValue($criteria);
|
||||||
|
|
||||||
|
try {
|
||||||
$body = json_encode(
|
$body = json_encode(
|
||||||
$sanitizedCriteria,
|
$sanitizedCriteria,
|
||||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
|
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
|
||||||
);
|
);
|
||||||
|
} catch (\JsonException $e) {
|
||||||
if (!is_string($body)) {
|
throw new RuntimeException('Failed to encode Store API criteria as valid JSON.', 0, $e);
|
||||||
throw new RuntimeException('Failed to encode Store API criteria.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->httpClient->request('POST', $url, [
|
$response = $this->httpClient->request('POST', $url, [
|
||||||
@@ -83,9 +83,10 @@ final readonly class StoreApiClient
|
|||||||
|
|
||||||
return new StoreApiException(
|
return new StoreApiException(
|
||||||
sprintf(
|
sprintf(
|
||||||
'Shopware Store API request failed with status %d. Response: %s',
|
'Shopware Store API request failed with status %d. Response: %s%s',
|
||||||
$statusCode,
|
$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,
|
$statusCode,
|
||||||
$serverFailure,
|
$serverFailure,
|
||||||
@@ -126,26 +127,34 @@ final readonly class StoreApiClient
|
|||||||
|
|
||||||
private function sanitizeString(string $value): string
|
private function sanitizeString(string $value): string
|
||||||
{
|
{
|
||||||
if (preg_match('//u', $value) === 1) {
|
if (preg_match('//u', $value) !== 1) {
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (function_exists('mb_convert_encoding')) {
|
if (function_exists('mb_convert_encoding')) {
|
||||||
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
$value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('//u', $value) === 1) {
|
if (preg_match('//u', $value) !== 1 && function_exists('iconv')) {
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (function_exists('iconv')) {
|
|
||||||
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
|
$converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
|
||||||
|
|
||||||
if (is_string($converted) && $converted !== '') {
|
if (is_string($converted)) {
|
||||||
return $converted;
|
$value = $converted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preg_match('//u', $value) !== 1) {
|
||||||
return '';
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user