Skip to content

Carry a token refusal's OAuth error and Retry-After, and type device authorization's 429 - #972

Merged
robzolkos merged 5 commits into
mainfrom
token-error-retry-after
Oct 7, 2026
Merged

robzolkos merged 5 commits into
mainfrom
token-error-retry-after

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Stacked on #971, which made the Go token-endpoint refusals typed; this builds on that typed error, and GitHub will retarget it to main when #971 merges.

What

A token-endpoint or device-authorization refusal now carries what the server said, on *basecamp.Error:

  • OAuthError and OAuthErrorDescription: the RFC 6749 error and error_description. Both are bounded the way the message is, and nothing else from the body is rendered (SPEC §9).
  • RetryAfter: the wait from Retry-After, parsed by the client's own §6 parser. ParseRetryAfter exports that parser so the oauth package reads the header the same way.
  • A 429 is rate_limit in the Exchanger and in AuthManager refresh. Rules 1 and 2 still come first, so invalid_grant on any status is still auth_required. This is the same refinement Rust already makes.

RequestDeviceAuthorization gets the same treatment. A 4xx body's error and error_description go into the typed fields and the message. A 429 is rate_limit with its RetryAfter, and everything else stays api_error with its status. Nothing becomes auth_required there, because the refusal is of the login being started, and "sign in again" doesn't fix it.

Why

A Basecamp CLI user locked themselves out. Two containers held copies of one login. The second refresh revoked it, and a scheduled job then resent the revoked token every two minutes. bc3's abuse tracker escalated to a 4-hour block on every OAuth endpoint for that client and address, device authorization included. Through the SDK:

  • the refresh 429 came back as api_error with no wait, so the caller's only option was to try again into the block;
  • the device login said device authorization failed with status 429, with no reason and no time.

The CLI also detected invalid_grant by matching "token error: invalid_grant" in the message. With OAuthError it can match the code.

Decisions

  • Go only. The CLI is the consumer that needs this. SPEC §16 records the Go behavior the same way it already records Rust's 429 refinement and other "Go only, today" rules. The other SDKs keep their current classification.
  • Device authorization reads a 4xx body; it used to read none. The old code classified every non-2xx before reading, so a stalled body couldn't turn into a retryable transport failure. That guarantee still holds: a body that can't be read, or is over the cap, leaves the status to classify alone, and cancellation still wins. A 3xx or 5xx is still classified without a read. The cost is that a stalled 4xx body now takes up to the request timeout before it fails as an api_error, instead of failing at once.
  • New fields on basecamp.Error, not a new error type. Callers already classify with errors.As(*basecamp.Error). Extra fields keep that working and reach the AuthManager path, which can't import oauth.

Testing

  • New tests failed first on behavior, not just compilation: the field was added before the logic. TestExchanger_Refresh_CarriesOAuthErrorAndRetryAfter and TestAuthManager_Refresh_CarriesOAuthErrorAndRetryAfter got api_error/429 with RetryAfter = 0 and an empty OAuthError. TestRequestDeviceAuthorization_CarriesOAuthErrorAndRetryAfter got the bare status 429 message. All pass now, along with the existing suites.
  • TestExchanger_Refresh_BoundsOAuthErrorFields: a 10 KB error or error_description is truncated in the typed fields as well.
  • make check passes (macOS)

Summary by cubic

A refused token refresh or device authorization in the Go SDK now carries the server's RFC 6749 error code, description, and Retry-After wait on *basecamp.Error, so callers act on the server's own verdict instead of a bare status.

  • A 429 is now rate_limit in the Exchanger and AuthManager refresh, matching Rust's classification.
  • Device authorization reads a 4xx body's error and error_description into the typed fields and message; a 429 is rate_limit with its wait, everything else stays api_error.
  • ParseRetryAfter exports the client's Retry-After parser so the oauth package reads the header the same way.
  • OAuthError and OAuthErrorDescription are bounded like the message; the composed message is bounded too.
  • An unreadable or oversized refusal keeps its status-based class and Retry-After, but a caller's own cancellation during a body read surfaces as cancellation, not a refusal.

Written for commit 7e83bb2. Summary will update on new commits.

View guided diff Turn on auto-fix

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T22:45:02.219029Z 77c59f5 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9df81bffb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/pkg/basecamp/oauth/exchange.go
Comment thread go/pkg/basecamp/oauth/device.go

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread go/pkg/basecamp/oauth/token_error_test.go Outdated
Comment thread go/pkg/basecamp/auth.go

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread go/pkg/basecamp/oauth/exchange.go
@jeremy
jeremy requested a balanced review from Copilot October 6, 2026 22:42
@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 77c59f5738

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jeremy

jeremy commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Review summary at 77c59f57

@robzolkos
robzolkos force-pushed the fix/token-error-classify-by-code branch from 5a8c49b to 8d2295a Compare October 7, 2026 16:24
@robzolkos
robzolkos force-pushed the token-error-retry-after branch from 77c59f5 to de2b9e4 Compare October 7, 2026 16:24
@robzolkos

Copy link
Copy Markdown
Collaborator

Dependency / rollout note

This change has no server/API rollout dependency. It consumes the existing OAuth refusal contract—HTTP 429, an optional Retry-After, and an optional RFC 6749 error body—and degrades safely when the header or readable body is absent. It changes no Smithy model, generated OpenAPI, endpoint, response shape, or API provenance.

Its only ordering dependency is inside the SDK: it builds on the typed Go token-refusal errors introduced by #971, so #971 should merge first. The CLI already consumes these fields and retry semantics through an unpublished SDK commit; publishing this work in an SDK release lets the CLI return to a tagged dependency.

Base automatically changed from fix/token-error-classify-by-code to main October 7, 2026 17:06
jeremy added 5 commits October 7, 2026 13:06
…vice authorization's

A refused refresh came back as a class and a status, and nothing else a caller
could act on. bc3's abuse tracker answers every OAuth endpoint with a 429 and a
Retry-After for up to a day once a client and address have failed often
enough; the Go SDK reported that as an api_error with no wait, so the caller's
only move was to try again into the block. Device authorization was worse: any
non-2xx was "device authorization failed with status 429", with neither the
server's reason nor the wait.

basecamp.Error gains OAuthError, the RFC 6749 error code the endpoint named.
The Exchanger and AuthManager refresh now type a 429 as rate_limit and carry
OAuthError and RetryAfter on every class. RequestDeviceAuthorization reads a
4xx body's error and error_description into OAuthError and the message, types
a 429 as rate_limit with its RetryAfter, and keeps everything else api_error.
ParseRetryAfter exports the client's own §6 parser so the oauth package reads
Retry-After the same way.
…nd the composed message

A 429 whose body could not be read, or was over the cap, came back as an
untyped read error from the Exchanger and as api_error from AuthManager,
losing the rate limit and its wait. The body now only adds the OAuth error;
the status and Retry-After classify the refusal whatever the body did.

The device-authorization and Exchanger messages bounded the code and the
description separately, so together they could reach twice the cap; the
composed message is bounded too. The Retry-After test tolerance now applies
only to the HTTP-date case.
@robzolkos
robzolkos force-pushed the token-error-retry-after branch from de2b9e4 to 7e83bb2 Compare October 7, 2026 17:06
@robzolkos
robzolkos merged commit 5ce5b48 into main Oct 7, 2026
96 of 99 checks passed
@robzolkos
robzolkos deleted the token-error-retry-after branch October 7, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants