PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/integrations/class-google-api-base-client.php +97 -0 2.6.02.7.0 View file →
@@ -129,9 +129,31 @@
129 129 $response_body = wp_remote_retrieve_body($response);
130 130
131 131 if ($status_code >= 400) {
132 132 $error_data = json_decode($response_body, true);
133 + $error_data = is_array($error_data) ? $error_data : [];
133 134 $error_message = $error_data['error']['message'] ?? 'Unknown API error';
135 +
136 + // A scope failure reads as "Request had insufficient authentication
137 + // scopes." — Google-internal wording that told the user nothing and
138 + // reached the MCP client verbatim (#674). Say what to do instead.
139 + $actionable = self::actionable_auth_message((int) $status_code, $error_data);
140 +
141 + if (null !== $actionable) {
142 + // Google's own sentence is kept after the instruction: support
143 + // needs the upstream wording to tell a scope failure from a
144 + // revoked grant, and the user needs the instruction first.
145 + // Built on its own line so the phpcs:ignore below sits on the
146 + // `throw` itself. The annotation only suppresses the next line,
147 + // and on a multi-line throw the reported violation is the
148 + // argument line, not the `throw` — so the ignore missed it and
149 + // Plugin Check failed on a sniff the repo standard does not run.
150 + $message = sprintf('%s (Google said: %s)', $actionable, $error_message);
151 +
152 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
153 + throw new \Exception($message, (int) $status_code);
154 + }
155 +
134 156 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Same as above: Google's wording is data, not markup; escaping it leaks entities into the UI.
135 157 throw new \Exception(sprintf('Google API error (%d): %s', (int) $status_code, $error_message), (int) $status_code);
136 158 }
137 159
@@ -227,8 +249,83 @@
227 249 *
228 250 * @return string Rate limit key
229 251 */
230 252 abstract protected function get_rate_limit_key(): string;
253 +
254 + /**
255 + * Turn an authorization failure into an instruction, or null to pass through.
256 + *
257 + * Google answers a missing scope with "Request had insufficient
258 + * authentication scopes." — accurate, and useless to the person reading it.
259 + * It reached the MCP client and the Performance panels verbatim, with
260 + * nothing to say that reconnecting the Google account is the fix (#674).
261 + *
262 + * The distinction that matters is refresh versus re-consent. A 401 is a
263 + * stale access token and Analytics_Manager already refreshes and retries it
264 + * silently. A scope 403 is not retryable: refreshing returns a token with
265 + * the same scopes, so only granting consent again can change the outcome —
266 + * which is why this says "reconnect", not "try again".
267 + *
268 + * Returns null for every other failure, so quota, rate-limit and genuine
269 + * permission errors keep Google's wording, which is informative for them.
270 + *
271 + * @since 2.7.0
272 + *
273 + * @param int $status_code HTTP status.
274 + * @param array $error_data Decoded error body.
275 + * @return string|null Instruction to lead with, or null to pass through.
276 + */
277 + protected static function actionable_auth_message(int $status_code, array $error_data): ?string {
278 + $error = is_array($error_data['error'] ?? null) ? $error_data['error'] : [];
279 + $message = (string) ($error['message'] ?? '');
280 +
281 + // google.rpc.ErrorInfo, which is what the newer APIs return.
282 + $reasons = [];
283 + foreach ((array) ($error['details'] ?? []) as $detail) {
284 + if (is_array($detail) && isset($detail['reason'])) {
285 + $reasons[] = (string) $detail['reason'];
286 + }
287 + }
288 +
289 + // The older errors[] shape, still used by Search Console.
290 + foreach ((array) ($error['errors'] ?? []) as $legacy) {
291 + if (is_array($legacy) && isset($legacy['reason'])) {
292 + $reasons[] = (string) $legacy['reason'];
293 + }
294 + }
295 +
296 + $scope_failure = 403 === $status_code
297 + && (
298 + in_array('ACCESS_TOKEN_SCOPE_INSUFFICIENT', $reasons, true)
299 + || in_array('insufficientPermissions', $reasons, true)
300 + || 1 === preg_match('/insufficient (authentication scopes|permission)/i', $message)
301 + );
302 +
303 + if ($scope_failure) {
304 + return __(
305 + 'The connected Google account is missing a permission this feature needs. Reconnect it under Essential SEO > Integrations and approve every permission Google asks for. Refreshing or retrying will not help, because the existing grant cannot gain a permission it was never given.',
306 + 'thinkrank'
307 + );
308 + }
309 +
310 + // A revoked or withdrawn grant. Analytics_Manager treats invalid_grant
311 + // as terminal already; this is the same condition seen from the API
312 + // side, where the refresh-and-retry loop has nothing left to try.
313 + $revoked = in_array($status_code, [401, 403], true)
314 + && (
315 + in_array('ACCESS_TOKEN_EXPIRED', $reasons, true)
316 + || 1 === preg_match('/invalid[_ ]grant|token has been expired or revoked/i', $message)
317 + );
318 +
319 + if ($revoked) {
320 + return __(
321 + 'The Google connection is no longer valid: access was revoked, or the grant expired. Reconnect the account under Essential SEO > Integrations.',
322 + 'thinkrank'
323 + );
324 + }
325 +
326 + return null;
327 + }
231 328
232 329 /**
233 330 * Get rate limit error message
234 331 * Must be implemented by concrete classes