diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 08:05:38 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 08:05:38 +0000 |
| commit | 4c22de26ed3720411c2c2351f900dd4c1415b3f8 (patch) | |
| tree | 0d8b0d476b9d1b69f9401ca5bc1d679afa4f9dd1 /internal/api/google_tasks_oauth.go | |
| parent | 4d160762aeef531208f8cf6094a0e58a9e7d53ec (diff) | |
Fix Google Tasks: token refresh used a cancelled context
main.go builds the Tasks client with a short-lived init context and calls
cancel() the moment construction returns. dbTokenSource captured that context
and reused it for every subsequent token refresh, so the first refresh after
the startup access token aged out — roughly an hour in — failed with
"context canceled", and so did every one after it.
That is the whole reason Google Tasks has not been working: all four task
lists, the same error on every fetch, recovering only for an hour after each
restart. The message pointed at a transport problem, which is why it read as
flaky networking rather than a lifetime bug.
A token refresh is not logically part of whichever request happened to trigger
it, so it now gets its own background context with its own timeout. oauth2's
client is likewise built on context.Background(), since NewClient binds the
context it is given to the returned transport for its lifetime; per-call
contexts still come from the tasks service methods.
Regression test drives a refresh through a stub token endpoint with an
already-cancelled construction context, which fails on the old code with
exactly the production error. Also covers Google omitting refresh_token on a
refresh response, which would otherwise blank the stored one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/api/google_tasks_oauth.go')
| -rw-r--r-- | internal/api/google_tasks_oauth.go | 27 |
1 files changed, 23 insertions, 4 deletions
diff --git a/internal/api/google_tasks_oauth.go b/internal/api/google_tasks_oauth.go index 1f5f8c5..6edba21 100644 --- a/internal/api/google_tasks_oauth.go +++ b/internal/api/google_tasks_oauth.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -45,12 +46,14 @@ func GoogleTasksOAuthConfig(clientID, clientSecret, redirectURL string) *oauth2. // call -- cheap enough to just hit the DB each time rather than add a // second layer of in-process caching. type dbTokenSource struct { - ctx context.Context store TokenStore source string config *oauth2.Config } +// tokenRefreshTimeout bounds a single refresh round-trip to Google. +const tokenRefreshTimeout = 30 * time.Second + func (d *dbTokenSource) Token() (*oauth2.Token, error) { current, err := d.store.GetOAuthToken(d.source) if err != nil { @@ -60,7 +63,19 @@ func (d *dbTokenSource) Token() (*oauth2.Token, error) { return nil, fmt.Errorf("google tasks not connected -- visit /settings and connect it") } - refreshed, err := d.config.TokenSource(d.ctx, current).Token() + // Deliberately NOT a context captured at construction time. main.go builds + // the client with a short-lived init context and cancels it the moment + // construction returns; reusing that here meant the first refresh after the + // startup access token aged out — roughly an hour in — failed with + // "context canceled", and so did every one after it. Google Tasks was + // broken from that point until a restart, which bought another hour. + // + // A token refresh is not logically part of whichever request happened to + // trigger it, so it gets its own background context with its own bound. + ctx, cancel := context.WithTimeout(context.Background(), tokenRefreshTimeout) + defer cancel() + + refreshed, err := d.config.TokenSource(ctx, current).Token() if err != nil { return nil, fmt.Errorf("refreshing token: %w", err) } @@ -87,7 +102,11 @@ func (d *dbTokenSource) Token() (*oauth2.Token, error) { // succeeds; API calls fail with the dbTokenSource error above until a // token exists (see HandleGoogleTasksOAuthCallback). func NewGoogleTasksOAuthClient(ctx context.Context, oauthConfig *oauth2.Config, tokenStore TokenStore, tasklistID, timezone string) (*GoogleTasksClient, error) { - src := &dbTokenSource{ctx: ctx, store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} - httpClient := oauth2.NewClient(ctx, src) + src := &dbTokenSource{store: tokenStore, source: GoogleTasksOAuthSource, config: oauthConfig} + // context.Background(), not ctx: oauth2.NewClient binds the context it is + // given to the returned client's transport for its whole lifetime, and ctx + // here is the caller's short-lived init context. The per-call context still + // applies — it comes from the tasks service methods themselves. + httpClient := oauth2.NewClient(context.Background(), src) return NewGoogleTasksClient(ctx, option.WithHTTPClient(httpClient), tasklistID, timezone) } |
