> */ private const ERROR_CODE_MAP = array( // Authentication / configuration failures. 'unauthorized_user' => AuthenticationException::class, 'not_found_application' => AuthenticationException::class, 'expired_application' => AuthenticationException::class, 'invalid_developer_id' => AuthenticationException::class, 'invalid_application_id' => AuthenticationException::class, 'invalid_access_key' => AuthenticationException::class, 'missing_access_key' => AuthenticationException::class, 'invalid_affiliate_id' => AuthenticationException::class, // Rate-limit refusals. 'too_many_requests' => RateLimitExceededException::class, 'rate_limit_exceeded' => RateLimitExceededException::class, // Request-shape failures. 'wrong_parameter' => ProviderRequestException::class, 'wrong_format' => ProviderRequestException::class, 'invalid_parameter' => ProviderRequestException::class, 'parameter_required' => ProviderRequestException::class, 'parameter_too_long' => ProviderRequestException::class, // Transient-failure tokens. 'system_error' => TemporaryUnavailableException::class, 'service_unavailable' => TemporaryUnavailableException::class, 'timeout' => TemporaryUnavailableException::class, 'temporary_failure' => TemporaryUnavailableException::class, ); /** * Translate a non-2xx Rakuten response into a §10.2 exception. * * Lifecycle: * * 1. Reject 2xx responses with {@see \LogicException} — this * mapper is the failure-translation surface and a * successful response is a programming error in the * provider orchestration. * * 2. Decode the response body as JSON when present. A * decode failure or non-array result is tolerated: the * mapper falls back to the status-class default. * * 3. Extract the Rakuten-native `error` / * `error_description` / `request_id` fields from the * decoded body when present. * * 4. Pick the §10.2 subclass via {@see self::ERROR_CODE_MAP} * when the error token is known, or via the status-class * default when it is not. * * 5. Build the structured context map and the human-readable * summary, then construct the chosen subclass. * * @param HttpResponse $response The non-2xx response from the * upstream that the provider * layer wants translated. * * @return ProviderException A concrete §10.2 subclass instance * ready to be thrown. * * @throws LogicException When `$response` carries a 2xx status * — this mapper is not the right * surface for successful responses. */ public function map( HttpResponse $response ): ProviderException { $statusCode = $response->statusCode(); if ( $response->isSuccessful() ) { $message = sprintf( 'RakutenErrorMapper::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 ); $gatewayErrors = is_array( $envelope['errors'] ?? null ) ? $envelope['errors'] : array(); $errorToken = $this->extractToken( $envelope, 'error' ); $errorDescription = $this->extractToken( $envelope, 'error_description' ); $requestId = $this->extractToken( $envelope, 'request_id' ); $gatewayErrorCode = $this->extractScalarToken( $gatewayErrors, 'errorCode' ); $gatewayMessage = $this->extractToken( $gatewayErrors, 'errorMessage' ); if ( $errorToken === null ) { $errorToken = $gatewayErrorCode; } if ( $errorDescription === null ) { $errorDescription = $gatewayMessage; } $exceptionClass = $this->pickExceptionClass( $statusCode, $errorToken, $errorDescription ); $context = array( 'status_code' => $statusCode, 'rakuten_error' => $errorToken, 'rakuten_error_description' => $errorDescription, 'request_id' => $requestId, 'gateway_error_code' => $gatewayErrorCode, ); $message = $this->buildMessage( $exceptionClass, $statusCode, $errorToken, $errorDescription ); /** @var ProviderException $exception */ $exception = new $exceptionClass( self::PROVIDER_KEY, $message, $context ); return $exception; } /** * Closed set of Rakuten-native error tokens this mapper * recognizes via {@see self::ERROR_CODE_MAP}, in declaration * order. * * Exposed for tests and for diagnostics surfaces that want to * enumerate the recognized tokens (for example, populating an * admin "known Rakuten error codes" reference table) without * duplicating the list. * * @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: * * - 401 / 403 → {@see AuthenticationException} * - 429 → {@see RateLimitExceededException} * - Other 4xx → {@see ProviderRequestException} * - 5xx → {@see TemporaryUnavailableException} * - 1xx / 3xx → {@see ProviderResponseException} (the * upstream returned a response that the cross-provider * model cannot interpret as a clean failure). * * @param int $statusCode HTTP status code on the response. * @param string|null $errorToken Rakuten-native error token, or null. * @param string|null $errorDescription Upstream error description, or null. * * @return class-string */ private function pickExceptionClass( int $statusCode, ?string $errorToken, ?string $errorDescription ): string { if ( $errorToken !== null && array_key_exists( $errorToken, self::ERROR_CODE_MAP ) ) { return self::ERROR_CODE_MAP[ $errorToken ]; } if ( $statusCode >= 400 && $statusCode <= 499 && $this->describesCredentialFailure( $errorDescription ) ) { return AuthenticationException::class; } 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; } /** * Detect current-gateway messages for account-wide credential * failures that arrive as a generic HTTP 400 envelope. * * @param string|null $description Upstream error description. */ private function describesCredentialFailure( ?string $description ): bool { if ( $description === null ) { return false; } $normalized = strtolower( $description ); return strpos( $normalized, 'accesskey' ) !== false || strpos( $normalized, 'access key' ) !== false || strpos( $normalized, 'applicationid' ) !== false || strpos( $normalized, 'application id' ) !== false; } /** * Build the human-readable summary for the produced exception. * * The summary names the §10.2 subclass, the HTTP status, and * the Rakuten-native token / description when present. Per * §10.1 the message MUST NOT carry credential material; the * mapper has no access to credentials, so the rule is * satisfied by construction. * * @param class-string $exceptionClass Chosen subclass. * @param int $statusCode HTTP status code. * @param string|null $errorToken Rakuten-native error token. * @param string|null $errorDescription Rakuten-native description. */ private function buildMessage( string $exceptionClass, int $statusCode, ?string $errorToken, ?string $errorDescription ): string { $friendly = $this->friendlyClassLabel( $exceptionClass ); $summary = sprintf( 'Rakuten %s (HTTP %d)', $friendly, $statusCode ); if ( $errorToken !== null ) { $summary .= sprintf( ': %s', $errorToken ); } if ( $errorDescription !== null ) { $summary .= sprintf( ' — %s', $errorDescription ); } return $summary; } /** * Render a short label for the chosen §10.2 subclass. * * Used by {@see self::buildMessage()} so log lines and * diagnostics views can grep for "Rakuten authentication" / * "Rakuten rate limit" / "Rakuten temporary outage" without * mining the FQCN. * * @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. * * The mapper deliberately tolerates a missing or malformed * body so the failure-translation path always produces a * §10.2 exception. A "we could not even parse the error * envelope" outcome is itself useful diagnostic information, * captured implicitly by the resulting null `rakuten_error` * context entry. * * @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; } // Reject positional arrays — we only consume associative // envelopes here. PHP's json_decode does not distinguish // between [] and {}, so guard explicitly. if ( $decoded !== array() && array_is_list( $decoded ) ) { return null; } /** @var array $decoded */ return $decoded; } /** * Extract a string token from the decoded envelope, returning * null when the field is missing, not a string, or empty after * trimming. * * @param array|null $envelope Decoded envelope. * @param string $field Field name to read. */ private function extractToken( ?array $envelope, string $field ): ?string { if ( $envelope === null ) { return null; } if ( ! array_key_exists( $field, $envelope ) ) { return null; } $value = $envelope[ $field ]; if ( ! is_string( $value ) ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } /** * Extract a string or integer gateway error code. * * @param array $envelope Decoded gateway error object. * @param string $field Field name to read. */ private function extractScalarToken( array $envelope, string $field ): ?string { if ( ! array_key_exists( $field, $envelope ) ) { return null; } $value = $envelope[ $field ]; if ( ! is_string( $value ) && ! is_int( $value ) ) { return null; } $trimmed = trim( (string) $value ); return $trimmed === '' ? null : $trimmed; } }