From 8d8f69545ee869242fe27fb2f4d4512429cdb240 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:57:10 +0530 Subject: [PATCH] feat: improve Google Calendar and Gmail connectors with enhanced error handling - Added user-friendly re-authentication messages for expired or revoked tokens in both Google Calendar and Gmail connectors. - Updated error handling in indexing tasks to log specific authentication errors and provide clearer feedback to users. - Enhanced the connector UI to handle indexing failures more effectively, improving overall user experience. --- .../connectors/google_calendar_connector.py | 14 +++++++ .../app/connectors/google_gmail_connector.py | 14 +++++++ .../google_calendar_indexer.py | 15 +++++-- .../google_gmail_indexer.py | 14 ++++++- .../assistant-ui/connector-popup.tsx | 18 ++++++-- .../views/connector-edit-view.tsx | 2 +- .../hooks/use-connector-dialog.ts | 6 ++- .../hooks/use-indexing-connectors.ts | 42 ++++++++++++++++++- 8 files changed, 113 insertions(+), 12 deletions(-) diff --git a/surfsense_backend/app/connectors/google_calendar_connector.py b/surfsense_backend/app/connectors/google_calendar_connector.py index 6d389ddd5..ac60b02a8 100644 --- a/surfsense_backend/app/connectors/google_calendar_connector.py +++ b/surfsense_backend/app/connectors/google_calendar_connector.py @@ -142,6 +142,12 @@ class GoogleCalendarConnector: flag_modified(connector, "config") await self._session.commit() except Exception as e: + error_str = str(e) + # Check if this is an invalid_grant error (token expired/revoked) + if "invalid_grant" in error_str.lower() or "token has been expired or revoked" in error_str.lower(): + raise Exception( + "Google Calendar authentication failed. Please re-authenticate." + ) from e raise Exception( f"Failed to refresh Google OAuth credentials: {e!s}" ) from e @@ -165,6 +171,10 @@ class GoogleCalendarConnector: self.service = build("calendar", "v3", credentials=credentials) return self.service except Exception as e: + error_str = str(e) + # If the error already contains a user-friendly re-authentication message, preserve it + if "re-authenticate" in error_str.lower() or "expired or been revoked" in error_str.lower() or "authentication failed" in error_str.lower(): + raise Exception(error_str) from e raise Exception(f"Failed to create Google Calendar service: {e!s}") from e async def get_calendars(self) -> tuple[list[dict[str, Any]], str | None]: @@ -271,6 +281,10 @@ class GoogleCalendarConnector: return events, None except Exception as e: + error_str = str(e) + # If the error already contains a user-friendly re-authentication message, preserve it + if "re-authenticate" in error_str.lower() or "expired or been revoked" in error_str.lower() or "authentication failed" in error_str.lower(): + return [], error_str return [], f"Error fetching events: {e!s}" def format_event_to_markdown(self, event: dict[str, Any]) -> str: diff --git a/surfsense_backend/app/connectors/google_gmail_connector.py b/surfsense_backend/app/connectors/google_gmail_connector.py index 10008ad73..8c0e4690e 100644 --- a/surfsense_backend/app/connectors/google_gmail_connector.py +++ b/surfsense_backend/app/connectors/google_gmail_connector.py @@ -141,6 +141,12 @@ class GoogleGmailConnector: flag_modified(connector, "config") await self._session.commit() except Exception as e: + error_str = str(e) + # Check if this is an invalid_grant error (token expired/revoked) + if "invalid_grant" in error_str.lower() or "token has been expired or revoked" in error_str.lower(): + raise Exception( + "Gmail authentication failed. Please re-authenticate." + ) from e raise Exception( f"Failed to refresh Google OAuth credentials: {e!s}" ) from e @@ -164,6 +170,10 @@ class GoogleGmailConnector: self.service = build("gmail", "v1", credentials=credentials) return self.service except Exception as e: + error_str = str(e) + # If the error already contains a user-friendly re-authentication message, preserve it + if "re-authenticate" in error_str.lower() or "expired or been revoked" in error_str.lower() or "authentication failed" in error_str.lower(): + raise Exception(error_str) from e raise Exception(f"Failed to create Gmail service: {e!s}") from e async def get_user_profile(self) -> tuple[dict[str, Any], str | None]: @@ -225,6 +235,10 @@ class GoogleGmailConnector: return messages, None except Exception as e: + error_str = str(e) + # If the error already contains a user-friendly re-authentication message, preserve it + if "re-authenticate" in error_str.lower() or "expired or been revoked" in error_str.lower() or "authentication failed" in error_str.lower(): + return [], error_str return [], f"Error fetching messages list: {e!s}" async def get_message_details( diff --git a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py index b8c0e564d..09bb8de4b 100644 --- a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py @@ -246,13 +246,20 @@ async def index_google_calendar_events( ) return 0, None else: + # Check if this is an authentication error that requires re-authentication + error_message = error + error_type = "APIError" + if "re-authenticate" in error.lower() or "expired or been revoked" in error.lower() or "authentication failed" in error.lower(): + error_message = "Google Calendar authentication failed. Please re-authenticate." + error_type = "AuthenticationError" + await task_logger.log_task_failure( log_entry, - f"Failed to get Google Calendar events: {error}", - "API Error", - {"error_type": "APIError"}, + error_message, + error, + {"error_type": error_type}, ) - return 0, f"Failed to get Google Calendar events: {error}" + return 0, error_message logger.info(f"Retrieved {len(events)} events from Google Calendar API") diff --git a/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py index e10297057..6a3057437 100644 --- a/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/google_gmail_indexer.py @@ -170,10 +170,20 @@ async def index_google_gmail_messages( ) if error: + # Check if this is an authentication error that requires re-authentication + error_message = error + error_type = "APIError" + if "re-authenticate" in error.lower() or "expired or been revoked" in error.lower() or "authentication failed" in error.lower(): + error_message = "Gmail authentication failed. Please re-authenticate." + error_type = "AuthenticationError" + await task_logger.log_task_failure( - log_entry, f"Failed to fetch messages: {error}", {} + log_entry, + error_message, + error, + {"error_type": error_type} ) - return 0, f"Failed to fetch Gmail messages: {error}" + return 0, error_message if not messages: success_msg = "No Google gmail messages found in the specified date range" diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 045c3c586..a04e2a9fd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -5,12 +5,14 @@ import { Cable, Loader2 } from "lucide-react"; import { useSearchParams } from "next/navigation"; import type { FC } from "react"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useConnectorsElectric } from "@/hooks/use-connectors-electric"; import { useDocumentsElectric } from "@/hooks/use-documents-electric"; +import { useInbox } from "@/hooks/use-inbox"; import { cn } from "@/lib/utils"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; @@ -27,10 +29,18 @@ import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view export const ConnectorIndicator: FC = () => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchParams = useSearchParams(); + const { data: currentUser } = useAtomValue(currentUserAtom); // Fetch document type counts using Electric SQL + PGlite for real-time updates const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId); + // Fetch notifications to detect indexing failures + const { inboxItems = [] } = useInbox( + currentUser?.id ?? null, + searchSpaceId ? Number(searchSpaceId) : null, + "connector_indexing" + ); + // Check if YouTube view is active const isYouTubeView = searchParams.get("view") === "youtube"; @@ -116,8 +126,10 @@ export const ConnectorIndicator: FC = () => { }; // Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed - const { indexingConnectorIds, startIndexing } = useIndexingConnectors( - connectors as SearchSourceConnector[] + // Also clears when failed notifications are detected + const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( + connectors as SearchSourceConnector[], + inboxItems ); const isLoading = connectorsLoading || documentTypesLoading; @@ -246,7 +258,7 @@ export const ConnectorIndicator: FC = () => { editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" ? () => { startIndexing(editingConnector.id); - handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type); + handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type, stopIndexing); } : undefined } diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 234898922..fbdffed7a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -97,7 +97,7 @@ export const ConnectorEditView: FC = ({ }; }, [checkScrollState]); - // Reset local quick indexing state when indexing completes + // Reset local quick indexing state when indexing completes or fails useEffect(() => { if (!isIndexing) { setIsQuickIndexing(false); diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index a2b1168bd..f505d8f83 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -1375,7 +1375,7 @@ export const useConnectorDialog = () => { // Handle quick index (index without date picker, uses backend defaults) const handleQuickIndexConnector = useCallback( - async (connectorId: number, connectorType?: string) => { + async (connectorId: number, connectorType?: string, stopIndexing?: (id: number) => void) => { if (!searchSpaceId) return; // Track quick index clicked event @@ -1401,6 +1401,10 @@ export const useConnectorDialog = () => { } catch (error) { console.error("Error indexing connector content:", error); toast.error(error instanceof Error ? error.message : "Failed to start indexing"); + // Stop indexing state on error + if (stopIndexing) { + stopIndexing(connectorId); + } } }, [searchSpaceId, indexConnector] diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts index 2ac8d340a..e82a8eb29 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-indexing-connectors.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import type { InboxItem } from "@/contracts/types/inbox.types"; +import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types"; /** * Hook to track which connectors are currently indexing using local state. @@ -9,10 +11,14 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; * This provides a better UX than polling by: * 1. Setting indexing state immediately when user triggers indexing (optimistic) * 2. Clearing indexing state when Electric SQL detects last_indexed_at changed + * 3. Clearing indexing state when a failed notification is detected * * The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state. */ -export function useIndexingConnectors(connectors: SearchSourceConnector[]) { +export function useIndexingConnectors( + connectors: SearchSourceConnector[], + inboxItems?: InboxItem[] +) { // Set of connector IDs that are currently indexing const [indexingConnectorIds, setIndexingConnectorIds] = useState>(new Set()); @@ -48,6 +54,40 @@ export function useIndexingConnectors(connectors: SearchSourceConnector[]) { } }, [connectors, indexingConnectorIds]); + // Detect failed notifications and stop indexing state + useEffect(() => { + if (!inboxItems || inboxItems.length === 0) return; + + const newIndexingIds = new Set(indexingConnectorIds); + let hasChanges = false; + + for (const item of inboxItems) { + // Only check connector_indexing notifications + if (item.type !== "connector_indexing") continue; + + // Check if this notification indicates a failure + const metadata = isConnectorIndexingMetadata(item.metadata) + ? item.metadata + : null; + if (!metadata) continue; + + // Check if status is "failed" or if there's an error_message + const isFailed = + metadata.status === "failed" || + (metadata.error_message && metadata.error_message.trim().length > 0); + + // If failed and connector is in indexing state, clear it + if (isFailed && indexingConnectorIds.has(metadata.connector_id)) { + newIndexingIds.delete(metadata.connector_id); + hasChanges = true; + } + } + + if (hasChanges) { + setIndexingConnectorIds(newIndexingIds); + } + }, [inboxItems, indexingConnectorIds]); + // Add a connector to the indexing set (called when indexing starts) const startIndexing = useCallback((connectorId: number) => { setIndexingConnectorIds((prev) => {