Skip to content

fix(calendar): stop retrying refused grants and failed push channels - #667

Merged
Valtora merged 2 commits into
mainfrom
fix/calendar-reconnect-and-push-backoff
Oct 23, 2026
Merged

Valtora merged 2 commits into
mainfrom
fix/calendar-reconnect-and-push-backoff

Conversation

@Valtora

@Valtora Valtora commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Pull Request

Description

Fixes #661 and #662. Both sit in the calendar sync's failure handling and share calendar_push_service.py.

#661. _get_access_token_for_connection now reads the token endpoint's OAuth error code. A 400 or 401 with invalid_grant, interaction_required, consent_required or login_required raises CalendarReauthorisationRequired, which sync records as reauthorisation_required with the reconnect wording Settings already shows. Google's "Bad Request" and "Token has been expired or revoked." bodies and Microsoft's AADSTS refusals all carry one of those codes. A reconnect-required account is left out of the push sweep's provisioning list, and the reconcile (after a sync, or from the sweep) asks for no token: it drops the account's channel rows and returns. The next failed attempt reclassifies an existing row, so there is no migration.

#662. _mark_channel_failed stamps updated_at itself, so writing the same failure again still moves the backoff clock. last_error and the warning now hold the HTTP status and the provider's code and message, capped at 200 characters: Graph's error.code and error.message, Google's errors[].reason and error.message. Only the response body's error fields are read, never the request, so no token or calendar address gets in. A 4xx other than 429 waits 6 hours. A network error, 5xx or 429 waits 30 minutes, as before.

docs/CALENDAR.md describes both, adds the CDN or WAF line to "Live Sync Not Activating", and notes that a Google app in testing mode ends each sign-in after seven days.

Decision record

  • Classify where the token is refreshed. A typed exception from _get_access_token_for_connection replaces the old match on "reauthorisation" in a ValueError's text. _classify_sync_failure also reads the OAuth code on a raw token-endpoint error, as a backstop.
  • invalid_client is not a refusal. A bad client secret is the admin's to fix and reconnecting would not help, so a 400 invalid_client stays "Needs attention" and keeps retrying. A 401 still means reconnect, as before.
  • Drop channel rows when a reconnect is required. Without a token nothing can be renewed or stopped. Active rows would keep the Live sync badge up for days and queue a failing sync on each notification. The sweep visits an account that still has rows once, to drop them without a token request. Reconnecting provisions fresh channels.
  • No new column. The backoff class is read back from the HTTP <status> that starts last_error. The alternatives were a retry_after column, which means a migration for a best-effort feature, or a new channel status, a wider change in meaning. A row in the old format gets the short wait once and is then rewritten.
  • 6 hours. A blocked webhook then costs four attempts a day per calendar, and a fixed proxy rule still takes effect the same day. Google's 403 rate-limit reasons get the long wait too, which is conservative, and the 15-minute poll is unaffected.
  • Left as is: the sync and the sweep can both attempt a channel when they run in the same second at a backoff boundary, so at most one extra attempt every 6 hours.

Type of change

  • Bug fix (non-breaking change that fixes an issue)

Checks run

  • Python: scripts/check.py gates (lint, format, blindexcept, heldpins, typecheck, docs, alembic) and the full pytest suite with Postgres, 3651 passed. Host side: whitespace, file size, docs validator, git diff --check.

The new tests use Google's two invalid_grant bodies and Microsoft AADSTS bodies, and drive a refused grant through sync_connection_by_id on SQLite to check the token endpoint is asked once. The backoff tests write the same Graph refusal again and again through the real ORM and check that the attempts land at 0, 6 and 12 hours. All of them fail on main.

Migration impact

  • No database migration.

Documentation impact

  • docs/CALENDAR.md: Sync Behaviour, Enabling Live Sync, Google Testing Mode Blocks a User, Live Sync Not Activating.

Security impact

  • No security-sensitive change. Token storage and refresh are unchanged; the new log text comes from the response body's error fields only.

Manual verification

None by hand beyond the tests.

Valtora and others added 2 commits October 23, 2026 18:27
A token endpoint's invalid_grant, interaction_required, consent_required
or login_required now marks the account reconnect required, read from
the OAuth error code rather than its description. Such accounts leave
the push sweep and the reconcile after a sync, and their channel rows
are dropped until they reconnect.

A failed push channel now stamps its attempt time, so a repeated failure
still backs off: 6 hours after a 4xx, 30 minutes after a network error,
5xx or 429. The log and last_error keep the HTTP status and the
provider's error code and message.

Fixes: #661
Fixes: #662
@Valtora
Valtora merged commit 7a2ba97 into main Oct 23, 2026
21 checks passed
@Valtora
Valtora deleted the fix/calendar-reconnect-and-push-backoff branch October 23, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Calendar sync retries a revoked Google token forever instead of asking to reconnect

1 participant