Handle Google token failures and report Google Calendar status
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:
+29
-28
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -84,7 +83,9 @@ type eventTime struct {
|
||||
// is safe. Nothing connected is ErrNotConnected, no calendar chosen is
|
||||
// ErrNoCalendar, a chosen calendar that was deleted or is no longer
|
||||
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
|
||||
// ErrReconnect, and any other Google failure is a *CalendarError.
|
||||
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
|
||||
// other Google failure is a *CalendarError, retryable when Google answered
|
||||
// every try with HTTP 429 or 5xx.
|
||||
//
|
||||
// Once every event is written, PushEvents returns record(), which the
|
||||
// caller uses to store what it pushed. record runs under the same hold of
|
||||
@@ -101,6 +102,12 @@ func (s *Service) PushEvents(ctx context.Context, events []Event, record func()
|
||||
}
|
||||
s.account.Lock()
|
||||
defer s.account.Unlock()
|
||||
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
|
||||
}
|
||||
|
||||
// pushEvents is PushEvents once the events are valid. The caller holds
|
||||
// s.account.
|
||||
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
|
||||
row, err := s.connection(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -148,43 +155,37 @@ func (s *Service) writeEvent(ctx context.Context, access, base string, e Event)
|
||||
return err
|
||||
}
|
||||
|
||||
// eventRequest sends an event insert (POST) or update (PUT) and returns
|
||||
// HTTP 200 or, for an insert, 409; every other answer is an error.
|
||||
// eventRequest sends an event insert (POST) or update (PUT), retrying a
|
||||
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
|
||||
// every other answer is an error. An insert Google took but answered with
|
||||
// a transient failure is answered 409 when retried, and updated.
|
||||
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
|
||||
what := "event insert"
|
||||
if method == http.MethodPut {
|
||||
what = "event update"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gcal: build %s request: %w", what, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
var transport *url.Error
|
||||
if errors.As(err, &transport) {
|
||||
// The URL is dropped: after a refused redirect it is the
|
||||
// Google-supplied Location.
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
|
||||
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
reply, err := io.ReadAll(io.LimitReader(resp.Body, maxEventBody))
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
|
||||
return 0, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
case status == http.StatusOK:
|
||||
return http.StatusOK, nil
|
||||
case resp.StatusCode == http.StatusConflict && method == http.MethodPost:
|
||||
case status == http.StatusConflict && method == http.MethodPost:
|
||||
return http.StatusConflict, nil
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
case status == http.StatusUnauthorized:
|
||||
// The access token was just refreshed from the grant.
|
||||
return 0, ErrReconnect
|
||||
case resp.StatusCode == http.StatusNotFound && method == http.MethodPost,
|
||||
resp.StatusCode == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
||||
case status == http.StatusNotFound && method == http.MethodPost,
|
||||
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
|
||||
// An insert into a deleted calendar, or one the account no longer
|
||||
// sees, is not found; a write to one it can only read is
|
||||
// requiredAccessLevel. An update not found is the event missing
|
||||
@@ -192,7 +193,7 @@ func (s *Service) eventRequest(ctx context.Context, access, method, target strin
|
||||
// says nothing about the calendar.
|
||||
return 0, ErrCalendarUnavailable
|
||||
}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, resp.StatusCode)}
|
||||
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
|
||||
}
|
||||
|
||||
// errorReason is the reason of the first error in a Calendar API error
|
||||
|
||||
Reference in New Issue
Block a user