Handle Google token failures and report Google Calendar status
CI / ci (push) Successful in 4m29s
CI / ci (pull_request) Successful in 4m21s

When Google refuses the stored grant (invalid_grant on refresh, or HTTP
401 from the Calendar API to the access token just refreshed) on any
Calendar call - listing, choosing or pushing - the connection becomes
reconnect required under the account lock: the refresh token is cleared
and last_sync_error set to "Google access was revoked or expired —
reconnect". The call answers 409 google_reconnect_required, and later
calls answer the same without contacting Google until a reconnect or a
disconnect. The chosen calendar and stored calendar event ids are kept:
the pushed events are still on that calendar, so forgetEvents is not
run.

HTTP 429 and 5xx from the token endpoint or the Calendar API are retried
at most twice with backoff, then answered 503 google_unavailable,
retryable, without clearing or disconnecting anything.

Add GET /api/v1/integrations/google, reporting not_connected, connected
or reconnect_required with the chosen calendar and last_sync_error.

Migration 0015 rebuilds integrations_google so refresh_token_ct can be
cleared, with a CHECK that a row without a token carries the reason.

Maestro task b8d8da5a-0450-4571-963e-b3dfead24283.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:41:50 -04:00
co-authored by Claude Opus 5.5
parent 6262489376
commit 1c8092800a
22 changed files with 1059 additions and 149 deletions
+41 -11
View File
@@ -147,9 +147,8 @@ func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc
// writeGoogleCalendarError maps a calendar listing or choice error to its
// API error: 409 not_connected when nothing is connected, 422
// unknown_calendar for a calendar MealQuest cannot push to, 502
// reconnect_required when Google no longer accepts the stored grant, and 502
// google_failed when Google refused or could not be reached.
// unknown_calendar for a calendar MealQuest cannot push to, and a Google
// failure as writeGoogleFailure does.
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var calendar *gcal.CalendarError
switch {
@@ -158,16 +157,47 @@ func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *sl
case errors.Is(err, gcal.ErrUnknownCalendar):
writeError(w, r, logger, http.StatusUnprocessableEntity, "unknown_calendar",
"choose one of your calendars that MealQuest can add dinners to")
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be reached; try again")
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
writeGoogleFailure(w, r, logger, action, err, "Google Calendar could not be reached; try again")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
// writeGoogleFailure answers err, gcal.ErrReconnect or a *gcal.CalendarError,
// the way every Google Calendar call does: 409 google_reconnect_required when
// Google refused the stored grant (recorded as reconnect required, so only
// connecting again helps), 503 google_unavailable, retryable, when Google
// answered every try with HTTP 429 or 5xx (nothing was disconnected), and
// 502 google_failed, with failed as its message, for any other failure.
func writeGoogleFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error, failed string) {
logger.WarnContext(r.Context(), action, "error", err)
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrReconnect):
writeError(w, r, logger, http.StatusConflict, "google_reconnect_required",
"Google access was revoked or expired; connect Google Calendar again")
case errors.As(err, &calendar) && calendar.Retryable():
writeAPIError(w, r, logger, http.StatusServiceUnavailable, apiError{Code: "google_unavailable",
Message: "Google Calendar is busy right now; try again in a few minutes", Retryable: true})
default:
writeError(w, r, logger, http.StatusBadGateway, "google_failed", failed)
}
}
// getGoogle answers the Google Calendar connection's status: {status,
// calendar_id, calendar_name, connected_at, last_sync_error}, where status
// is not_connected, connected or reconnect_required.
func getGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
st, err := svc.Status(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read Google Calendar status", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, st)
}
}
+100 -17
View File
@@ -413,41 +413,124 @@ func TestGoogleCalendarsNotConnected(t *testing.T) {
}
}
// TestGoogleCalendarsGrantRevoked lists and chooses after the household
// removed MealQuest's access in their Google account: 502
// reconnect_required, and the connection is kept for the household to
// reconnect or disconnect.
// getGoogleStatus answers GET /integrations/google, decoded.
func (e *googleEnv) getGoogleStatus(t *testing.T) map[string]any {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googlePath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d %s, want 200", googlePath, rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("GET %s Cache-Control = %q, want no-store", googlePath, cc)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body
}
// TestGoogleCalendarsGrantRevoked lists and chooses after Google stopped
// accepting MealQuest's grant — the household removed its access in their
// Google account (invalid_grant), or Google refuses the access token just
// refreshed (HTTP 401): 409 google_reconnect_required, the refresh token is
// cleared, and GET /integrations/google reports reconnect_required with the
// reason, keeping the chosen calendar.
func TestGoogleCalendarsGrantRevoked(t *testing.T) {
t.Parallel()
for name, refuse := range map[string]func(*gcaltest.Google){
"invalid_grant": (*gcaltest.Google).RevokeGrant,
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
if rec := e.serve(chooseCalendar("family@group.calendar.google.com")); rec.Code != http.StatusOK {
t.Fatalf("PUT calendar = %d %s", rec.Code, rec.Body)
}
refuse(e.google)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("household@gmail.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "google_reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 409 google_reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
got := e.getGoogleStatus(t)
if got["status"] != "reconnect_required" || got["last_sync_error"] != "Google access was revoked or expired — reconnect" ||
got["calendar_id"] != "family@group.calendar.google.com" {
t.Errorf("GET %s = %v, want reconnect_required with the reason and the calendar kept", googlePath, got)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil || row.RefreshTokenCt != nil {
t.Errorf("connection = %+v, %v; want it kept without a refresh token", row, err)
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
})
}
}
// TestGoogleCalendarsTransientFailure fails the calendar list with HTTP 503
// on every try: it is tried three times, then 503 google_unavailable,
// retryable, and the connection stays connected.
func TestGoogleCalendarsTransientFailure(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.RevokeGrant()
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("family@group.calendar.google.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 502 reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
e.google.FailCalendarList(http.StatusServiceUnavailable)
calls := e.google.CalendarCalls()
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusServiceUnavailable || body.Error.Code != "google_unavailable" || !body.Error.Retryable {
t.Errorf("GET calendars = %d %s, want 503 google_unavailable, retryable", rec.Code, rec.Body)
}
if n := e.google.CalendarCalls() - calls; n != 3 {
t.Errorf("calendar list tried %d times, want 3", n)
}
if got := e.getGoogleStatus(t); got["status"] != "connected" || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no error", googlePath, got)
}
if !e.stored(t) {
t.Error("the connection was cleared")
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleStatus(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
if got := e.getGoogleStatus(t); got["status"] != "not_connected" {
t.Errorf("GET %s = %v, want not_connected", googlePath, got)
}
e.connected(t)
got := e.getGoogleStatus(t)
if got["status"] != "connected" || got["connected_at"] == nil || got["calendar_id"] != nil || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no calendar", googlePath, got)
}
if len(got) != 5 {
t.Errorf("GET %s = %v, want exactly status, calendar_id, calendar_name, connected_at and last_sync_error", googlePath, got)
}
}
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusInternalServerError)
e.google.FailCalendarList(http.StatusForbidden)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
if !strings.Contains(e.logs.String(), "HTTP 500") {
if !strings.Contains(e.logs.String(), "HTTP 403") {
t.Errorf("logs = %s, want the failure's status", e.logs)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
+5 -11
View File
@@ -115,9 +115,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
// not_connected when Google Calendar is not connected, no_calendar when no
// calendar is chosen, calendar_unavailable when the chosen calendar was
// deleted or is no longer writable, and plan_changed when the plan was
// edited during the push; 502 reconnect_required when Google no longer
// accepts the grant, and google_failed when Google refused or could not be
// reached. A failed push leaves the plan's status as it was. Any other
// edited during the push; a Google failure as writeGoogleFailure maps it
// (409 google_reconnect_required, 503 google_unavailable or 502
// google_failed). A failed push leaves the plan's status as it was. Any other
// error is the plan's, as writePlanError maps it.
func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
var calendar *gcal.CalendarError
@@ -133,14 +133,8 @@ func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Log
"the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings")
case errors.Is(err, plan.ErrPlanChanged):
writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error())
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be reached; push again")
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
writeGoogleFailure(w, r, logger, "push plan to Calendar", err, "Google Calendar could not be reached; push again")
default:
writePlanError(w, r, logger, "push plan to Calendar", err)
}
+19 -2
View File
@@ -429,10 +429,18 @@ func TestPushPlanToCalendarErrors(t *testing.T) {
{"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.RevokeGrant()
}, http.StatusBadGateway, "reconnect_required"},
}, http.StatusConflict, "google_reconnect_required"},
{"401 after a refresh", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.RejectAccessToken()
}, http.StatusConflict, "google_reconnect_required"},
{"Google unavailable", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.FailEventWrites(3, 3, http.StatusServiceUnavailable)
}, http.StatusServiceUnavailable, "google_unavailable"},
{"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.FailEventWrite(3, http.StatusServiceUnavailable)
e.google.FailEventWrite(3, http.StatusBadRequest)
}, http.StatusBadGateway, "google_failed"},
{"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"},
{"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"},
@@ -445,6 +453,15 @@ func TestPushPlanToCalendarErrors(t *testing.T) {
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
}
if apiErr, _ := body["error"].(map[string]any); apiErr["retryable"] != (tc.code == "google_unavailable") {
t.Errorf("push = %s, want retryable only when Google is unavailable", rec.Body)
}
if tc.code == "google_reconnect_required" {
_, status := callDinners(t, e.h, http.MethodGet, googlePath, "")
if status["status"] != "reconnect_required" || status["last_sync_error"] != gcal.ReconnectReason {
t.Errorf("GET %s after the push = %v, want reconnect_required with the reason", googlePath, status)
}
}
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
if err != nil {
t.Fatal(err)
+3 -2
View File
@@ -47,8 +47,8 @@ type RouteMounter interface {
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
// /api/v1/integrations/google/connect, /callback and /calendars, PUT
// /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
@@ -141,6 +141,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
// The callback's state is bound to the session that started connect.
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
apiV1.Get("/integrations/google", getGoogle(logger, googleSvc))
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))