summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks_oauth.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-20 08:05:38 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-20 08:05:38 +0000
commit4c22de26ed3720411c2c2351f900dd4c1415b3f8 (patch)
tree0d8b0d476b9d1b69f9401ca5bc1d679afa4f9dd1 /internal/api/google_tasks_oauth.go
parent4d160762aeef531208f8cf6094a0e58a9e7d53ec (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.go27
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)
}