diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/source.py index fbaa6a0dc479..b2064b90d8eb 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/source.py @@ -65,6 +65,12 @@ def get_non_retryable_errors(self) -> dict[str, str | None]: "401 Client Error": "Your Google Analytics connection is invalid or expired. Please reconnect your account.", "403 Client Error": "PostHog is not authorized to read this Google Analytics property. Please make sure the connected Google account has access to the property.", "ACCESS_TOKEN_SCOPE_INSUFFICIENT": "Insufficient permissions. Please reconnect your Google Analytics account with the required scopes.", + # Raised as a bare `RefreshError` from `AuthorizedSession` when the stored refresh token + # has been revoked or expired. `validate_credentials` already maps this to a reconnect + # prompt, but only runs before a sync starts. Mid-sync it reaches `_run_report` via + # `session.post()` before any HTTP status is available to match on, so match Google's + # stable OAuth error code instead. + "invalid_grant": "Your Google Analytics connection has expired or been revoked. Please reconnect your account.", } def get_retryable_errors(self) -> set[str]: diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/tests/test_google_analytics_source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/tests/test_google_analytics_source.py index 314c7ee1b479..3a3172f2688e 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/tests/test_google_analytics_source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/google_analytics/tests/test_google_analytics_source.py @@ -8,6 +8,7 @@ from posthog.models.integration import Integration +from products.warehouse_sources.backend.temporal.data_imports.sources.common.base import error_message_matches from products.warehouse_sources.backend.temporal.data_imports.sources.generated_configs.googleanalytics import ( GoogleAnalyticsSourceConfig, ) @@ -262,6 +263,17 @@ def test_non_retryable_errors_cover_auth_failures(): assert "401 Client Error" in errors assert "403 Client Error" in errors assert "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in errors + assert "invalid_grant" in errors + + +def test_non_retryable_errors_matches_revoked_refresh_token(): + # `_run_report` refreshes credentials via `session.post()` before any HTTP status is + # available, so a revoked/expired refresh token surfaces as a bare `RefreshError` whose + # `str()` is the raw (message, response_dict) tuple repr, e.g.: + # ('invalid_grant: Bad Request', {'error': 'invalid_grant', 'error_description': 'Bad Request'}) + observed_error = str(RefreshError("invalid_grant: Bad Request", {"error": "invalid_grant"})) + non_retryable_errors = GoogleAnalyticsSource().get_non_retryable_errors() + assert error_message_matches(observed_error, non_retryable_errors) def test_retryable_errors_cover_exhausted_quota_retries():