fix(publisher): treat GitHub device-flow slow_down as retriable (#1290)

## Summary

- Handle GitHub's OAuth device-flow `slow_down` response per [RFC 8628
§3.5](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5) — treat
it as retriable and bump the polling interval by 5 seconds, instead of
bailing with a fatal error.

Fixes #1289.

## Why

`pollForToken` in `cmd/publisher/auth/github-at.go` only treats
`authorization_pending` as retriable. Every other response from GitHub's
token endpoint — including the non-terminal `slow_down` signal — falls
through to:

```go
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
```

So users hitting GitHub's polling rate-limit get:

```
Error: login failed: error polling for token: token request failed: slow_down
```

…and have to re-run `mcp-publisher login github` from scratch, which is
especially painful because `slow_down` is more likely when the user
takes more than a few seconds to enter and authorize the device code.

Per RFC 8628 §3.5, `slow_down` is defined as:

> A variant of "authorization_pending", the authorization request is
still pending and polling should continue, but the interval MUST be
increased by 5 seconds for this and all subsequent requests.

## Change

Treat `slow_down` the same as `authorization_pending` but increase
`interval` by 5 seconds before sleeping, matching the RFC exactly.

A possible follow-up (not in this PR, to keep the diff minimal): seed
the initial `interval` from `DeviceCodeResponse.Interval` (already
parsed but currently unused) instead of hardcoding `5`. Happy to do that
here or as a separate PR if preferred.

## Test plan

- [x] `go build ./cmd/publisher/...` — clean
- [x] `go test ./cmd/publisher/auth/...` — passes
- [x] `gofmt -d` and `go vet ./cmd/publisher/auth/...` — clean
- [ ] CI `make check` (lint + integration tests)

I could not exercise `pollForToken` from a unit test without refactoring
`GitHubAccessTokenURL` to be injectable, which felt out of scope for a
bug-fix PR. Glad to add that refactor + a table-driven test for
`authorization_pending` / `slow_down` / fatal errors in a follow-up if
maintainers want it.

---------

Co-authored-by: developer-ishan <igupta@groupon.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ishan Gupta 2026-09-16 20:44:51 +05:30 • committed by GitHub
commit 73aebc1526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -17,6 +17,17 @@ const (
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
)
const (
// defaultPollInterval is the initial device-flow polling interval in
// seconds, per RFC 8628 §3.5.
defaultPollInterval = 5
// maxPollInterval caps how large the polling interval may grow. RFC 8628
// §3.5 mandates a 5-second increase on each slow_down but leaves the
// maximum to the implementation; this prevents a misbehaving auth server
// from growing the interval unboundedly.
maxPollInterval = 60
)
// DeviceCodeResponse represents the response from GitHub's device code endpoint
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
@ -46,6 +57,16 @@ type GitHubATProvider struct {
registryURL string
providedToken string // Token provided via --token flag or MCP_GITHUB_TOKEN env var
githubToken string // In-memory GitHub token set by Login()
// accessTokenURL is the GitHub access-token polling endpoint. It is a field
// (rather than the package constant) so tests can point it at a mock server.
accessTokenURL string
// pollInterval is the initial polling interval in seconds. Defaults to
// defaultPollInterval; overridable in tests to avoid real delays.
pollInterval int
// sleep abstracts time.Sleep so tests can run without real delays and
// assert the back-off sequence. Defaults to time.Sleep.
sleep func(time.Duration)
}
// ServerHealthResponse represents the response from the health endpoint
@ -62,8 +83,11 @@ func NewGitHubATProvider(registryURL, token string) Provider {
}
return &GitHubATProvider{
registryURL: registryURL,
providedToken: token,
registryURL: registryURL,
providedToken: token,
accessTokenURL: GitHubAccessTokenURL,
pollInterval: defaultPollInterval,
sleep: time.Sleep,
}
}
@ -199,12 +223,12 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
}
// Default polling interval and expiration time
interval := 5 // seconds
interval := g.pollInterval
expiresIn := 900 // 15 minutes
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.accessTokenURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
@ -229,9 +253,17 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
return "", err
}
if tokenResp.Error == "authorization_pending" {
// User hasn't authorized yet, wait and retry
time.Sleep(time.Duration(interval) * time.Second)
// Per RFC 8628 §3.5, both authorization_pending and slow_down indicate
// the client should keep polling. slow_down additionally requires that
// the polling interval be increased by 5 seconds.
if tokenResp.Error == "authorization_pending" || tokenResp.Error == "slow_down" {
if tokenResp.Error == "slow_down" {
interval += 5
if interval > maxPollInterval {
interval = maxPollInterval
}
}
g.sleep(time.Duration(interval) * time.Second)
continue
}