Skip to content

Commit 6a4f5f2

Browse files
authored
fix(trigger): handle Drive rate limits, 410 page token expiry, and clean up comments (#4112)
* fix(trigger): handle Drive rate limits, 410 page token expiry, and clean up comments * fix(trigger): treat Drive rate limits as success to preserve failure budget * fix(trigger): distinguish Drive 403 rate limits from permission errors, preserve knownFileIds on 410 re-seed
1 parent 74d0a47 commit 6a4f5f2

File tree

1 file changed

+50
-26
lines changed

1 file changed

+50
-26
lines changed

apps/sim/lib/webhooks/polling/google-drive.ts

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,12 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
8989

9090
const config = webhookData.providerConfig as unknown as GoogleDriveWebhookConfig
9191

92-
// First poll: get startPageToken and seed state
92+
// First poll (or re-seed after 410): seed page token, preserve any existing known file IDs.
9393
if (!config.pageToken) {
9494
const startPageToken = await getStartPageToken(accessToken, config, requestId, logger)
95-
9695
await updateWebhookProviderConfig(
9796
webhookId,
98-
{ pageToken: startPageToken, knownFileIds: [] },
97+
{ pageToken: startPageToken, knownFileIds: config.knownFileIds ?? [] },
9998
logger
10099
)
101100
await markWebhookSuccess(webhookId, logger)
@@ -105,7 +104,6 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
105104
return 'success'
106105
}
107106

108-
// Fetch changes since last pageToken
109107
const { changes, newStartPageToken } = await fetchChanges(
110108
accessToken,
111109
config,
@@ -120,7 +118,6 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
120118
return 'success'
121119
}
122120

123-
// Filter changes client-side (folder, MIME type, trashed)
124121
const filteredChanges = filterChanges(changes, config)
125122

126123
if (!filteredChanges.length) {
@@ -145,11 +142,6 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
145142
logger
146143
)
147144

148-
// Update state: new pageToken and rolling knownFileIds.
149-
// Newest IDs are placed first so that when the set exceeds MAX_KNOWN_FILE_IDS,
150-
// the oldest (least recently seen) IDs are evicted. Recent files are more
151-
// likely to be modified again, so keeping them prevents misclassifying a
152-
// repeat modification as a "created" event.
153145
const existingKnownIds = config.knownFileIds || []
154146
const mergedKnownIds = [...new Set([...newKnownFileIds, ...existingKnownIds])].slice(
155147
0,
@@ -180,13 +172,38 @@ export const googleDrivePollingHandler: PollingProviderHandler = {
180172
)
181173
return 'success'
182174
} catch (error) {
175+
if (error instanceof Error && error.name === 'DrivePageTokenInvalidError') {
176+
await updateWebhookProviderConfig(webhookId, { pageToken: undefined }, logger)
177+
await markWebhookSuccess(webhookId, logger)
178+
logger.warn(
179+
`[${requestId}] Drive page token invalid for webhook ${webhookId}, re-seeding on next poll`
180+
)
181+
return 'success'
182+
}
183+
if (error instanceof Error && error.name === 'DriveRateLimitError') {
184+
await markWebhookSuccess(webhookId, logger)
185+
logger.warn(
186+
`[${requestId}] Drive API rate limited for webhook ${webhookId}, skipping to retry next poll cycle`
187+
)
188+
return 'success'
189+
}
183190
logger.error(`[${requestId}] Error processing Google Drive webhook ${webhookId}:`, error)
184191
await markWebhookFailed(webhookId, logger)
185192
return 'failure'
186193
}
187194
},
188195
}
189196

197+
const DRIVE_RATE_LIMIT_REASONS = new Set(['rateLimitExceeded', 'userRateLimitExceeded'])
198+
199+
/** Returns true only for quota/rate-limit 403s, not permission errors. */
200+
function isDriveRateLimitError(status: number, errorData: Record<string, unknown>): boolean {
201+
if (status !== 403) return false
202+
const reason = (errorData as { error?: { errors?: { reason?: string }[] } })?.error?.errors?.[0]
203+
?.reason
204+
return reason !== undefined && DRIVE_RATE_LIMIT_REASONS.has(reason)
205+
}
206+
190207
async function getStartPageToken(
191208
accessToken: string,
192209
config: GoogleDriveWebhookConfig,
@@ -204,9 +221,15 @@ async function getStartPageToken(
204221
})
205222

206223
if (!response.ok) {
224+
const status = response.status
207225
const errorData = await response.json().catch(() => ({}))
226+
if (status === 429 || isDriveRateLimitError(status, errorData)) {
227+
const err = new Error(`Drive API rate limit (${status}): ${JSON.stringify(errorData)}`)
228+
err.name = 'DriveRateLimitError'
229+
throw err
230+
}
208231
throw new Error(
209-
`Failed to get startPageToken: ${response.status} - ${JSON.stringify(errorData)}`
232+
`Failed to get Drive start page token: ${status} - ${JSON.stringify(errorData)}`
210233
)
211234
}
212235

@@ -227,7 +250,6 @@ async function fetchChanges(
227250
const maxFiles = config.maxFilesPerPoll || MAX_FILES_PER_POLL
228251
let pages = 0
229252

230-
// eslint-disable-next-line no-constant-condition
231253
while (true) {
232254
pages++
233255
const params = new URLSearchParams({
@@ -248,8 +270,19 @@ async function fetchChanges(
248270
})
249271

250272
if (!response.ok) {
273+
const status = response.status
251274
const errorData = await response.json().catch(() => ({}))
252-
throw new Error(`Failed to fetch changes: ${response.status} - ${JSON.stringify(errorData)}`)
275+
if (status === 410) {
276+
const err = new Error('Drive page token is no longer valid')
277+
err.name = 'DrivePageTokenInvalidError'
278+
throw err
279+
}
280+
if (status === 429 || isDriveRateLimitError(status, errorData)) {
281+
const err = new Error(`Drive API rate limit (${status}): ${JSON.stringify(errorData)}`)
282+
err.name = 'DriveRateLimitError'
283+
throw err
284+
}
285+
throw new Error(`Failed to fetch Drive changes: ${status} - ${JSON.stringify(errorData)}`)
253286
}
254287

255288
const data = await response.json()
@@ -274,12 +307,9 @@ async function fetchChanges(
274307
currentPageToken = data.nextPageToken as string
275308
}
276309

310+
// When allChanges exceeds maxFiles (multi-page overshoot), resume mid-list via lastNextPageToken.
311+
// Otherwise resume from newStartPageToken (end of change list) or lastNextPageToken (MAX_PAGES hit).
277312
const slicingOccurs = allChanges.length > maxFiles
278-
// Drive API guarantees exactly one of nextPageToken or newStartPageToken per response.
279-
// Slicing case: prefer lastNextPageToken (mid-list resume); fall back to newStartPageToken
280-
// (guaranteed on final page when hasMore was false). Non-slicing case: prefer newStartPageToken
281-
// (guaranteed when loop exhausted all pages); fall back to lastNextPageToken (when loop exited
282-
// early due to MAX_PAGES with hasMore still true).
283313
const resumeToken = slicingOccurs
284314
? (lastNextPageToken ?? newStartPageToken!)
285315
: (newStartPageToken ?? lastNextPageToken!)
@@ -292,26 +322,21 @@ function filterChanges(
292322
config: GoogleDriveWebhookConfig
293323
): DriveChangeEntry[] {
294324
return changes.filter((change) => {
295-
// Always include removals (deletions)
296325
if (change.removed) return true
297326

298327
const file = change.file
299328
if (!file) return false
300329

301-
// Exclude trashed files
302330
if (file.trashed) return false
303331

304-
// Folder filter: check if file is in the specified folder
305332
const folderId = config.folderId || config.manualFolderId
306333
if (folderId) {
307334
if (!file.parents || !file.parents.includes(folderId)) {
308335
return false
309336
}
310337
}
311338

312-
// MIME type filter
313339
if (config.mimeTypeFilter) {
314-
// Support prefix matching (e.g., "image/" matches "image/png", "image/jpeg")
315340
if (config.mimeTypeFilter.endsWith('/')) {
316341
if (!file.mimeType.startsWith(config.mimeTypeFilter)) {
317342
return false
@@ -339,7 +364,6 @@ async function processChanges(
339364
const knownFileIdsSet = new Set(config.knownFileIds || [])
340365

341366
for (const change of changes) {
342-
// Determine event type before idempotency to avoid caching filter decisions
343367
let eventType: 'created' | 'modified' | 'deleted'
344368
if (change.removed) {
345369
eventType = 'deleted'
@@ -349,12 +373,12 @@ async function processChanges(
349373
eventType = 'modified'
350374
}
351375

352-
// Track file as known regardless of filter (for future create/modify distinction)
376+
// Track file as known regardless of filter so future changes are correctly classified
353377
if (!change.removed) {
354378
newKnownFileIds.push(change.fileId)
355379
}
356380

357-
// Client-side event type filter — skip before idempotency so filtered events aren't cached
381+
// Apply event type filter before idempotency so filtered events aren't cached
358382
const filter = config.eventTypeFilter
359383
if (filter) {
360384
const skip = filter === 'created_or_modified' ? eventType === 'deleted' : eventType !== filter

0 commit comments

Comments
 (0)