*/ private const WP_ERROR_STATUS = array( 'reauth_required' => 409, 'reconnect_required' => 409, 'not_connected' => 409, 'no_property_access' => 403, 'api_disabled' => 403, 'quota' => 429, 'google_unavailable' => 503, 'relay_unavailable' => 503, ); /** * The stable error slug. * * @var string */ private string $slug; /** * The HTTP status that produced this error, or 0 when there was none * (e.g. a network-level WP_Error). * * @var int */ private int $status; /** * Raw error details (Google's `error` object, or the relay's error body), * for logging. Never rendered to the user as-is. * * @var array */ private array $details; /** * Constructor. * * @param string $slug Stable error slug. * @param string $message Diagnostic message (Google's/relay's, sanitized). Not user-facing. * @param int $status HTTP status that produced this error. * @param array $details Raw error details, for logging only. */ public function __construct( string $slug, string $message = '', int $status = 0, array $details = array() ) { parent::__construct( $message ); $this->slug = $slug; $this->status = $status; $this->details = $details; } /** * The stable error slug. */ public function slug(): string { return $this->slug; } /** * The HTTP status that produced this error, or 0 when there was none. */ public function status(): int { return $this->status; } /** * Raw error details, for logging only. Never contains tokens or request bodies. * * @return array */ public function details(): array { return $this->details; } /** * Maps an HTTP status and decoded JSON body to an ApiError, per the * table in spec ยง3 "Recovery and messaging". * * @param int $status HTTP status code. * @param array|null $body Decoded JSON response body. */ public static function from_response( int $status, ?array $body ): self { $body = $body ?? array(); $error = $body['error'] ?? null; $details = is_array( $error ) ? $error : array(); $message = self::extract_message( $body, $error ); if ( 400 === $status && is_string( $error ) && 'invalid_grant' === $error ) { return new self( 'reauth_required', $message, $status, $details ); } if ( 401 === $status ) { return new self( 'unauthorized', $message, $status, $details ); } if ( 403 === $status && is_array( $error ) && 'PERMISSION_DENIED' === ( $error['status'] ?? '' ) ) { if ( self::is_service_disabled( $error, $message ) ) { return new self( 'api_disabled', $message, $status, $details ); } return new self( 'no_property_access', $message, $status, $details ); } if ( 429 === $status || ( is_array( $error ) && 'RESOURCE_EXHAUSTED' === ( $error['status'] ?? '' ) ) ) { return new self( 'quota', $message, $status, $details ); } if ( $status >= 500 ) { return new self( 'google_unavailable', $message, $status, $details ); } return new self( 'bad_request', $message, $status, $details ); } /** * Whether stale cached data is safe to keep serving alongside this error. */ public function is_stale_ok(): bool { return in_array( $this->slug, self::STALE_OK_SLUGS, true ); } /** * A translated, plain-English message safe to show to the user. Never * includes Google's raw message, tokens or request bodies. */ public function user_message(): string { switch ( $this->slug ) { case 'not_connected': return __( 'Google Analytics is not connected. Please connect your account in Settings.', 'tagsmith' ); case 'reauth_required': if ( 'byo' === ( $this->details['mode'] ?? '' ) ) { return __( 'Google access expired or was revoked. Please reconnect. If you use your own Google client, set its OAuth consent screen\'s publishing status to "In production" in Google Cloud: while it is "Testing", Google ends access after 7 days.', 'tagsmith' ); } return __( 'Google access expired or was revoked. Please reconnect.', 'tagsmith' ); case 'unauthorized': return __( 'Google access expired or was revoked. Please reconnect.', 'tagsmith' ); case 'reconnect_required': return __( 'Reconnect required: your site\'s security keys changed. Please reconnect Google.', 'tagsmith' ); case 'https_required': return __( 'Google sign-in needs HTTPS. Add the tracking code only, or enable HTTPS.', 'tagsmith' ); case 'scope_denied': return __( 'Google Analytics access was not granted. Please try again and allow Analytics access.', 'tagsmith' ); case 'no_property_access': return __( 'This Google account no longer has access to the selected property.', 'tagsmith' ); case 'api_disabled': return __( 'This Google API is not enabled for your project. Enable it in Google Cloud Console, then try again.', 'tagsmith' ); case 'quota': return __( 'Google Analytics API quota was exceeded. Showing the last saved data; please try again later.', 'tagsmith' ); case 'google_unavailable': return __( 'Google Analytics is temporarily unavailable. Showing the last saved data.', 'tagsmith' ); case 'relay_unavailable': return __( 'The Tagsmith relay is temporarily unavailable. Try again later, or use your own Google client (Settings, Sign-in & data).', 'tagsmith' ); case 'not_configured': return __( 'Choose a Google Analytics property in Settings first.', 'tagsmith' ); case 'bad_request': default: return __( 'The request to Google could not be completed. Please try again.', 'tagsmith' ); } } /** * Converts this error to a WP_Error carrying the same slug and an HTTP * status suitable for a REST response. */ public function to_wp_error(): \WP_Error { $status = self::WP_ERROR_STATUS[ $this->slug ] ?? 400; return new \WP_Error( 'tagsmith_' . $this->slug, $this->user_message(), array( 'status' => $status, 'slug' => $this->slug, ) ); } /** * Extracts Google's or the relay's diagnostic message, sanitized. Never * returns tokens or a raw request/response body. * * @param array $body Decoded JSON response body. * @param mixed $error The `error` field of that body. */ private static function extract_message( array $body, $error ): string { if ( is_array( $error ) ) { $message = (string) ( $error['message'] ?? '' ); } else { $message = (string) ( $body['error_description'] ?? '' ); } return sanitize_text_field( $message ); } /** * Whether a 403 PERMISSION_DENIED error means the API itself isn't * enabled for the project (BYO mode only), rather than a lack of access * to the property. * * @param array $error Google's `error` object. * @param string $message Extracted, sanitized message. */ private static function is_service_disabled( array $error, string $message ): bool { $details = $error['details'] ?? array(); if ( is_array( $details ) ) { foreach ( $details as $detail ) { if ( is_array( $detail ) && 'SERVICE_DISABLED' === ( $detail['reason'] ?? '' ) ) { return true; } } } return false !== stripos( $message, 'has not been used' ); } }