Skip to content

Fix: Improve Google Calendar OAuth reliability with retry and wake refresh#904

Open
jclawson-atlassian wants to merge 1 commit intoleits:masterfrom
jclawson-atlassian:fix/google-oauth-reliability-pr
Open

Fix: Improve Google Calendar OAuth reliability with retry and wake refresh#904
jclawson-atlassian wants to merge 1 commit intoleits:masterfrom
jclawson-atlassian:fix/google-oauth-reliability-pr

Conversation

@jclawson-atlassian
Copy link
Copy Markdown

@jclawson-atlassian jclawson-atlassian commented Mar 18, 2026

Problem

When MeetingBar uses Google Calendar as the data source, the connection often "drops" overnight. After waking the Mac in the morning, no meetings are displayed. Users have to manually go to Preferences → Calendars, switch to macOS Calendar, switch back to Google Calendar, and re-select their calendars to restore functionality.

Root Cause

Two issues were identified:

  1. No refresh on wake/unlock: When the Mac sleeps overnight and the user unlocks it in the morning, nothing triggers an event refresh. The 180-second Combine timer gets suspended during sleep and may not fire reliably or immediately on wake.

  2. Silent failure on auth errors: When a refresh fails (e.g., expired OAuth token after overnight sleep), the error is logged but the result is silently set to empty arrays — showing zero calendars and zero events with no user feedback.

Solution

Wake/Unlock Refresh

  • unlockListener now triggers eventManager.triggerRefresh() on screen unlock
  • Added NSWorkspace.didWakeNotification observer as a belt-and-suspenders for system wake (covers Touch ID auto-unlock and other edge cases)
  • triggerRefresh() includes a 15-minute staleness check to avoid redundant API calls on quick lock/unlock cycles

Exponential Backoff Retry

  • Replaced the "try once, silently return empty" logic with fetchWithRetry()
  • Retries up to 5 times with exponential backoff: 2s → 4s → 8s → 16s → 32s
  • On each retry, calls provider.signIn(forcePrompt: false) to re-authenticate (triggering AppAuth's token refresh)
  • After all retries are exhausted, sends a macOS notification alerting the user instead of silently showing no events

Test Infrastructure

  • Added XCTest guard in applicationDidFinishLaunching to prevent the app UI from launching during tests
  • Suppressed macOS notifications during test runs
  • Extended FakeEventStore with error injection and call counting for retry testing
  • Added comprehensive tests: retry exhaustion, transient failure recovery, no-retry on success, staleness skip, and staleness proceed

Files Changed

File Change
MeetingBar/App/AppDelegate.swift Wake/unlock refresh triggers, XCTest guard
MeetingBar/Core/Managers/EventManager.swift Retry logic, staleness check, triggerRefresh()
MeetingBarTests/Helpers/FakeEventStore.swift Error injection support for testing
MeetingBarTests/RetryAndStalenessTests.swift 5 new tests

Summary by CodeRabbit

  • New Features
    • Calendar events now automatically refresh when your computer wakes up or is unlocked
    • Automatic retry mechanism for failed event fetches to improve reliability
    • Smart refresh optimization to avoid redundant updates within recent time windows

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Mar 18, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 18, 2026

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: improving Google Calendar OAuth reliability through retry logic and wake refresh mechanisms.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable the changed files summary in the walkthrough.

Disable the reviews.changed_files_summary setting to disable the changed files summary in the walkthrough.

@dosubot dosubot Bot added the bug Something isn't working label Mar 18, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 90.49774% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 33.95%. Comparing base (84e0a9f) to head (be073c4).
⚠️ Report is 15 commits behind head on master.

Files with missing lines Patch % Lines
MeetingBar/App/AppDelegate.swift 18.75% 13 Missing ⚠️
MeetingBar/Core/Managers/EventManager.swift 82.97% 7 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #904      +/-   ##
==========================================
+ Coverage   26.30%   33.95%   +7.64%     
==========================================
  Files          36       49      +13     
  Lines        5645     5307     -338     
  Branches     2210     1748     -462     
==========================================
+ Hits         1485     1802     +317     
+ Misses       4103     3462     -641     
+ Partials       57       43      -14     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
MeetingBarTests/Helpers/FakeEventStore.swift (1)

58-60: Let the fake throw from signIn(forcePrompt:) too.

The retry path now depends on re-auth failures as much as fetchAllCalendars() failures, but this helper can only simulate the latter. Adding injectable sign-in failures here would let the new suite cover the auth-refresh branch that motivated the PR.

Possible test-helper extension
+    var signInErrorToThrow: Error?
+
     func signIn(forcePrompt: Bool = false) async throws {
         signInCallCount += 1
+        if let error = signInErrorToThrow {
+            throw error
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBarTests/Helpers/FakeEventStore.swift` around lines 58 - 60, The
FakeEventStore.signIn(forcePrompt:) currently only increments signInCallCount
and cannot simulate auth failures; add an injectable failure control (e.g. a
signInError: Error? or signInShouldThrow + signInError) to the FakeEventStore
and update signIn(forcePrompt:) to throw that error when set while still
incrementing signInCallCount; ensure tests can set/reset this injectable to
exercise the auth-refresh retry branch.
MeetingBarTests/RetryAndStalenessTests.swift (1)

28-45: Make this exhaustion test deterministic.

The fixed 3s asyncAfter makes this case slower and can flap if another refresh cycle starts before the timer fires. Waiting on a published terminal state—or injecting a sleeper/scheduler—would make the retry-count assertions precise instead of timing-based.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBarTests/RetryAndStalenessTests.swift` around lines 28 - 45, Replace
the time-based DispatchQueue.main.asyncAfter in RetryAndStalenessTests with a
deterministic wait for the store's terminal "exhausted" state (or use an
injected test scheduler to advance timers); observe the store's published state
(e.g., store.$state or equivalent property) and fulfill the expectation when it
transitions to the exhaustion/terminal enum value, then assert
store.fetchCalendarsCallCount and store.signInCallCount (still using the cycles
= fetchCalendarsCallCount / 5 logic) so the test no longer relies on a fixed 3s
delay and becomes deterministic and reliable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@MeetingBar/Core/Managers/EventManager.swift`:
- Around line 119-124: The triggerRefresh() method currently only checks
lastSuccessfulRefresh and can enqueue duplicate refresh work when multiple
wake/unlock events arrive before that timestamp updates; add an "in-flight"
guard to coalesce concurrent refreshes by tracking whether a refresh is
currently running (e.g., an atomic Bool or DispatchQueue-protected flag like
isRefreshInFlight) and return early if set; set the flag true immediately when
starting the refresh pipeline (or inside triggerRefresh before calling
refreshSubject.send()) and clear it when the pipeline completes or fails so
subsequent calls will trigger again; reference triggerRefresh, refreshSubject,
lastSuccessfulRefresh, and Self.staleThreshold when adding the flag and its
set/clear points.
- Around line 170-174: EventManager currently calls provider.signIn(forcePrompt:
false) on any retry (inside the attempt > 0 block), which causes unwanted
permission/OAuth dialogs (e.g., EKEventStore ignores forcePrompt) and triggers
re-auth flows for non-auth errors; change this to only attempt a silent refresh
for providers that support it and only for authentication-related errors: add
and use a provider capability check (e.g., provider.supportsSilentRefresh or a
new Provider.refreshTokenSilently()) and guard the retry call by inspecting the
error (auth/token expired error kinds) before invoking the refresh; ensure
EKEventStore and other providers that always prompt are excluded from the
retry-path so signIn() is not called unconditionally during EventManager retries
(preserve existing maxRetryAttempts/attempt logic).

---

Nitpick comments:
In `@MeetingBarTests/Helpers/FakeEventStore.swift`:
- Around line 58-60: The FakeEventStore.signIn(forcePrompt:) currently only
increments signInCallCount and cannot simulate auth failures; add an injectable
failure control (e.g. a signInError: Error? or signInShouldThrow + signInError)
to the FakeEventStore and update signIn(forcePrompt:) to throw that error when
set while still incrementing signInCallCount; ensure tests can set/reset this
injectable to exercise the auth-refresh retry branch.

In `@MeetingBarTests/RetryAndStalenessTests.swift`:
- Around line 28-45: Replace the time-based DispatchQueue.main.asyncAfter in
RetryAndStalenessTests with a deterministic wait for the store's terminal
"exhausted" state (or use an injected test scheduler to advance timers); observe
the store's published state (e.g., store.$state or equivalent property) and
fulfill the expectation when it transitions to the exhaustion/terminal enum
value, then assert store.fetchCalendarsCallCount and store.signInCallCount
(still using the cycles = fetchCalendarsCallCount / 5 logic) so the test no
longer relies on a fixed 3s delay and becomes deterministic and reliable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b996686d-baf9-4614-8638-b3356bea8419

📥 Commits

Reviewing files that changed from the base of the PR and between 48373d7 and be073c4.

📒 Files selected for processing (4)
  • MeetingBar/App/AppDelegate.swift
  • MeetingBar/Core/Managers/EventManager.swift
  • MeetingBarTests/Helpers/FakeEventStore.swift
  • MeetingBarTests/RetryAndStalenessTests.swift

Comment on lines +119 to +124
public func triggerRefresh() {
guard Date().timeIntervalSince(lastSuccessfulRefresh) > Self.staleThreshold else {
NSLog("EventManager skipping refresh — last successful refresh was \(Int(Date().timeIntervalSince(lastSuccessfulRefresh)))s ago (threshold: \(Int(Self.staleThreshold))s)")
return
}
refreshSubject.send()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Coalesce wake/unlock refreshes while one is already running.

Both wake handlers in MeetingBar/App/AppDelegate.swift, Lines 333-345 call this, and this guard only checks the last successful refresh. After a long sleep, wake and unlock can both arrive before that timestamp is updated, so you still enqueue duplicate retry loops and duplicate Google calls. Add an in-flight guard here (or in the pipeline) to coalesce those notifications.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/Managers/EventManager.swift` around lines 119 - 124, The
triggerRefresh() method currently only checks lastSuccessfulRefresh and can
enqueue duplicate refresh work when multiple wake/unlock events arrive before
that timestamp updates; add an "in-flight" guard to coalesce concurrent
refreshes by tracking whether a refresh is currently running (e.g., an atomic
Bool or DispatchQueue-protected flag like isRefreshInFlight) and return early if
set; set the flag true immediately when starting the refresh pipeline (or inside
triggerRefresh before calling refreshSubject.send()) and clear it when the
pipeline completes or fails so subsequent calls will trigger again; reference
triggerRefresh, refreshSubject, lastSuccessfulRefresh, and Self.staleThreshold
when adding the flag and its set/clear points.

Comment on lines +170 to +174
// On retries, attempt to re-authenticate in case the token expired
if attempt > 0 {
NSLog("EventManager retry attempt \(attempt)/\(Self.maxRetryAttempts) — re-authenticating…")
try await provider.signIn(forcePrompt: false)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(EventManager|EventStore)" | head -20

Repository: leits/MeetingBar

Length of output: 325


🏁 Script executed:

cat -n MeetingBar/Core/Managers/EventManager.swift | sed -n '160,180p'

Repository: leits/MeetingBar

Length of output: 1176


🏁 Script executed:

cat -n MeetingBar/Core/EventStores/EKEventStore.swift | sed -n '1,50p'

Repository: leits/MeetingBar

Length of output: 2055


🏁 Script executed:

cat -n MeetingBar/Core/EventStores/Protocol.swift

Repository: leits/MeetingBar

Length of output: 929


🏁 Script executed:

cat -n MeetingBar/Core/EventStores/GCEventStore.swift | head -100

Repository: leits/MeetingBar

Length of output: 4432


🏁 Script executed:

cat -n MeetingBar/Core/Managers/EventManager.swift | sed -n '160,210p'

Repository: leits/MeetingBar

Length of output: 2606


🏁 Script executed:

cat -n MeetingBar/Core/EventStores/GCEventStore.swift | sed -n '95,180p'

Repository: leits/MeetingBar

Length of output: 4130


🏁 Script executed:

rg "forcePrompt" MeetingBar/Core/EventStores/EKEventStore.swift

Repository: leits/MeetingBar

Length of output: 124


Extract silent token refresh from universal retry hook.

The code calls provider.signIn(forcePrompt: false) on every retry attempt (EventManager.swift:173), but this is problematic:

  1. EKEventStore (lines 22-38) ignores the forcePrompt parameter entirely and always triggers EventKit's permission request APIs (requestFullAccessToEvents or requestAccess), which display system dialogs.

  2. The retry is unconditional—it calls signIn() regardless of the error type. Network failures, API errors, and rate-limiting will incorrectly trigger re-authentication flows instead of being treated as transient failures.

This forces users through permission dialogs or OAuth flows even when the original failure had nothing to do with authorization. Either implement a provider-specific silent token refresh mechanism (distinct from signIn()), or gate this call to only auth-related errors and providers that support silent refresh.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@MeetingBar/Core/Managers/EventManager.swift` around lines 170 - 174,
EventManager currently calls provider.signIn(forcePrompt: false) on any retry
(inside the attempt > 0 block), which causes unwanted permission/OAuth dialogs
(e.g., EKEventStore ignores forcePrompt) and triggers re-auth flows for non-auth
errors; change this to only attempt a silent refresh for providers that support
it and only for authentication-related errors: add and use a provider capability
check (e.g., provider.supportsSilentRefresh or a new
Provider.refreshTokenSilently()) and guard the retry call by inspecting the
error (auth/token expired error kinds) before invoking the refresh; ensure
EKEventStore and other providers that always prompt are excluded from the
retry-path so signIn() is not called unconditionally during EventManager retries
(preserve existing maxRetryAttempts/attempt logic).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant