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.
This commit is contained in:
Anish Sarkar 2026-01-23 18:57:10 +05:30
parent 29382070aa
commit 8d8f69545e
8 changed files with 113 additions and 12 deletions

View file

@ -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:

View file

@ -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(

View file

@ -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")

View file

@ -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"

View file

@ -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
}

View file

@ -97,7 +97,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
};
}, [checkScrollState]);
// Reset local quick indexing state when indexing completes
// Reset local quick indexing state when indexing completes or fails
useEffect(() => {
if (!isIndexing) {
setIsQuickIndexing(false);

View file

@ -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]

View file

@ -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<Set<number>>(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) => {