Files
MealQuest/internal/gcal/grant.go
T
dev_agentandClaude Opus 5.5 1c8092800a
CI / ci (push) Successful in 4m29s
CI / ci (pull_request) Successful in 4m21s
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>
2026-10-07 23:41:50 -04:00

180 lines
6.0 KiB
Go

package gcal
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// ReconnectReason is the last_sync_error stored when Google refuses the
// stored grant.
const ReconnectReason = "Google access was revoked or expired — reconnect"
const (
// maxRetries is how many times a request Google answers with a
// transient failure (HTTP 429 or 5xx) is sent again.
maxRetries = 2
// firstRetryDelay is the wait before the first retry; each later retry
// waits twice as long as the one before.
firstRetryDelay = 500 * time.Millisecond
)
// The states Status reports.
const (
// StatusNotConnected: no Google account is connected.
StatusNotConnected = "not_connected"
// StatusConnected: a Google account is connected and its grant was last
// accepted.
StatusConnected = "connected"
// StatusReconnectRequired: Google refused the stored grant, so it was
// cleared; only connecting again restores Push to Calendar.
StatusReconnectRequired = "reconnect_required"
)
// Status is the Google Calendar connection as the household sees it. It
// never holds a token.
type Status struct {
State string `json:"status"`
CalendarID *string `json:"calendar_id"`
CalendarName *string `json:"calendar_name"`
ConnectedAt *string `json:"connected_at"`
LastSyncError *string `json:"last_sync_error"`
}
// Status reports the connection: not connected, connected, or reconnect
// required, with the chosen calendar, when it was connected and the last
// sync error. It is one read of one row, so it takes no lock.
func (s *Service) Status(ctx context.Context) (Status, error) {
row, err := s.connection(ctx)
if errors.Is(err, ErrNotConnected) {
return Status{State: StatusNotConnected}, nil
}
if err != nil {
return Status{}, err
}
st := Status{
State: StatusConnected,
CalendarID: nullable(row.CalendarID),
CalendarName: nullable(row.CalendarName),
ConnectedAt: &row.ConnectedAt,
LastSyncError: nullable(row.LastSyncError),
}
if row.RefreshTokenCt == nil {
st.State = StatusReconnectRequired
}
return st, nil
}
func nullable(v sql.NullString) *string {
if !v.Valid {
return nil
}
return &v.String
}
// recordRefusal returns err, first recording, when it is ErrReconnect, that
// Google refuses the stored grant: the refresh token is cleared and
// last_sync_error set to ReconnectReason, so Status reports reconnect
// required and no request offers Google the refused token again.
//
// The chosen calendar and every stored calendar event id are kept, and
// forgetEvents is deliberately not run: the events MealQuest pushed are
// still on the chosen calendar, so a stored calendar_event_id still names
// an event on the chosen calendar. Reconnecting keeps the choice (Complete),
// and the next push updates those events in place. The caller holds
// s.account, which every push and calendar choice also holds, so the
// refusal is recorded between them, never during one.
func (s *Service) recordRefusal(ctx context.Context, err error) error {
if !errors.Is(err, ErrReconnect) {
return err
}
cleared := s.st.WithTx(ctx, func(tx *sql.Tx) error {
n, err := db.New(tx).ClearIntegrationsGoogleToken(ctx, db.ClearIntegrationsGoogleTokenParams{
LastSyncError: sql.NullString{String: ReconnectReason, Valid: true},
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return err
}
if n == 0 {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
return nil
})
if cleared != nil {
return fmt.Errorf("gcal: record that Google refused the grant: %w", cleared)
}
return err
}
// transient reports whether Google's HTTP status is a failure worth
// retrying: rate limited (429) or a server error (5xx).
func transient(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
// backoff waits before retry number attempt (1 for the first), or until ctx
// ends.
func (s *Service) backoff(ctx context.Context, attempt int) error {
t := time.NewTimer(s.retryDelay << (attempt - 1))
defer t.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("gcal: wait to retry: %w", ctx.Err())
case <-t.C:
return nil
}
}
// send sends the request newRequest builds, and sends it again, up to
// maxRetries times, while Google answers it with a transient failure. It
// returns the final answer's status and up to limit bytes of its body. A
// transient failure on the last try is a retryable *CalendarError; what
// names the request in errors, which never hold the URL, a token or
// Google's response body.
func (s *Service) send(ctx context.Context, what string, limit int64, newRequest func() (*http.Request, error)) (int, []byte, error) {
for attempt := 0; ; attempt++ {
if attempt > 0 {
if err := s.backoff(ctx, attempt); err != nil {
return 0, nil, err
}
}
req, err := newRequest()
if err != nil {
return 0, nil, fmt.Errorf("gcal: build %s request: %w", what, err)
}
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, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
}
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
resp.Body.Close()
if err != nil {
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
}
if !transient(resp.StatusCode) {
return resp.StatusCode, body, nil
}
if attempt == maxRetries {
return 0, nil, &CalendarError{retryable: true,
msg: fmt.Sprintf("gcal: Google could not take the %s (HTTP %d) in %d tries", what, resp.StatusCode, maxRetries+1)}
}
}
}