mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-06 22:12:12 +02:00
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:
parent
29382070aa
commit
8d8f69545e
8 changed files with 113 additions and 12 deletions
|
|
@ -142,6 +142,12 @@ class GoogleCalendarConnector:
|
||||||
flag_modified(connector, "config")
|
flag_modified(connector, "config")
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
except Exception as e:
|
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(
|
raise Exception(
|
||||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
@ -165,6 +171,10 @@ class GoogleCalendarConnector:
|
||||||
self.service = build("calendar", "v3", credentials=credentials)
|
self.service = build("calendar", "v3", credentials=credentials)
|
||||||
return self.service
|
return self.service
|
||||||
except Exception as e:
|
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
|
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]:
|
async def get_calendars(self) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
|
@ -271,6 +281,10 @@ class GoogleCalendarConnector:
|
||||||
return events, None
|
return events, None
|
||||||
|
|
||||||
except Exception as e:
|
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}"
|
return [], f"Error fetching events: {e!s}"
|
||||||
|
|
||||||
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,12 @@ class GoogleGmailConnector:
|
||||||
flag_modified(connector, "config")
|
flag_modified(connector, "config")
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
except Exception as e:
|
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(
|
raise Exception(
|
||||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
@ -164,6 +170,10 @@ class GoogleGmailConnector:
|
||||||
self.service = build("gmail", "v1", credentials=credentials)
|
self.service = build("gmail", "v1", credentials=credentials)
|
||||||
return self.service
|
return self.service
|
||||||
except Exception as e:
|
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
|
raise Exception(f"Failed to create Gmail service: {e!s}") from e
|
||||||
|
|
||||||
async def get_user_profile(self) -> tuple[dict[str, Any], str | None]:
|
async def get_user_profile(self) -> tuple[dict[str, Any], str | None]:
|
||||||
|
|
@ -225,6 +235,10 @@ class GoogleGmailConnector:
|
||||||
return messages, None
|
return messages, None
|
||||||
|
|
||||||
except Exception as e:
|
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}"
|
return [], f"Error fetching messages list: {e!s}"
|
||||||
|
|
||||||
async def get_message_details(
|
async def get_message_details(
|
||||||
|
|
|
||||||
|
|
@ -246,13 +246,20 @@ async def index_google_calendar_events(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
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(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get Google Calendar events: {error}",
|
error_message,
|
||||||
"API Error",
|
error,
|
||||||
{"error_type": "APIError"},
|
{"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")
|
logger.info(f"Retrieved {len(events)} events from Google Calendar API")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,10 +170,20 @@ async def index_google_gmail_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
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(
|
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:
|
if not messages:
|
||||||
success_msg = "No Google gmail messages found in the specified date range"
|
success_msg = "No Google gmail messages found in the specified date range"
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import { Cable, Loader2 } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
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 { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||||
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
||||||
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
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 = () => {
|
export const ConnectorIndicator: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
||||||
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
||||||
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
|
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
|
// Check if YouTube view is active
|
||||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
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
|
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||||
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
|
// Also clears when failed notifications are detected
|
||||||
connectors as SearchSourceConnector[]
|
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||||
|
connectors as SearchSourceConnector[],
|
||||||
|
inboxItems
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = connectorsLoading || documentTypesLoading;
|
const isLoading = connectorsLoading || documentTypesLoading;
|
||||||
|
|
@ -246,7 +258,7 @@ export const ConnectorIndicator: FC = () => {
|
||||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||||
? () => {
|
? () => {
|
||||||
startIndexing(editingConnector.id);
|
startIndexing(editingConnector.id);
|
||||||
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
|
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type, stopIndexing);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
};
|
};
|
||||||
}, [checkScrollState]);
|
}, [checkScrollState]);
|
||||||
|
|
||||||
// Reset local quick indexing state when indexing completes
|
// Reset local quick indexing state when indexing completes or fails
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isIndexing) {
|
if (!isIndexing) {
|
||||||
setIsQuickIndexing(false);
|
setIsQuickIndexing(false);
|
||||||
|
|
|
||||||
|
|
@ -1375,7 +1375,7 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
// Handle quick index (index without date picker, uses backend defaults)
|
// Handle quick index (index without date picker, uses backend defaults)
|
||||||
const handleQuickIndexConnector = useCallback(
|
const handleQuickIndexConnector = useCallback(
|
||||||
async (connectorId: number, connectorType?: string) => {
|
async (connectorId: number, connectorType?: string, stopIndexing?: (id: number) => void) => {
|
||||||
if (!searchSpaceId) return;
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
// Track quick index clicked event
|
// Track quick index clicked event
|
||||||
|
|
@ -1401,6 +1401,10 @@ export const useConnectorDialog = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error indexing connector content:", error);
|
console.error("Error indexing connector content:", error);
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
|
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
|
||||||
|
// Stop indexing state on error
|
||||||
|
if (stopIndexing) {
|
||||||
|
stopIndexing(connectorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchSpaceId, indexConnector]
|
[searchSpaceId, indexConnector]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
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.
|
* 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:
|
* This provides a better UX than polling by:
|
||||||
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
|
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
|
||||||
* 2. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
* 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.
|
* 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
|
// Set of connector IDs that are currently indexing
|
||||||
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
|
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
|
@ -48,6 +54,40 @@ export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
||||||
}
|
}
|
||||||
}, [connectors, indexingConnectorIds]);
|
}, [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)
|
// Add a connector to the indexing set (called when indexing starts)
|
||||||
const startIndexing = useCallback((connectorId: number) => {
|
const startIndexing = useCallback((connectorId: number) => {
|
||||||
setIndexingConnectorIds((prev) => {
|
setIndexingConnectorIds((prev) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue