@@ -117,19 +117,27 @@ export type OutlookEvent = {
117117
118118export type SyncState = {
119119 calendarId : string ;
120- state ?: string ; // deltaToken or deltaLink
120+ state ?: string ; // nextLink (listing mode) or deltaToken/ deltaLink (delta mode)
121121 more ?: boolean ;
122122 min ?: Date ;
123123 max ?: Date ;
124124 sequence ?: number ;
125125 /**
126126 * Two-pass initial sync phase. `"quick"` walks `timeMin = now` to
127- * front-load upcoming meetings; `"full"` walks the 2-year historical
128- * backfill afterwards. Both phases share one sync lock; the
129- * quick→full transition swaps state without releasing. Absent for
130- * incremental syncs.
127+ * front-load upcoming meetings; `"full"` walks the full event history
128+ * afterwards to establish a real delta cursor (see `mode`). Both phases
129+ * share one sync lock; the quick→full transition swaps state without
130+ * releasing. Absent for incremental syncs.
131131 */
132132 phase ?: "quick" | "full" ;
133+ /**
134+ * Which Graph resource `state` continues. Microsoft's `/events/delta`
135+ * resource ("SyncEvents") rejects `$filter`/`$orderby`/`$select`/`$expand`/
136+ * `$search` entirely — so any request bounded by `min`/`max` must use a
137+ * plain, non-delta `/events` listing (`"listing"`) instead of the real
138+ * delta chain (`"delta"`), which only an unfiltered request may use.
139+ */
140+ mode ?: "listing" | "delta" ;
133141} ;
134142
135143/**
@@ -139,7 +147,12 @@ export class GraphApi {
139147 constructor ( public accessToken : string ) { }
140148
141149 /**
142- * Make a request to Microsoft Graph API
150+ * Make a request to Microsoft Graph API. Retries once on 429/503, honoring
151+ * a `Retry-After` header (seconds), capped at 15s — matches the retry
152+ * behavior already in GraphMailApi.call (mail/graph-mail-api.ts). Without
153+ * this, a transient rate limit during composeChannels (mail + calendar
154+ * fetching channels concurrently at connect time) surfaces as a hard
155+ * connect-time failure instead of a brief, self-healing delay.
143156 */
144157 public async call (
145158 method : string ,
@@ -155,39 +168,52 @@ export class GraphApi {
155168 ...( body ? { "Content-Type" : "application/json" } : { } ) ,
156169 } ;
157170
158- const response = await fetch ( url + query , {
159- method,
160- headers,
161- ...( body ? { body : JSON . stringify ( body ) } : { } ) ,
162- } ) ;
163-
164- switch ( response . status ) {
165- case 400 :
166- const responseBody = await response . json ( ) ;
167- throw new Error ( "Invalid request" , { cause : responseBody } ) ;
168- case 401 :
169- throw new Error ( "Authentication failed - token may be expired" ) ;
170- case 403 :
171- throw new Error ( "Access denied - insufficient permissions" ) ;
172- case 404 :
173- return null ;
174- case 410 :
175- // Gone - delta token expired, need full sync
176- return null ;
177- case 429 :
178- throw new Error ( "Rate limit exceeded - too many requests" ) ;
179- case 200 :
180- case 201 :
181- case 204 :
182- if ( response . status === 204 ) return { } ; // No content
183- return await response . json ( ) ;
184- case 500 :
185- case 502 :
186- case 503 :
187- case 504 :
188- throw new Error ( `Server error: ${ response . status } ` ) ;
189- default :
190- throw new Error ( await response . text ( ) ) ;
171+ for ( let attempt = 0 ; ; attempt ++ ) {
172+ const response = await fetch ( url + query , {
173+ method,
174+ headers,
175+ ...( body ? { body : JSON . stringify ( body ) } : { } ) ,
176+ } ) ;
177+
178+ if (
179+ ( response . status === 429 || response . status === 503 ) &&
180+ attempt === 0
181+ ) {
182+ const retryAfter = Number ( response . headers . get ( "Retry-After" ) ?? "2" ) ;
183+ await new Promise ( ( r ) =>
184+ setTimeout ( r , Math . min ( isNaN ( retryAfter ) ? 2 : retryAfter , 15 ) * 1000 )
185+ ) ;
186+ continue ;
187+ }
188+
189+ switch ( response . status ) {
190+ case 400 :
191+ const responseBody = await response . json ( ) ;
192+ throw new Error ( "Invalid request" , { cause : responseBody } ) ;
193+ case 401 :
194+ throw new Error ( "Authentication failed - token may be expired" ) ;
195+ case 403 :
196+ throw new Error ( "Access denied - insufficient permissions" ) ;
197+ case 404 :
198+ return null ;
199+ case 410 :
200+ // Gone - delta token expired, need full sync
201+ return null ;
202+ case 429 :
203+ throw new Error ( "Rate limit exceeded - too many requests" ) ;
204+ case 200 :
205+ case 201 :
206+ case 204 :
207+ if ( response . status === 204 ) return { } ; // No content
208+ return await response . json ( ) ;
209+ case 500 :
210+ case 502 :
211+ case 503 :
212+ case 504 :
213+ throw new Error ( `Server error: ${ response . status } ` ) ;
214+ default :
215+ throw new Error ( await response . text ( ) ) ;
216+ }
191217 }
192218 }
193219
@@ -599,7 +625,15 @@ export function transformOutlookEvent(
599625}
600626
601627/**
602- * Sync calendar events using Microsoft Graph delta query
628+ * Sync calendar events using Microsoft Graph.
629+ *
630+ * Microsoft's `/events/delta` resource ("SyncEvents") rejects
631+ * `$filter`/`$orderby`/`$select`/`$expand`/`$search` outright — there is no
632+ * way to bound a delta query by date. So any request with `min`/`max` set
633+ * (the quick pass, or a manual start with an explicit window) uses a plain,
634+ * non-delta `/events` listing instead; only a fully unfiltered request may
635+ * use the real delta chain that yields a `deltaLink` for future incremental
636+ * syncs.
603637 */
604638export async function syncOutlookCalendar (
605639 api : GraphApi ,
@@ -609,43 +643,37 @@ export async function syncOutlookCalendar(
609643 events : OutlookEvent [ ] ;
610644 state : SyncState ;
611645} > {
612- let url : string ;
646+ const resource =
647+ calendarId === "primary"
648+ ? "/me/events"
649+ : `/me/calendars/${ calendarId } /events` ;
613650
614- // If we have a delta link (full URL with token), use it directly
651+ const bounded = ! ! ( state . min || state . max ) ;
652+ const mode : "listing" | "delta" = bounded ? "listing" : "delta" ;
653+
654+ let url : string ;
615655 if ( state . state && state . state . startsWith ( "http" ) ) {
656+ // Continuing pagination via a full nextLink/deltaLink URL.
616657 url = state . state ;
617658 } else if ( state . state ) {
618- // We have a delta token, append it to the URL
619- const resource =
620- calendarId === "primary"
621- ? "/me/events"
622- : `/me/calendars/${ calendarId } /events` ;
659+ // Continuing a delta chain via a bare deltaToken.
623660 url = `https://graph.microsoft.com/v1.0${ resource } /delta?$deltatoken=${ state . state } ` ;
624- } else {
625- // Initial sync - use delta query without token
626- const resource =
627- calendarId === "primary"
628- ? "/me/events"
629- : `/me/calendars/${ calendarId } /events` ;
630-
661+ } else if ( bounded ) {
662+ // Bounded request: plain (non-delta) filtered listing.
631663 const params : string [ ] = [ ] ;
632-
633- // Add time filter if specified
634664 if ( state . min && state . max ) {
635- // Both min and max - use combined filter
636665 params . push (
637666 `$filter=start/dateTime ge '${ state . min . toISOString ( ) } ' and start/dateTime lt '${ state . max . toISOString ( ) } '`
638667 ) ;
639668 } else if ( state . min ) {
640- // Only min
641669 params . push ( `$filter=start/dateTime ge '${ state . min . toISOString ( ) } '` ) ;
642670 } else if ( state . max ) {
643- // Only max
644671 params . push ( `$filter=start/dateTime lt '${ state . max . toISOString ( ) } '` ) ;
645672 }
646-
647- const queryString = params . length > 0 ? `?${ params . join ( "&" ) } ` : "" ;
648- url = `https://graph.microsoft.com/v1.0${ resource } /delta${ queryString } ` ;
673+ url = `https://graph.microsoft.com/v1.0${ resource } ?${ params . join ( "&" ) } ` ;
674+ } else {
675+ // Unbounded: real delta query, no query params allowed.
676+ url = `https://graph.microsoft.com/v1.0${ resource } /delta` ;
649677 }
650678
651679 const data = ( await api . call ( "GET" , url ) ) as {
@@ -655,7 +683,7 @@ export async function syncOutlookCalendar(
655683 } | null ;
656684
657685 if ( ! data ) {
658- // Delta token expired or sync failed, need full sync
686+ // Token expired (410) or resource missing (404) — restart from scratch.
659687 const newState : SyncState = {
660688 calendarId,
661689 min : state . min ,
@@ -665,12 +693,14 @@ export async function syncOutlookCalendar(
665693 return syncOutlookCalendar ( api , calendarId , newState ) ;
666694 }
667695
668- // Extract next link or delta link
696+ // Extract next link (listing or mid-delta pagination) or delta link
697+ // (delta chain complete for this page).
669698 const nextLink = data [ "@odata.nextLink" ] ;
670699 const deltaLink = data [ "@odata.deltaLink" ] ;
671700
672701 const nextState : SyncState = {
673702 calendarId,
703+ mode,
674704 state : nextLink || deltaLink ,
675705 more : ! ! nextLink ,
676706 min : state . min ,
0 commit comments