@@ -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+
190207async 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