From 045599b415b42c3c1fbeaa22a431e7757e3ae701 Mon Sep 17 00:00:00 2001 From: Kris Braun Date: Sat, 1 Aug 2026 13:12:21 -0400 Subject: [PATCH] Google: stop the Tasks poll erroring on a disconnected account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Tasks has no webhooks, so its sync is a durable hourly poll. When the account's OAuth grant lapses or is revoked, the channel resolves no token and the poll threw an unhandled error — once an hour, multiplied by the queue's retries, for as long as the connection stayed disconnected. That state is expected and already surfaced: resolving no token flags the connection for re-auth, so the app shows "Reconnect" and only the user can clear it. It's also specific to Tasks among this connector's products — mail and calendar are push-driven, and a revoked grant simply stops delivering notifications, so nothing keeps knocking. The poll now ends the cycle quietly instead. It drops the in-flight cycle state and reports done; the recurring schedule re-fires on its own and picks back up once the connection is restored. `last_sync_time_` is left untouched, so the window skipped while disconnected is re-covered by the first successful cycle rather than being silently lost, and the channel is left enabled — this is not a teardown, unlike the deleted-task-list path. The initial backfill deliberately keeps throwing: the app shows a sync spinner until the backfill reports completion, and a silent early return there would leave it spinning forever. Co-Authored-By: Claude Opus 5 --- connectors/google/src/tasks/sync.test.ts | 64 +++++++++++++++++++++++- connectors/google/src/tasks/sync.ts | 44 +++++++++++++++- 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/connectors/google/src/tasks/sync.test.ts b/connectors/google/src/tasks/sync.test.ts index e1ab79ae..0006ab16 100644 --- a/connectors/google/src/tasks/sync.test.ts +++ b/connectors/google/src/tasks/sync.test.ts @@ -55,11 +55,15 @@ type HostHarness = { saveLink: ReturnType; }; -function makeHost(): HostHarness { +function makeHost(options?: { token?: { token: string; scopes: string[] } | null }): HostHarness { const store = new Map(); const archiveLinks = vi.fn(async () => {}); const cancelScheduledTask = vi.fn(async () => {}); const saveLink = vi.fn(async () => "thread-id"); + const token: { token: string; scopes: string[] } | null = + options && "token" in options + ? (options.token ?? null) + : { token: "tok", scopes: [] }; const host: TasksSyncHost = { id: "twist-instance-1", @@ -73,7 +77,7 @@ function makeHost(): HostHarness { }, tools: { integrations: { - get: async () => ({ token: "tok", scopes: [] }), + get: async () => token, saveLink, channelSyncCompleted: vi.fn(async () => {}), archiveLinks, @@ -157,6 +161,62 @@ describe("periodicSyncBatchFn — deleted task list (404)", () => { }); }); +describe("periodicSyncBatchFn — connection needs re-auth (no token)", () => { + it("ends the cycle quietly instead of throwing", async () => { + // The account's OAuth grant lapsed or was revoked, so the channel resolves + // no token. The platform already flags the connection for re-auth (the app + // shows "Reconnect"), so this is an expected, user-actionable state — not a + // connector bug. Throwing from the hourly poll turns one lapsed connection + // into an error report every hour, multiplied by the queue's retries, for + // as long as it stays disconnected. Tasks is the only Google product that + // keeps hitting a dead token this way: mail and calendar are push-driven, + // and a revoked grant simply stops delivering notifications. + const { host, store, archiveLinks, cancelScheduledTask } = makeHost({ + token: null, + }); + store.set(`sync_enabled_${LIST_ID}`, true); + store.set(`periodic_sync_state_${LIST_ID}`, { + pageToken: null, + cycleStart: "2026-06-25T00:00:00.000Z", + } satisfies PeriodicSyncState); + + const result = await periodicSyncBatchFn(host, LIST_ID); + + expect(result).toEqual({ done: true }); + // The in-flight cycle is dropped so the next poll starts a clean one... + expect(store.has(`periodic_sync_state_${LIST_ID}`)).toBe(false); + // ...but this is NOT a teardown: the list still exists and the channel is + // still enabled, so re-auth must resume syncing without re-enabling. + expect(cancelScheduledTask).not.toHaveBeenCalled(); + expect(archiveLinks).not.toHaveBeenCalled(); + expect(store.get(`sync_enabled_${LIST_ID}`)).toBe(true); + // last_sync_time is left untouched so the cycle skipped here is re-covered + // by the first poll after re-auth rather than silently lost. + expect(store.has(`last_sync_time_${LIST_ID}`)).toBe(false); + expect(api.listTasks).not.toHaveBeenCalled(); + }); +}); + +describe("syncBatchFn — connection needs re-auth (no token)", () => { + it("still throws, so the initial backfill does not silently latch", async () => { + // The initial backfill is different: the app shows a sync spinner until + // channelSyncCompleted fires, and a silent early return would leave it + // spinning forever. Throwing here is what drives the stuck-sync watchdog + // to surface "Reconnect", so this path must keep throwing. + const { host, store } = makeHost({ token: null }); + store.set(`sync_state_${LIST_ID}`, { + pageToken: null, + batchNumber: 1, + tasksProcessed: 0, + initialSync: true, + } satisfies SyncState); + + await expect(syncBatchFn(host, LIST_ID)).rejects.toThrow( + /authentication token/i + ); + }); +}); + describe("onLinkUpdatedFn — deleted task/list (404)", () => { it("swallows the 404 instead of throwing", async () => { const { host } = makeHost(); diff --git a/connectors/google/src/tasks/sync.ts b/connectors/google/src/tasks/sync.ts index 4d634480..97d6b180 100644 --- a/connectors/google/src/tasks/sync.ts +++ b/connectors/google/src/tasks/sync.ts @@ -142,6 +142,34 @@ export async function getTokenFn( return token.token; } +/** + * Like {@link getTokenFn}, but returns `null` instead of throwing when the + * channel resolves no auth token (lapsed / revoked Google OAuth). + * + * Use this on the recurring poll, where a missing token is an expected, + * already-surfaced state rather than a fault: reading it flags the connection + * for re-auth, so the app is already showing "Reconnect" and the user is the + * only one who can clear it. Throwing there instead reports one unhandled + * error per poll — multiplied by the queue's retries — for as long as the + * connection stays disconnected, which can be indefinitely. + * + * Google Tasks is the only product in this connector that hits a dead token + * this way: mail and calendar are push-driven, and a revoked grant simply + * stops delivering notifications. Tasks has no webhooks, so its hourly poll + * keeps knocking. + * + * Paths that must still surface a missing token — notably the initial + * backfill, where a silent early return would leave the app's sync spinner + * running forever — keep using the throwing {@link getTokenFn}. + */ +export async function tryGetTokenFn( + host: TasksSyncHost, + channelId: string +): Promise { + const token = await host.tools.integrations.get(channelId); + return token?.token ?? null; +} + // --------------------------------------------------------------------------- // Channel enable / disable (data-plane state) // --------------------------------------------------------------------------- @@ -353,7 +381,21 @@ export async function periodicSyncBatchFn( if (!state) return { done: true }; const lastSync = await host.get(`last_sync_time_${listId}`); - const token = await getTokenFn(host, listId); + + // No token means the Google grant lapsed or was revoked. Reading it already + // flagged the connection for re-auth, so the user is seeing "Reconnect" and + // nothing this poll does can help until they act. Drop the in-flight cycle + // and report `done` rather than throwing: the poll is durably recurring, so + // it re-fires on its own and picks straight back up once the connection is + // restored. `last_sync_time_` is deliberately left untouched, so the window + // skipped here is re-covered by the first successful cycle instead of being + // silently lost. + const token = await tryGetTokenFn(host, listId); + if (!token) { + await host.clear(`periodic_sync_state_${listId}`); + return { done: true }; + } + const authActorId = await host.get("auth_actor_id"); let result;