Skip to content

Commit 3e98cc0

Browse files
committed
fix(search): preserve Google sync diagnostics and retry transient failures
1 parent cb67145 commit 3e98cc0

24 files changed

Lines changed: 710 additions & 261 deletions

apps/docs/content/docs/search/gmail.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ Updates, removals, and access refresh in the background. Empty mailboxes and fil
139139

140140
## Troubleshooting
141141

142+
An individual thread failure does not mean the whole mailbox failed. Sim retries temporary server and rate-limit errors with bounded backoff. Error diagnostics record the Google API operation, HTTP status, and a recognized error reason when available.
143+
142144
| What you see | What to do |
143145
| --- | --- |
144146
| A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. |

apps/docs/content/docs/search/google-calendar.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ Search schedules syncs hourly. Event edits, cancellations, access changes, inact
135135

136136
## Troubleshooting
137137

138+
When a sync fails, **Sync history** includes the Google API operation, HTTP status, and a recognized reason when available. A `403` alone does not establish a missing scope: `rateLimitExceeded` and `userRateLimitExceeded` are retried with bounded backoff. A persistent access error requires checking the affected user’s Calendar access.
139+
138140
| What you see | What to do |
139141
| --- | --- |
140142
| No events | Check the date range, search query, and calendar IDs. Use an empty calendar selection or `primary` for each person's own calendar. |

apps/docs/content/docs/search/google-drive.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ Search schedules syncs hourly. Central crawls revisit the selected users' files
155155

156156
## Troubleshooting
157157

158+
**Directory permission sync failed** means Sim could not fully verify group membership. Check the Directory administrator’s access to the affected group and any nested groups; this is separate from file-download access. An incomplete membership read does not replace the last verified membership, which remains subject to freshness checks.
159+
158160
| Problem | Next step |
159161
| --- | --- |
160162
| Google rejects authorization (`unauthorized_client`) | In **Manage Domain Wide Delegation**, verify the numeric **Client ID** matches `client_id` in the JSON key uploaded to Sim and all required scopes appear under **View details**. Check pending approval and allow time for recent changes to propagate. Changing the OAuth consent screen alone does not authorize delegation. |

apps/sim/background/knowledge-processing.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { DrizzleQueryError } from 'drizzle-orm/errors'
45
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
56

67
const {
@@ -566,6 +567,22 @@ describe('knowledge processing worker', () => {
566567
expect(mockTrigger).not.toHaveBeenCalled()
567568
})
568569

570+
it('keeps database failures retryable without sending SQL or parameters to Trigger', async () => {
571+
const error = new DrizzleQueryError(
572+
'insert private SQL',
573+
['private bound content'],
574+
Object.assign(new Error('private database detail'), { code: '57014' })
575+
)
576+
mockProcessDocumentAsync.mockRejectedValueOnce(error)
577+
const failure = await runDocumentProcessing(WORKSPACE_PAYLOAD).catch(
578+
(caught: unknown) => caught
579+
)
580+
expect(failure).toBeInstanceOf(Error)
581+
expect(failure).toMatchObject({ message: 'Database request failed (SQLSTATE 57014).' })
582+
expect(failure).not.toHaveProperty('cause')
583+
expect(JSON.stringify(failure)).not.toContain('private')
584+
})
585+
569586
it('retries failed provider continuation dispatch instead of reporting a successful deferral', async () => {
570587
const error = new Error('Trigger dispatch unavailable')
571588
mockTrigger.mockRejectedValue(error)

apps/sim/background/knowledge-processing.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isBYOKEmbeddingCredentialRejection,
88
isEmbeddingQuotaExhaustion,
99
} from '@/lib/embeddings'
10+
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
1011
import {
1112
getOcrRequestRejection,
1213
isPermanentDocumentProcessingError,
@@ -194,7 +195,12 @@ export async function runDocumentProcessing(
194195
processingTime: Date.now() - startedAt,
195196
}
196197
}
197-
logger.error(`[${requestId}] Failed to process document: ${docData.filename}`, error)
198+
const diagnostic = getConnectorFailureDiagnostic(error)
199+
logger.error(
200+
`[${requestId}] Failed to process document: ${docData.filename}`,
201+
diagnostic ?? error
202+
)
203+
if (diagnostic?.category === 'database') throw new Error(diagnostic.message)
198204
throw error
199205
}
200206
}

apps/sim/connectors/gmail/gmail.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const { mockFetchWithRetry } = vi.hoisted(() => ({ mockFetchWithRetry: vi.fn() }))
77

8-
vi.mock('@/lib/knowledge/documents/utils', () => ({ VALIDATE_RETRY_OPTIONS: {} }))
98
vi.mock('@/lib/knowledge/documents/secure-fetch.server', () => ({
10-
fetchWithRetry: mockFetchWithRetry,
9+
fetchWithRetry: (
10+
url: string,
11+
init: RequestInit,
12+
options: {
13+
fetcher?: (url: string, init: RequestInit, transport: typeof fetch) => Promise<Response>
14+
}
15+
) =>
16+
options.fetcher
17+
? options.fetcher(url, init, mockFetchWithRetry)
18+
: mockFetchWithRetry(url, init),
1119
}))
1220
vi.mock('@/components/icons', () => ({ GmailIcon: () => null }))
1321
vi.mock('@/lib/knowledge/documents/service', () => ({
@@ -618,7 +626,9 @@ describe('Gmail separately stored message bodies', () => {
618626
.catch((caught: unknown) => caught)
619627

620628
expect(error).toBeInstanceOf(Error)
621-
expect(error).toMatchObject({ message: `Failed to fetch Gmail message body: ${status}` })
629+
expect(error).toMatchObject({
630+
message: `gmail.messages.attachments.get failed (HTTP ${status}).`,
631+
})
622632
expect(gmailConnector.isCredentialInvalidError?.(error)).toBe(status === 401)
623633
}
624634
)
@@ -899,7 +909,7 @@ describe('Gmail thread revisions and deferred content', () => {
899909
.mockResolvedValueOnce(Response.json({ threads: [{ id: 'thread-1' }] }))
900910
.mockResolvedValueOnce(new Response(null, { status }))
901911
await expect(gmailConnector.listDocuments('token', {}, undefined, {})).rejects.toThrow(
902-
`Failed to fetch thread thread-1: ${status}`
912+
`gmail.threads.get failed (HTTP ${status}).`
903913
)
904914
}
905915
)

apps/sim/connectors/gmail/gmail.ts

Lines changed: 52 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { isPlainRecord } from '@sim/utils/object'
44
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
55
import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
6-
import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
76
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
87
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
8+
import { fetchGoogleApiWithRetry, GoogleApiError } from '@/connectors/google-workspace/api-errors'
99
import {
1010
getGoogleWorkspaceDocument,
1111
InvalidGoogleWorkspaceCursor,
@@ -53,16 +53,6 @@ const CHANGED_THREAD_CONCURRENCY = 5
5353
/** Gmail's thread listing omits these unless `includeSpamTrash` is set; the feed must agree. */
5454
const HIDDEN_LABEL_IDS = new Set(['SPAM', 'TRASH'])
5555

56-
class GmailApiError extends Error {
57-
constructor(
58-
message: string,
59-
readonly status: number
60-
) {
61-
super(`${message}: ${status}`)
62-
this.name = 'GmailApiError'
63-
}
64-
}
65-
6656
interface GmailHeader {
6757
name: string
6858
value: string
@@ -229,26 +219,23 @@ async function getLabelIndex(
229219

230220
let index: GmailLabelIndex | null = null
231221
try {
232-
const response = await fetchWithRetry(`${GMAIL_API_BASE}/labels`, {
233-
method: 'GET',
234-
signal,
235-
headers: {
236-
Authorization: `Bearer ${accessToken}`,
237-
Accept: 'application/json',
238-
},
239-
})
222+
const response = await fetchGoogleApiWithRetry(
223+
'gmail.labels.list',
224+
`${GMAIL_API_BASE}/labels`,
225+
{
226+
method: 'GET',
227+
signal,
228+
headers: {
229+
Authorization: `Bearer ${accessToken}`,
230+
Accept: 'application/json',
231+
},
232+
}
233+
)
240234

241-
if (response.status === 401) {
242-
throw new GmailApiError('Failed to fetch Gmail labels', response.status)
243-
}
244-
if (response.ok) {
245-
index = buildLabelIndex(await readLabels(response))
246-
} else {
247-
logger.warn('Failed to fetch Gmail labels', { status: response.status })
248-
}
235+
index = buildLabelIndex(await readLabels(response))
249236
} catch (error) {
250237
signal?.throwIfAborted()
251-
if (error instanceof GmailApiError && error.status === 401) throw error
238+
if (error instanceof GoogleApiError && error.status === 401) throw error
252239
logger.warn('Failed to fetch Gmail labels', { error: toError(error).message })
253240
}
254241

@@ -387,17 +374,15 @@ async function readMessageBody(
387374
if (!body.attachmentId) return body.data ? decodeBase64Url(body.data, context) : ''
388375

389376
const params = new URLSearchParams({ fields: 'data,size' })
390-
const response = await fetchWithRetry(
377+
const response = await fetchGoogleApiWithRetry(
378+
'gmail.messages.attachments.get',
391379
`${GMAIL_API_BASE}/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(body.attachmentId)}?${params}`,
392380
{
393381
method: 'GET',
394382
signal: context.signal,
395383
headers: { Authorization: `Bearer ${context.accessToken}`, Accept: 'application/json' },
396384
}
397385
)
398-
if (!response.ok) {
399-
throw new GmailApiError('Failed to fetch Gmail message body', response.status)
400-
}
401386

402387
let fetchedBody: unknown
403388
try {
@@ -591,18 +576,16 @@ async function fetchThread(
591576
params.set('fields', 'id,historyId,snippet,messages(id,labelIds,internalDate)')
592577
const url = `${GMAIL_API_BASE}/threads/${encodeURIComponent(threadId)}?${params}`
593578

594-
const response = await fetchWithRetry(url, {
595-
method: 'GET',
596-
signal,
597-
headers: {
598-
Authorization: `Bearer ${accessToken}`,
599-
Accept: 'application/json',
600-
},
601-
})
602-
603-
if (!response.ok) {
604-
if (response.status === 404) return null
605-
throw new GmailApiError(`Failed to fetch thread ${threadId}`, response.status)
579+
let response: Response
580+
try {
581+
response = await fetchGoogleApiWithRetry('gmail.threads.get', url, {
582+
method: 'GET',
583+
signal,
584+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
585+
})
586+
} catch (error) {
587+
if (error instanceof GoogleApiError && error.status === 404) return null
588+
throw error
606589
}
607590

608591
const thread = await readResponseJsonWithLimit(response, {
@@ -790,18 +773,21 @@ function threadInScope(thread: GmailThread, scope: GmailChangeScope): boolean {
790773
const gmailMailboxConnector: ConnectorConfig = {
791774
...gmailConnectorMeta,
792775

793-
isCredentialInvalidError: (error) => error instanceof GmailApiError && error.status === 401,
776+
isCredentialInvalidError: (error) => error instanceof GoogleApiError && error.status === 401,
794777

795778
/** The mailbox's current history id; `users.history.list` replays everything after it. */
796779
getChangeCursor: async (accessToken, _sourceConfig, syncContext): Promise<string> => {
797780
if (syncContext?.mirrorsSourceAcls === true) {
798781
throw new Error('Company-wide Gmail indexing uses complete mailbox listings')
799782
}
800-
const response = await fetchWithRetry(`${GMAIL_API_BASE}/profile?fields=historyId`, {
801-
method: 'GET',
802-
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
803-
})
804-
if (!response.ok) throw new GmailApiError('Failed to read the Gmail profile', response.status)
783+
const response = await fetchGoogleApiWithRetry(
784+
'gmail.users.getProfile',
785+
`${GMAIL_API_BASE}/profile?fields=historyId`,
786+
{
787+
method: 'GET',
788+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
789+
}
790+
)
805791
const data: unknown = await response.json()
806792
if (
807793
!isPlainRecord(data) ||
@@ -851,14 +837,14 @@ const gmailMailboxConnector: ConnectorConfig = {
851837
for (const type of HISTORY_TYPES) params.append('historyTypes', type)
852838
if (pageToken) params.set('pageToken', pageToken)
853839

854-
const response = await fetchWithRetry(`${GMAIL_API_BASE}/history?${params.toString()}`, {
855-
method: 'GET',
856-
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
857-
})
858-
if (!response.ok) {
859-
logger.warn('Failed to list Gmail history', { status: response.status })
860-
throw new GmailApiError('Failed to list Gmail history', response.status)
861-
}
840+
const response = await fetchGoogleApiWithRetry(
841+
'gmail.history.list',
842+
`${GMAIL_API_BASE}/history?${params.toString()}`,
843+
{
844+
method: 'GET',
845+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
846+
}
847+
)
862848
const page = parseHistoryList(await response.json())
863849

864850
const changes = await mapWithConcurrency(
@@ -881,7 +867,7 @@ const gmailMailboxConnector: ConnectorConfig = {
881867
/** Gmail answers 404 once `startHistoryId` falls outside the history it retains. */
882868
isChangeCursorInvalidError: (error) =>
883869
error instanceof InvalidGmailChangeCursorError ||
884-
(error instanceof GmailApiError && error.status === 404),
870+
(error instanceof GoogleApiError && error.status === 404),
885871

886872
listDocuments: async (
887873
accessToken: string,
@@ -953,7 +939,7 @@ const gmailMailboxConnector: ConnectorConfig = {
953939
maxThreads,
954940
})
955941

956-
const response = await fetchWithRetry(url, {
942+
const response = await fetchGoogleApiWithRetry('gmail.threads.list', url, {
957943
method: 'GET',
958944
signal,
959945
headers: {
@@ -962,11 +948,6 @@ const gmailMailboxConnector: ConnectorConfig = {
962948
},
963949
})
964950

965-
if (!response.ok) {
966-
logger.error('Failed to list Gmail threads', { status: response.status })
967-
throw new GmailApiError('Failed to list Gmail threads', response.status)
968-
}
969-
970951
/** Gmail can return 204 when an empty listing has no requested metadata fields. */
971952
const { threads, nextPageToken } = parseThreadList(
972953
response.status === 204
@@ -1102,7 +1083,8 @@ const gmailMailboxConnector: ConnectorConfig = {
11021083

11031084
try {
11041085
const profileUrl = `${GMAIL_API_BASE}/profile`
1105-
const profileResponse = await fetchWithRetry(
1086+
await fetchGoogleApiWithRetry(
1087+
'gmail.users.getProfile',
11061088
profileUrl,
11071089
{
11081090
method: 'GET',
@@ -1115,10 +1097,6 @@ const gmailMailboxConnector: ConnectorConfig = {
11151097
VALIDATE_RETRY_OPTIONS
11161098
)
11171099

1118-
if (!profileResponse.ok) {
1119-
return { valid: false, error: `Failed to access Gmail: ${profileResponse.status}` }
1120-
}
1121-
11221100
/**
11231101
* Labels may arrive as ids (from the `gmail.labels` selector) or as names
11241102
* (typed into the advanced input), so both forms are accepted here and the
@@ -1128,7 +1106,8 @@ const gmailMailboxConnector: ConnectorConfig = {
11281106
let labelIndex = EMPTY_LABEL_INDEX
11291107
if (configuredLabels.length > 0) {
11301108
const labelsUrl = `${GMAIL_API_BASE}/labels`
1131-
const labelsResponse = await fetchWithRetry(
1109+
const labelsResponse = await fetchGoogleApiWithRetry(
1110+
'gmail.labels.list',
11321111
labelsUrl,
11331112
{
11341113
method: 'GET',
@@ -1141,10 +1120,6 @@ const gmailMailboxConnector: ConnectorConfig = {
11411120
VALIDATE_RETRY_OPTIONS
11421121
)
11431122

1144-
if (!labelsResponse.ok) {
1145-
return { valid: false, error: 'Failed to fetch labels' }
1146-
}
1147-
11481123
const labels = await readLabels(labelsResponse)
11491124
labelIndex = buildLabelIndex(labels)
11501125
const missing = configuredLabels.filter(
@@ -1171,7 +1146,8 @@ const gmailMailboxConnector: ConnectorConfig = {
11711146
if (query?.trim()) {
11721147
const searchQuery = buildSearchQuery(sourceConfig, labelIndex)
11731148
const testUrl = `${GMAIL_API_BASE}/threads?q=${encodeURIComponent(searchQuery)}&maxResults=1`
1174-
const testResponse = await fetchWithRetry(
1149+
await fetchGoogleApiWithRetry(
1150+
'gmail.threads.list',
11751151
testUrl,
11761152
{
11771153
method: 'GET',
@@ -1183,10 +1159,6 @@ const gmailMailboxConnector: ConnectorConfig = {
11831159
},
11841160
VALIDATE_RETRY_OPTIONS
11851161
)
1186-
1187-
if (!testResponse.ok) {
1188-
return { valid: false, error: 'Invalid search query. Check Gmail search syntax.' }
1189-
}
11901162
}
11911163

11921164
return { valid: true }

0 commit comments

Comments
 (0)