From f0230b88d1996ab1237f69a7c42ea228a6cfc4ac Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 11 Dec 2025 22:28:29 +0000 Subject: [PATCH 1/2] fix: handle `slow_down` error Signed-off-by: Babak K. Shandiz --- device/device_flow.go | 33 +++- device/device_flow_test.go | 382 +++++++++++++++++++++++++++++++++++-- device/poller.go | 18 +- 3 files changed, 412 insertions(+), 21 deletions(-) diff --git a/device/device_flow.go b/device/device_flow.go index 0447980..5fe1767 100644 --- a/device/device_flow.go +++ b/device/device_flow.go @@ -148,7 +148,7 @@ type WaitOptions struct { // Wait polls the server at uri until authorization completes. func Wait(ctx context.Context, c httpClient, uri string, opts WaitOptions) (*api.AccessToken, error) { - checkInterval := time.Duration(opts.DeviceCode.Interval) * time.Second + baseCheckInterval := time.Duration(opts.DeviceCode.Interval) * time.Second expiresIn := time.Duration(opts.DeviceCode.ExpiresIn) * time.Second grantType := opts.GrantType if opts.GrantType == "" { @@ -159,7 +159,7 @@ func Wait(ctx context.Context, c httpClient, uri string, opts WaitOptions) (*api if makePoller == nil { makePoller = newPoller } - _, poll := makePoller(ctx, checkInterval, expiresIn) + _, poll := makePoller(ctx, baseCheckInterval, expiresIn) for { if err := poll.Wait(); err != nil { @@ -187,8 +187,35 @@ func Wait(ctx context.Context, c httpClient, uri string, opts WaitOptions) (*api token, err := resp.AccessToken() if err == nil { return token, nil - } else if !(errors.As(err, &apiError) && apiError.Code == "authorization_pending") { + } + + if !errors.As(err, &apiError) { return nil, err } + + if apiError.Code == "authorization_pending" { + // Keep polling + continue + } + + if apiError.Code == "slow_down" { + // Based on the RFC spec, we must add 5 seconds to our current polling interval. + // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + newInterval := poll.GetInterval() + 5*time.Second + + // GitHub OAuth API returns the new interval in seconds in the response. + // We should try to use that if provided. It's okay if we couldn't find + // it as we have already increased our interval as of the RFC spec. + if s := resp.Get("interval"); s != "" { + if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 { + newInterval = time.Duration(v) * time.Second + } + } + + poll.SetInterval(newInterval) + continue + } + + return nil, err } } diff --git a/device/device_flow_test.go b/device/device_flow_test.go index 08fbd0f..a24de11 100644 --- a/device/device_flow_test.go +++ b/device/device_flow_test.go @@ -297,9 +297,18 @@ func TestRequestCode(t *testing.T) { } func TestPollToken(t *testing.T) { - makeFakePoller := func(maxWaits int) pollerFactory { + singletonFakePoller := func(maxWaits int) pollerFactory { + var instance *fakePoller return func(ctx context.Context, interval, expiresIn time.Duration) (context.Context, poller) { - return ctx, &fakePoller{maxWaits: maxWaits} + if instance != nil { + // This is to make the factory return the same instance in tests. + return ctx, instance + } + instance = &fakePoller{ + maxWaits: maxWaits, + interval: interval, + } + return ctx, instance } } @@ -309,12 +318,13 @@ func TestPollToken(t *testing.T) { opts WaitOptions } tests := []struct { - name string - args args - want *api.AccessToken - wantErr string - posts []postArgs - slept time.Duration + name string + args args + want *api.AccessToken + wantErr string + assertFunc func(*testing.T, args) + posts []postArgs + slept time.Duration }{ { name: "success", @@ -343,7 +353,7 @@ func TestPollToken(t *testing.T) { ExpiresIn: 99, Interval: 5, }, - newPoller: makeFakePoller(2), + newPoller: singletonFakePoller(2), }, }, want: &api.AccessToken{ @@ -367,6 +377,335 @@ func TestPollToken(t *testing.T) { }, }, }, + assertFunc: func(t *testing.T, a args) { + // Get the created poller + _, poller := a.opts.newPoller(context.Background(), 0, 0) + if poller.(*fakePoller).updatedIntervals != nil { + t.Errorf("no interval change expected = %v", poller.(*fakePoller).updatedIntervals) + } + }, + }, + { + name: "success with slow down, new interval in returned response", + args: args{ + http: apiClient{ + stubs: []apiStub{ + { + body: "error=authorization_pending", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down&error_description=Too+many+requests+have+been+made+in+the+same+timeframe.&error_uri=https%3A%2F%2Fdocs.github.com&interval=22", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "access_token=123abc", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + }, + }, + url: "https://github.com/oauth", + opts: WaitOptions{ + ClientID: "CLIENT-ID", + DeviceCode: &CodeResponse{ + DeviceCode: "DEVIC", + UserCode: "123-abc", + VerificationURI: "http://verify.me", + ExpiresIn: 99, + Interval: 5, + }, + newPoller: singletonFakePoller(3), + }, + }, + want: &api.AccessToken{ + Token: "123abc", + }, + posts: []postArgs{ + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + }, + assertFunc: func(t *testing.T, a args) { + // Get the created poller + _, poller := a.opts.newPoller(context.Background(), 0, 0) + got := poller.(*fakePoller).updatedIntervals + want := []time.Duration{22 * time.Second} + if !reflect.DeepEqual(got, want) { + t.Errorf("unexpected updated intervals = %v, want %v", got, want) + } + }, + }, + { + name: "success with multiple slow down, new interval in returned response", + args: args{ + http: apiClient{ + stubs: []apiStub{ + { + body: "error=authorization_pending", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down&error_description=Too+many+requests+have+been+made+in+the+same+timeframe.&error_uri=https%3A%2F%2Fdocs.github.com&interval=22", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down&error_description=Too+many+requests+have+been+made+in+the+same+timeframe.&error_uri=https%3A%2F%2Fdocs.github.com&interval=33", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "access_token=123abc", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + }, + }, + url: "https://github.com/oauth", + opts: WaitOptions{ + ClientID: "CLIENT-ID", + DeviceCode: &CodeResponse{ + DeviceCode: "DEVIC", + UserCode: "123-abc", + VerificationURI: "http://verify.me", + ExpiresIn: 99, + Interval: 5, + }, + newPoller: singletonFakePoller(4), + }, + }, + want: &api.AccessToken{ + Token: "123abc", + }, + posts: []postArgs{ + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + }, + assertFunc: func(t *testing.T, a args) { + // Get the created poller + _, poller := a.opts.newPoller(context.Background(), 0, 0) + got := poller.(*fakePoller).updatedIntervals + want := []time.Duration{22 * time.Second, 33 * time.Second} + if !reflect.DeepEqual(got, want) { + t.Errorf("unexpected updated intervals = %v, want %v", got, want) + } + }, + }, + { + name: "success with slow down, no interval in returned response", + args: args{ + http: apiClient{ + stubs: []apiStub{ + { + body: "error=authorization_pending", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "access_token=123abc", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + }, + }, + url: "https://github.com/oauth", + opts: WaitOptions{ + ClientID: "CLIENT-ID", + DeviceCode: &CodeResponse{ + DeviceCode: "DEVIC", + UserCode: "123-abc", + VerificationURI: "http://verify.me", + ExpiresIn: 99, + Interval: 5, + }, + newPoller: singletonFakePoller(3), + }, + }, + want: &api.AccessToken{ + Token: "123abc", + }, + posts: []postArgs{ + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + }, + assertFunc: func(t *testing.T, a args) { + // Get the created poller + _, poller := a.opts.newPoller(context.Background(), 0, 0) + got := poller.(*fakePoller).updatedIntervals + want := []time.Duration{10 * time.Second} + if !reflect.DeepEqual(got, want) { + t.Errorf("unexpected updated intervals = %v, want %v", got, want) + } + }, + }, + { + name: "success with multiple slow down, no interval in returned response", + args: args{ + http: apiClient{ + stubs: []apiStub{ + { + body: "error=authorization_pending", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "error=slow_down", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + { + body: "access_token=123abc", + status: 200, + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }, + }, + }, + url: "https://github.com/oauth", + opts: WaitOptions{ + ClientID: "CLIENT-ID", + DeviceCode: &CodeResponse{ + DeviceCode: "DEVIC", + UserCode: "123-abc", + VerificationURI: "http://verify.me", + ExpiresIn: 99, + Interval: 5, + }, + newPoller: singletonFakePoller(4), + }, + }, + want: &api.AccessToken{ + Token: "123abc", + }, + posts: []postArgs{ + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + { + url: "https://github.com/oauth", + params: url.Values{ + "client_id": {"CLIENT-ID"}, + "device_code": {"DEVIC"}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }, + }, + }, + assertFunc: func(t *testing.T, a args) { + // Get the created poller + _, poller := a.opts.newPoller(context.Background(), 0, 0) + got := poller.(*fakePoller).updatedIntervals + want := []time.Duration{10 * time.Second, 15 * time.Second} + if !reflect.DeepEqual(got, want) { + t.Errorf("unexpected updated intervals = %v, want %v", got, want) + } + }, }, { name: "with client secret and grant type", @@ -392,7 +731,7 @@ func TestPollToken(t *testing.T) { ExpiresIn: 99, Interval: 5, }, - newPoller: makeFakePoller(1), + newPoller: singletonFakePoller(1), }, }, want: &api.AccessToken{ @@ -437,7 +776,7 @@ func TestPollToken(t *testing.T) { ExpiresIn: 14, Interval: 5, }, - newPoller: makeFakePoller(2), + newPoller: singletonFakePoller(2), }, }, wantErr: "context deadline exceeded", @@ -482,7 +821,7 @@ func TestPollToken(t *testing.T) { ExpiresIn: 99, Interval: 5, }, - newPoller: makeFakePoller(1), + newPoller: singletonFakePoller(1), }, }, wantErr: "access_denied", @@ -514,13 +853,28 @@ func TestPollToken(t *testing.T) { if !reflect.DeepEqual(tt.args.http.calls, tt.posts) { t.Errorf("PostForm() = %v, want %v", tt.args.http.calls, tt.posts) } + + if tt.assertFunc != nil { + tt.assertFunc(t, tt.args) + } }) } } type fakePoller struct { - maxWaits int - count int + interval time.Duration + maxWaits int + count int + updatedIntervals []time.Duration +} + +func (p *fakePoller) GetInterval() time.Duration { + return p.interval +} + +func (p *fakePoller) SetInterval(d time.Duration) { + p.interval = d + p.updatedIntervals = append(p.updatedIntervals, d) } func (p *fakePoller) Wait() error { diff --git a/device/poller.go b/device/poller.go index 06a2e9d..f74399a 100644 --- a/device/poller.go +++ b/device/poller.go @@ -6,17 +6,19 @@ import ( ) type poller interface { + GetInterval() time.Duration + SetInterval(time.Duration) Wait() error Cancel() } type pollerFactory func(context.Context, time.Duration, time.Duration) (context.Context, poller) -func newPoller(ctx context.Context, checkInteval, expiresIn time.Duration) (context.Context, poller) { +func newPoller(ctx context.Context, checkInterval, expiresIn time.Duration) (context.Context, poller) { c, cancel := context.WithTimeout(ctx, expiresIn) return c, &intervalPoller{ ctx: c, - interval: checkInteval, + interval: checkInterval, cancelFunc: cancel, } } @@ -27,7 +29,15 @@ type intervalPoller struct { cancelFunc func() } -func (p intervalPoller) Wait() error { +func (p *intervalPoller) GetInterval() time.Duration { + return p.interval +} + +func (p *intervalPoller) SetInterval(d time.Duration) { + p.interval = d +} + +func (p *intervalPoller) Wait() error { t := time.NewTimer(p.interval) select { case <-p.ctx.Done(): @@ -38,6 +48,6 @@ func (p intervalPoller) Wait() error { } } -func (p intervalPoller) Cancel() { +func (p *intervalPoller) Cancel() { p.cancelFunc() } From 5ffd59066702277a117fb13eea145ae94f0f988d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 11 Dec 2025 22:28:42 +0000 Subject: [PATCH 2/2] ci: upgrade `actions/setup-go` and add more Go versions Signed-off-by: Babak K. Shandiz --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d5d4b..3680511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ jobs: test: strategy: matrix: - go: [ '1.21', '1.22', '1.23' ] + go: [ '1.21', '1.22', '1.23', '1.24', '1.25' ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Setup Go - uses: actions/setup-go@v1 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: Run tests