CI / ci (push) Successful in 4m51s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
180 lines
6.0 KiB
Go
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)}
|
|
}
|
|
}
|
|
}
|