Skip to content

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

Merged
rdimitrov merged 4 commits into
modelcontextprotocol:mainfrom
developer-ishan:fix/publisher-slow-down
Sep 16, 2026
Merged

rdimitrov merged 4 commits into
modelcontextprotocol:mainfrom
developer-ishan:fix/publisher-slow-down

Conversation

@developer-ishan

Copy link
Copy Markdown
Contributor

Summary

  • Handle GitHub's OAuth device-flow slow_down response per RFC 8628 §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:

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

  • go build ./cmd/publisher/... — clean
  • go test ./cmd/publisher/auth/... — passes
  • 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.

GitHub's device-flow token endpoint returns slow_down when the client
polls too frequently. Per RFC 8628 §3.5 this is a non-terminal signal:
the client must increase its polling interval by 5 seconds and keep
polling. The current pollForToken implementation only handles
authorization_pending as retriable and bails on every other error,
turning a routine rate-limit response into an unrecoverable
"login failed: error polling for token: token request failed: slow_down".

Treat slow_down the same as authorization_pending, bumping the
interval per the RFC.

Fixes modelcontextprotocol#1289
@P4ST4S

This comment was marked as spam.

@developer-ishan

Copy link
Copy Markdown
Contributor Author

@P4ST4S good suggestions, let me check and test both these cases.

developer-ishan and others added 2 commits May 31, 2026 20:01
Address review feedback on the device-flow slow_down fix:

- Add regression tests for pollForToken (previously untested):
  - slow_down then success: confirms polling continues to success
  - slow_down / authorization_pending / slow_down then success: confirms
    the RFC 8628 §3.5 interval increment compounds across mixed retryable
    errors and is not reset between slow_down instances
  - repeated slow_down: confirms the interval is capped
  - non-retryable error: confirms polling stops immediately
- Cap the polling interval at 60s so a misbehaving auth server returning
  slow_down repeatedly cannot grow it unboundedly (RFC 8628 §3.5 mandates
  the +5s increase but leaves the maximum to the implementation).

To make pollForToken testable, the access-token URL, initial interval, and
sleep function are now provider fields wired to defaults in the constructor;
tests inject a mock server and capture the back-off sequence deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@developer-ishan

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @P4ST4S! Both points are addressed in 9c5e9fc.

1. Regression tests — pollForToken was untested, so I made the access-token URL, initial interval, and sleep function provider fields (wired to the existing defaults in the constructor) so a test can point at an httptest mock and capture the back-off sequence without real delays. Four table-style cases:

  • slow_down → success: polling continues and the success path is reached
  • slow_down → authorization_pending → slow_down → success: confirms the interval compounds (5 → 5 → 10) and isn't reset between slow_down instances per RFC 8628 §3.5
  • repeated slow_down: confirms the interval cap holds
  • non-retryable error: confirms polling stops immediately with no back-off

2. Interval cap — added a 60s ceiling so a misbehaving auth server returning slow_down repeatedly can't grow the interval unboundedly. The RFC mandates the +5s increase but leaves the max to the implementation, so this is just a sanity bound.

gofmt, go vet, go build, the full auth package tests, and golangci-lint (v2.11.4, matching CI) all pass. Let me know if you'd prefer the cap split into a follow-up instead.

@P4ST4S

This comment was marked as spam.

@JosephDoUrden JosephDoUrden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this locally. go test ./cmd/publisher/... all green, the new pollForToken tests pass with -race too. slow_down handling follows RFC 8628, +5s each time and it sticks, 60s cap also works. Tests are solid btw, I tried to break the code on purpose (reset the interval, remove the cap) and they caught both. Branch is ~60 commits behind main but it merges clean, tests still pass after merge. Looks merge-ready to me.

@rdimitrov rdimitrov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! 🙌

@rdimitrov
rdimitrov merged commit 73aebc1 into modelcontextprotocol:main Sep 16, 2026
4 checks passed
rdimitrov added a commit to JosephDoUrden/registry that referenced this pull request Sep 16, 2026
Resolve overlapping GitHub device-flow changes after modelcontextprotocol#1290 merged, preserving the device-code grace retry implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
rdimitrov added a commit that referenced this pull request Sep 16, 2026
…polling (#1588)

Follow-up to my analysis on #1543.

Heads up on the shape first: this builds on #1290's poll-loop seams and
github wont let me target a fork branch, so the diff shows its two
commits too. Review only the last commit, the first two are #1290
unchanged, credit to @developer-ishan. Happy to rebase whichever way
#1290 goes, or split differently if that's easier to review.

What it does. pollForToken treats every non-pending answer as terminal,
so one flaky response kills a login the user may already have approved
in the browser. This spends a small grace budget (2 polls) on
incorrect_device_code before giving up, retries 5xx with a growing
backoff, and returns status plus body on other non-200s instead of dying
inside json.Unmarshal on an html error page. Terminal errors now carry
error_description and error_uri, and a timeout that happens after
polling recovered reports a plain timeout, not a stale error from
minutes earlier.

Also honours the interval and expires_in GitHub returns with the device
code (RFC 8628 3.2) instead of hardcoding 5/900, clamped (60s / 3600s)
so a misbehaving response can't stall the CLI or overflow the duration
math.

One thing I changed my mind on from the issue comment: I wrote there
that the client should wait one interval before the first poll. Dropped
that. My own repro test showed 40s of clean polling before authorize, so
the propagation theory doesn't hold, and the grace retry covers the same
window without taxing every fast login. First poll stays immediate,
pinned by a test.

Root cause note: I can't prove the GitHub-side race (cli/cli#9302 is the
same error class but was closed on an unconfirmed "platform race"). So
this isn't claimed as a root-cause fix, it makes the login recoverable
if the condition is transient and diagnosable if it isn't. Btw
incorrect_device_code is a GitHub extension, not an RFC 8628 3.5 code,
so there's no spec retry semantic being violated by the grace polls.

## Test

12 new tests in github_at_device_flow_internal_test.go, all green with
-race (33 in the package). #1290's four tests pass unmodified.
golangci-lint clean in the touched files under both v2.11.4 (this
branch's CI pin) and v2.13.1 (what main pins now).

## AI Disclosure

AI assistance (Claude) was used for issue research and during
development. All changes were reviewed and tested by the author.

Fixes #1543

---------

Co-authored-by: developer-ishan <igupta@groupon.com>
Co-authored-by: Ishan Gupta <62565307+developer-ishan@users.noreply.github.com>
Co-authored-by: developer-ishan <guptaishan849@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Radoslav Dimitrov <radoslav@stacklok.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp-publisher: device-flow login treats GitHub slow_down as fatal instead of backing off

5 participants