safeMessage( $exception, 'Product not found.' ), ErrorEnvelope::STATUS_NOT_FOUND, $ctx, $this->extractDetails( $exception ), false ); } if ( $exception instanceof AuthenticationException ) { return ErrorEnvelope::fromContext( 'affilun_provider_unauthorized', $this->safeMessage( $exception, 'Provider rejected the supplied credentials.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), false ); } if ( $exception instanceof RateLimitExceededException ) { $details = $this->extractDetails( $exception ); $context = $exception->context(); if ( array_key_exists( 'retry_after', $context ) ) { $details['retry_after_seconds'] = $context['retry_after']; } return ErrorEnvelope::fromContext( ErrorEnvelope::CODE_RATE_LIMITED, $this->safeMessage( $exception, 'Provider rate limit exceeded.' ), ErrorEnvelope::STATUS_TOO_MANY_REQUESTS, $ctx, $details, true ); } if ( $exception instanceof TemporaryUnavailableException ) { return ErrorEnvelope::fromContext( ErrorEnvelope::CODE_PROVIDER_UNAVAILABLE, $this->safeMessage( $exception, 'Provider is temporarily unavailable.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), true ); } if ( $exception instanceof ProviderConfigurationException ) { return ErrorEnvelope::fromContext( 'affilun_provider_misconfigured', $this->safeMessage( $exception, 'Provider configuration is incomplete.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), false ); } if ( $exception instanceof UnsupportedFeatureException ) { return ErrorEnvelope::fromContext( 'affilun_provider_unsupported_feature', $this->safeMessage( $exception, 'Provider does not support the requested feature.' ), ErrorEnvelope::STATUS_BAD_REQUEST, $ctx, $this->extractDetails( $exception ), false ); } if ( $exception instanceof ProviderResponseException ) { $context = $exception->context(); $reason = $context['reason'] ?? null; if ( $reason === ProviderResponseException::REASON_POLICY_VIOLATION ) { return ErrorEnvelope::fromContext( 'affilun_provider_policy_violation', $this->safeMessage( $exception, 'Provider rejected the request on policy grounds.' ), ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, $this->extractDetails( $exception ), false ); } return ErrorEnvelope::fromContext( 'affilun_provider_response_invalid', $this->safeMessage( $exception, 'Provider returned an invalid response.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), true ); } if ( $exception instanceof ProviderRequestException ) { return ErrorEnvelope::fromContext( 'affilun_provider_request_failed', $this->safeMessage( $exception, 'Provider request failed.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), true ); } if ( $exception instanceof ProviderException ) { return ErrorEnvelope::fromContext( 'affilun_provider_error', $this->safeMessage( $exception, 'Provider error.' ), ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE, $ctx, $this->extractDetails( $exception ), true ); } // Non-provider throwable: never expose internal diagnostic // detail through the envelope's message field. The literal // "Internal server error." string is the safe default; a // future debug-mode hook can add an opt-in pass-through. return ErrorEnvelope::fromContext( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Internal server error.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx, array(), false ); } /** * Pull the canonical `provider` field off a * {@see ProviderException} into a fresh details map. * * Subclass-specific augmentation (rate-limit retry-after, * unsupported-feature `feature` key, ...) is layered by the * caller on top of this base map. * * @param ProviderException $exception Exception to inspect. * * @return array */ private function extractDetails( ProviderException $exception ): array { return array( 'provider' => $exception->provider(), ); } /** * Return a safe, non-empty human-readable message for the * envelope. * * The provider exception's own message is forwarded when * non-empty (per §10.1 the raise site is responsible for not * including secrets); otherwise the supplied fallback is used * so the envelope's `message` invariant (non-empty) is always * satisfied. * * @param Throwable $exception Exception to inspect. * @param string $fallback Fallback message when the * exception message is empty. */ private function safeMessage( Throwable $exception, string $fallback ): string { $message = trim( $exception->getMessage() ); return $message === '' ? $fallback : $message; } }