@@ -89,10 +89,9 @@ 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: seed page token and known file set
9393 if ( ! config . pageToken ) {
9494 const startPageToken = await getStartPageToken ( accessToken , config , requestId , logger )
95-
9695 await updateWebhookProviderConfig (
9796 webhookId ,
9897 { pageToken : startPageToken , knownFileIds : [ ] } ,
@@ -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,6 +172,14 @@ 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+ }
183183 logger . error ( `[${ requestId } ] Error processing Google Drive webhook ${ webhookId } :` , error )
184184 await markWebhookFailed ( webhookId , logger )
185185 return 'failure'
@@ -204,9 +204,15 @@ async function getStartPageToken(
204204 } )
205205
206206 if ( ! response . ok ) {
207+ const status = response . status
207208 const errorData = await response . json ( ) . catch ( ( ) => ( { } ) )
209+ if ( status === 403 || status === 429 ) {
210+ throw new Error (
211+ `Drive API rate limit (${ status } ) — skipping to retry next poll cycle: ${ JSON . stringify ( errorData ) } `
212+ )
213+ }
208214 throw new Error (
209- `Failed to get startPageToken : ${ response . status } - ${ JSON . stringify ( errorData ) } `
215+ `Failed to get Drive start page token : ${ status } - ${ JSON . stringify ( errorData ) } `
210216 )
211217 }
212218
@@ -227,7 +233,6 @@ async function fetchChanges(
227233 const maxFiles = config . maxFilesPerPoll || MAX_FILES_PER_POLL
228234 let pages = 0
229235
230- // eslint-disable-next-line no-constant-condition
231236 while ( true ) {
232237 pages ++
233238 const params = new URLSearchParams ( {
@@ -248,8 +253,19 @@ async function fetchChanges(
248253 } )
249254
250255 if ( ! response . ok ) {
256+ const status = response . status
251257 const errorData = await response . json ( ) . catch ( ( ) => ( { } ) )
252- throw new Error ( `Failed to fetch changes: ${ response . status } - ${ JSON . stringify ( errorData ) } ` )
258+ if ( status === 410 ) {
259+ const err = new Error ( 'Drive page token is no longer valid' )
260+ err . name = 'DrivePageTokenInvalidError'
261+ throw err
262+ }
263+ if ( status === 403 || status === 429 ) {
264+ throw new Error (
265+ `Drive API rate limit (${ status } ) — skipping to retry next poll cycle: ${ JSON . stringify ( errorData ) } `
266+ )
267+ }
268+ throw new Error ( `Failed to fetch Drive changes: ${ status } - ${ JSON . stringify ( errorData ) } ` )
253269 }
254270
255271 const data = await response . json ( )
@@ -274,12 +290,9 @@ async function fetchChanges(
274290 currentPageToken = data . nextPageToken as string
275291 }
276292
293+ // When allChanges exceeds maxFiles (multi-page overshoot), resume mid-list via lastNextPageToken.
294+ // Otherwise resume from newStartPageToken (end of change list) or lastNextPageToken (MAX_PAGES hit).
277295 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).
283296 const resumeToken = slicingOccurs
284297 ? ( lastNextPageToken ?? newStartPageToken ! )
285298 : ( newStartPageToken ?? lastNextPageToken ! )
@@ -292,26 +305,21 @@ function filterChanges(
292305 config : GoogleDriveWebhookConfig
293306) : DriveChangeEntry [ ] {
294307 return changes . filter ( ( change ) => {
295- // Always include removals (deletions)
296308 if ( change . removed ) return true
297309
298310 const file = change . file
299311 if ( ! file ) return false
300312
301- // Exclude trashed files
302313 if ( file . trashed ) return false
303314
304- // Folder filter: check if file is in the specified folder
305315 const folderId = config . folderId || config . manualFolderId
306316 if ( folderId ) {
307317 if ( ! file . parents || ! file . parents . includes ( folderId ) ) {
308318 return false
309319 }
310320 }
311321
312- // MIME type filter
313322 if ( config . mimeTypeFilter ) {
314- // Support prefix matching (e.g., "image/" matches "image/png", "image/jpeg")
315323 if ( config . mimeTypeFilter . endsWith ( '/' ) ) {
316324 if ( ! file . mimeType . startsWith ( config . mimeTypeFilter ) ) {
317325 return false
@@ -339,7 +347,6 @@ async function processChanges(
339347 const knownFileIdsSet = new Set ( config . knownFileIds || [ ] )
340348
341349 for ( const change of changes ) {
342- // Determine event type before idempotency to avoid caching filter decisions
343350 let eventType : 'created' | 'modified' | 'deleted'
344351 if ( change . removed ) {
345352 eventType = 'deleted'
@@ -349,12 +356,12 @@ async function processChanges(
349356 eventType = 'modified'
350357 }
351358
352- // Track file as known regardless of filter (for future create/modify distinction)
359+ // Track file as known regardless of filter so future changes are correctly classified
353360 if ( ! change . removed ) {
354361 newKnownFileIds . push ( change . fileId )
355362 }
356363
357- // Client-side event type filter — skip before idempotency so filtered events aren't cached
364+ // Apply event type filter before idempotency so filtered events aren't cached
358365 const filter = config . eventTypeFilter
359366 if ( filter ) {
360367 const skip = filter === 'created_or_modified' ? eventType === 'deleted' : eventType !== filter
0 commit comments