← Back to all posts

The 7-Day Bug: How Google OAuth Testing Mode Silently Broke My Calendar Sync

I build a scheduling app for a personal trainer. It is a small Astro SSR app running on Cloudflare Workers with a D1 database, the interface is in Hebrew, and the person using it every day is not technical. The trainer does not read logs, does not know what OAuth is, and should never have to. One of the features they rely on is a one-way sync: every session booked in the app shows up in a Google Calendar on their phone.

From about August 1, 2026, that sync stopped working. Nothing crashed. The app kept booking, moving and cancelling sessions exactly as before. Google Calendar simply stopped receiving anything new. It stayed that way for roughly a month, until the fix shipped on September 2. This post is the full postmortem: the root cause, why it was invisible, a second limit I ran into while fixing it, and the reconciliation design the sync uses now.

How the sync is built

The app is the source of truth. Google is a mirror, and only in one direction: app to Google, never back. To keep the blast radius small, the app asks for the narrowest Calendar scope Google offers, calendar.app.created. With it, the app can create its own calendar and manage events inside that calendar, and it cannot see the trainer's primary calendar or any existing events.

// Narrowest possible scope: the app can only see/manage calendars *it*
// creates, never the trainer's primary calendar or existing events.
const SCOPE = "https://www.googleapis.com/auth/calendar.app.created";
const CALENDAR_NAME = "Training Sessions";

When the trainer connects, the OAuth callback exchanges the code for tokens, creates a fresh "Training Sessions" calendar, and stores the access token, refresh token and expiry in a google_calendar_connections row. The auth URL uses access_type: "offline" and prompt: "consent" so Google always hands back a refresh token. After that, every create, edit and cancel in the app pushes a POST, PATCH or DELETE to the Calendar API.

Access tokens live for about an hour, and the stored refresh token gets a new one. That refresh step is where everything went wrong.

Root cause: Testing mode and the 7-day refresh token

Every Google OAuth app has a consent screen with a publishing status. While you develop, it sits in Testing, which limits sign-in to a list of test users you add by hand. For an app built for one specific trainer, that felt like the right place to leave it.

What I had not internalized is Google's rule for that state: for an external app in Testing mode that requests scopes beyond basic profile info, refresh tokens expire seven days after they are issued. Not after seven days of inactivity. Seven days, period. Calendar scopes are not basic profile scopes, so this applied.

So the integration worked perfectly for a week after the trainer connected. Then the refresh token died, every refresh call to oauth2.googleapis.com/token started failing with an invalid_grant error, and no event reached Google from then on. Reconnecting would have bought another seven days, then the same failure again.

The fix for the root cause was not code. It was moving the OAuth app from Testing to In production in the Google Cloud console. Publishing required a public privacy policy page, so the app now has one, stating what the scope can and cannot touch and that tokens are deleted on disconnect. Once published, refresh tokens no longer expire on a fixed seven-day timer.

Why nobody noticed for a month

A month-long outage of a feature someone uses daily should not be possible. It was possible because of a design decision I still think is correct, combined with one I got wrong.

The correct decision: the Google sync is best-effort and must never block scheduling. If Google is down or slow, the trainer still needs to book a session. So the sync function catches everything and returns. The wrong decision: the catch block did nothing else. It swallowed the error with no trace at all. Here is the current version, with the line that was missing:

  } catch (err) {
    // best-effort - never block scheduling, but log so worker observability
    // shows sync failures instead of a silent month-long outage.
    console.error(`Google sync failed for session ${session.id}:`, err);
    return false;
  }

Workers observability is enabled in the project's wrangler.jsonc, which means anything written with console.error shows up in the Cloudflare dashboard with the invocation that produced it. Before the fix, there was nothing to show. Non-2xx responses from the Calendar API are now logged too, with the HTTP status and the response body, so a 401 or 404 is distinguishable from a network failure.

The other half of the silence is the user. A non-technical user who trusts the phone has no reason to suspect the app, because the app itself looks fine. The only defense is making failures loud on the developer's side.

The second lesson: 50 subrequests is not a lot

While fixing the sync I also had to deal with a limit that had been quietly shaping the code. On the Workers free plan, a single invocation gets roughly 50 subrequests. Outgoing fetch calls count, and so do D1 queries. Every SELECT and every UPDATE spends budget.

That matters because the app supports recurring sessions, and an indefinite weekly series is materialized up front, capped at 52 occurrences (INDEFINITE_OCCURRENCE_CAP). The naive implementation had per-item loops everywhere. Creating a series checked each occurrence for conflicts with its own D1 query, so a weekly series spent 52 subrequests before inserting a single row. Syncing then did a connection lookup, a Google fetch and a D1 write per session. A long series would die partway through, leaving some sessions in Google and some not.

The fixes were all variations of "one query instead of N":

const { results: existingSessions } = await db
  .prepare(
    `SELECT starts_at, duration_minutes FROM sessions
     WHERE trainer_id = ? AND status != 'cancelled' AND starts_at >= ? AND starts_at < ?`
  )
  .bind(trainerId, rangeStart, rangeEnd)
  .all<{ starts_at: string; duration_minutes: number }>();

Batching reads is not enough, because each synced session still costs a Google fetch plus a D1 write for the returned event id. So the request path also caps its inline sync work:

const auth = await getValidAccessToken(db, trainer.id, env).catch(() => null);
if (auth) {
  const toSync = await getSessionsByIds(db, trainer.id, result.createdIds.slice(0, 10));
  for (const created of toSync) {
    await syncSessionToGoogle(db, env, trainer.id, created, auth);
  }
}

Ten sessions inline gives the trainer immediate feedback for the next few weeks of a series. The loop is sequential on purpose, since a burst of parallel requests risks Google's per-user rate limit. The other 42 sessions of a 52-week series are left for the cron.

Reconciliation: let the cron finish the job

Capping inline work only works if the leftover work is recorded somewhere durable and picked up later. A session with google_event_id IS NULL has never been mirrored. A cancelled session that still has a google_event_id has an event in Google that should be deleted. The one missing case was edits: a session that was synced, then moved to a different time. Its event id is set, so it looks synced, but the event in Google shows the old time.

Migration 0013 added a flag for exactly that:

-- Marks sessions whose time changed after they were already synced to
-- Google, so the hourly cron can re-PATCH the events the inline (capped)
-- sync didn't reach. 0 = in sync / never synced, 1 = needs re-sync.
ALTER TABLE sessions ADD COLUMN google_dirty INTEGER NOT NULL DEFAULT 0;

Every write that changes a session's time sets google_dirty = 1 in the same UPDATE, so the flag can never fall out of step with the change it describes. The sync function clears it only after Google returns a success response. A failed PATCH leaves the row dirty, and it gets retried.

A small wrapper adds a scheduled handler next to the Astro fetch handler, and a cron trigger runs it hourly. Each run handles three queues for each connection, in small batches:

const pending = await listUnsyncedSessions(db, trainer_id, 10);
for (const session of pending) {
  await syncSessionToGoogle(db, env, trainer_id, session, auth);
}

// ... WHERE s.google_dirty = 1 AND s.google_event_id IS NOT NULL
//     AND s.status != 'cancelled' LIMIT 5   -> re-PATCH

// ... WHERE status = 'cancelled' AND google_event_id IS NOT NULL
//     LIMIT 5                                 -> DELETE

The batch sizes come from the budget. A run with no token refresh spends one query to list connections, one to load the connection, one query per queue (three), and up to two subrequests per session: 20 for creates, 10 for edits, 10 for deletes. That is 45. A refresh adds a token request and two more D1 calls, which brings it to 48. It is tight, but it fits.

The useful property of this design is that every failure mode turns into a delay instead of lost data. A dead token, a Google outage, a 52-session series, or a request that runs out of subrequests all leave rows in one of the three queues, and the cron keeps draining them once the cause is gone. Ten creates an hour clears a full 52-week series in about five hours, which is fine for a calendar mirror.

Recovering from the dead token

Publishing the OAuth app fixes future tokens. It does not resurrect the expired one already stored in the database, so recovery needed a few manual steps from the trainer:

  1. Disconnect and reconnect Google Calendar in the app settings to get a new, non-expiring refresh token.
  2. Run the backfill from the same settings screen, which pushes unsynced sessions starting 30 days back.
  3. Enable the new calendar in the Google Calendar phone app.

The third step surprised me. Every connect creates a brand new "Training Sessions" calendar, and a freshly created secondary calendar may not be visible in the phone app until it is switched on there. From the trainer's point of view, they reconnected and still saw nothing, which looks exactly like the original bug. That step now needs to be part of any reconnect instructions.

A new calendar also means every stored event id is stale, since those ids point at events in the old calendar. PATCHing them would return 404s. The callback clears them so everything flows back through the "never synced" queue:

// Every connect creates a fresh calendar, so event ids from a previous
// connection point at the old one - clear them so the hourly cron
// re-syncs everything into the new calendar instead of PATCHing 404s.
await db.prepare("UPDATE sessions SET google_event_id = NULL WHERE trainer_id = ?").bind(trainer.id).run();

The backfill endpoint processes one batch of 20 per click and leaves the rest to the cron. Since the sync function swallows its own errors, it counts success by re-querying which attempted ids now have a google_event_id, so the settings page reports what actually landed in Google.

What is still not great

A checklist for Google OAuth on Workers

What I would check before calling a Google OAuth integration on Workers done:

  1. Check the consent screen publishing status. If it says Testing and you request anything beyond basic profile scopes, your refresh tokens expire after seven days. Publish to Production before a real user connects.
  2. Have a public privacy policy page ready. Write it to match the exact scope you request.
  3. Pick the narrowest scope that works. calendar.app.created was enough for a one-way mirror into a dedicated calendar.
  4. Never swallow errors silently. Best-effort with no log line is how you get a month-long outage. Use console.error and turn on Workers observability.
  5. Log the token refresh path separately. It fails differently from API calls, and it is the one that tells you a human has to reconnect.
  6. Count your subrequests. D1 queries count toward the roughly 50 per invocation on the free plan. Write the budget down next to the batch sizes.
  7. Resolve auth once per request, replace per-item queries with one range or IN query, and use db.batch() for bulk writes.
  8. Cap inline work and record what is left. A null external id, a dirty flag and a "cancelled but still has an id" condition were enough to describe every pending change here.
  9. Add a scheduled reconciler that drains those queues in small batches, so failures become delays.
  10. Plan the reconnect flow. Clear stale external ids, offer a backfill, and tell the user about any step they must take on their own device.

The root cause was a single setting in a console I rarely open. The real damage came from code that was designed to fail quietly and did exactly that. The publishing status fixed the first problem in a minute. The logging, the dirty flag and the cron are what should keep the next problem from going unnoticed for a month.