> */ private const ERROR_CODE_MAP = array( // Authentication / configuration failures. 'InvalidAppId' => AuthenticationException::class, 'MissingAppId' => AuthenticationException::class, 'InvalidApplicationId' => AuthenticationException::class, 'Unauthorized' => AuthenticationException::class, 'Forbidden' => AuthenticationException::class, 'AppIdRequired' => AuthenticationException::class, // Rate-limit refusals. 'TooManyRequests' => RateLimitExceededException::class, 'RateLimitExceeded' => RateLimitExceededException::class, 'QuotaExceeded' => RateLimitExceededException::class, // Request-shape failures. 'InvalidParameter' => ProviderRequestException::class, 'MissingParameter' => ProviderRequestException::class, 'InvalidValue' => ProviderRequestException::class, 'BadRequest' => ProviderRequestException::class, // Transient-failure tokens. 'InternalServerError' => TemporaryUnavailableException::class, 'ServiceUnavailable' => TemporaryUnavailableException::class, 'Timeout' => TemporaryUnavailableException::class, 'TemporaryFailure' => TemporaryUnavailableException::class, ); /** * Translate a non-2xx Yahoo!ショッピング response into a §10.2 * exception. * * @param HttpResponse $response Non-2xx response to translate. * * @return ProviderException Concrete §10.2 subclass instance. * * @throws LogicException When `$response` carries a 2xx status. */ public function map( HttpResponse $response ): ProviderException { $statusCode = $response->statusCode(); if ( $response->isSuccessful() ) { $message = sprintf( 'YahooShoppingErrorMapper::map() must not be called for successful ' . 'responses; got status %d.', $statusCode ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new LogicException( $message ); } $envelope = $this->decodeBody( $response ); $errorToken = $this->extractErrorCode( $envelope ); $errorMessage = $this->extractErrorMessage( $envelope ); $retryAfter = $this->extractRetryAfter( $response ); $exceptionClass = $this->pickExceptionClass( $statusCode, $errorToken ); $context = array( 'status_code' => $statusCode, 'yahoo_error_code' => $errorToken, 'yahoo_error_message' => $errorMessage, 'retry_after' => $retryAfter, ); $message = $this->buildMessage( $exceptionClass, $statusCode, $errorToken, $errorMessage ); /** @var ProviderException $exception */ $exception = new $exceptionClass( self::PROVIDER_KEY, $message, $context ); return $exception; } /** * Closed set of Yahoo-native error tokens this mapper * recognizes via {@see self::ERROR_CODE_MAP}, in declaration * order. * * @return list */ public static function knownErrorTokens(): array { return array_keys( self::ERROR_CODE_MAP ); } /** * Pick the §10.2 subclass for the supplied (status, token) * pair. * * The token-driven mapping wins when the token is in * {@see self::ERROR_CODE_MAP}; otherwise the status-class * default applies. * * @param int $statusCode HTTP status code. * @param string|null $errorToken Yahoo-native error token, or null. * * @return class-string */ private function pickExceptionClass( int $statusCode, ?string $errorToken ): string { if ( $errorToken !== null && array_key_exists( $errorToken, self::ERROR_CODE_MAP ) ) { return self::ERROR_CODE_MAP[ $errorToken ]; } if ( $statusCode === 401 || $statusCode === 403 ) { return AuthenticationException::class; } if ( $statusCode === 429 ) { return RateLimitExceededException::class; } if ( $statusCode >= 400 && $statusCode <= 499 ) { return ProviderRequestException::class; } if ( $statusCode >= 500 && $statusCode <= 599 ) { return TemporaryUnavailableException::class; } return ProviderResponseException::class; } /** * Build the human-readable summary for the produced exception. * * @param class-string $exceptionClass Chosen subclass. * @param int $statusCode HTTP status code. * @param string|null $errorToken Yahoo error token. * @param string|null $errorMessage Yahoo error message. */ private function buildMessage( string $exceptionClass, int $statusCode, ?string $errorToken, ?string $errorMessage ): string { $friendly = $this->friendlyClassLabel( $exceptionClass ); $summary = sprintf( 'Yahoo!ショッピング %s (HTTP %d)', $friendly, $statusCode ); if ( $errorToken !== null ) { $summary .= sprintf( ': %s', $errorToken ); } if ( $errorMessage !== null ) { $summary .= sprintf( ' — %s', $errorMessage ); } return $summary; } /** * Render a short label for the chosen §10.2 subclass. * * @param class-string $exceptionClass Chosen subclass. */ private function friendlyClassLabel( string $exceptionClass ): string { switch ( $exceptionClass ) { case AuthenticationException::class: return 'authentication failure'; case RateLimitExceededException::class: return 'rate-limit refusal'; case TemporaryUnavailableException::class: return 'temporary outage'; case ProviderRequestException::class: return 'request rejected'; case ProviderResponseException::class: return 'response contract violation'; default: return 'failure'; } } /** * Decode the response body as JSON and return the resulting * associative array, or null when the body is empty / not * valid JSON / not an object at the top level. * * @param HttpResponse $response Response under test. * * @return array|null Decoded envelope, or null. */ private function decodeBody( HttpResponse $response ): ?array { if ( $response->body() === '' ) { return null; } try { /** @var mixed $decoded */ $decoded = $response->jsonBody(); } catch ( \InvalidArgumentException $unused ) { unset( $unused ); return null; } if ( ! is_array( $decoded ) ) { return null; } if ( $decoded !== array() && array_is_list( $decoded ) ) { return null; } /** @var array $decoded */ return $decoded; } /** * Extract the Yahoo-native error code from the decoded * envelope. * * Yahoo's商品検索 v3 returns errors in either a top-level * `Error` object or in an `Errors[]` list. The mapper * accepts both shapes. * * @param array|null $envelope Decoded envelope. */ private function extractErrorCode( ?array $envelope ): ?string { if ( $envelope === null ) { return null; } $singular = $envelope['Error'] ?? null; if ( is_array( $singular ) ) { $code = $singular['Code'] ?? null; if ( is_string( $code ) ) { $trimmed = trim( $code ); if ( $trimmed !== '' ) { return $trimmed; } } } $plural = $envelope['Errors'] ?? null; if ( is_array( $plural ) && $plural !== array() ) { $first = array_values( $plural )[0]; if ( is_array( $first ) ) { $code = $first['Code'] ?? null; if ( is_string( $code ) ) { $trimmed = trim( $code ); if ( $trimmed !== '' ) { return $trimmed; } } } } // Fallback for low-level error envelopes that surface a // plain `code` key. $flatCode = $envelope['code'] ?? null; if ( is_string( $flatCode ) ) { $trimmed = trim( $flatCode ); if ( $trimmed !== '' ) { return $trimmed; } } return null; } /** * Extract the Yahoo-native error message from the decoded * envelope. * * @param array|null $envelope Decoded envelope. */ private function extractErrorMessage( ?array $envelope ): ?string { if ( $envelope === null ) { return null; } $singular = $envelope['Error'] ?? null; if ( is_array( $singular ) ) { $message = $singular['Message'] ?? null; if ( is_string( $message ) ) { $trimmed = trim( $message ); if ( $trimmed !== '' ) { return $trimmed; } } } $plural = $envelope['Errors'] ?? null; if ( is_array( $plural ) && $plural !== array() ) { $first = array_values( $plural )[0]; if ( is_array( $first ) ) { $message = $first['Message'] ?? null; if ( is_string( $message ) ) { $trimmed = trim( $message ); if ( $trimmed !== '' ) { return $trimmed; } } } } $flatMessage = $envelope['message'] ?? null; if ( is_string( $flatMessage ) ) { $trimmed = trim( $flatMessage ); if ( $trimmed !== '' ) { return $trimmed; } } return null; } /** * Extract the `Retry-After` response header, when present, as * the verbatim header value (callers can parse it as either * delta-seconds or HTTP-date per RFC 9110). * * @param HttpResponse $response Response under test. */ private function extractRetryAfter( HttpResponse $response ): ?string { $value = $response->header( 'Retry-After' ); if ( $value === null ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } }