Skip to content

Refresh token and retry once on a 401 - #179

Merged
cbento-gusto merged 13 commits into
mainfrom
reactive-token-refresh-on-401
Sep 24, 2026
Merged

cbento-gusto merged 13 commits into
mainfrom
reactive-token-refresh-on-401

Conversation

@cbento-gusto

@cbento-gusto cbento-gusto commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • REST commands and MCP tool calls resolved a session-sourced access token once and never reacted if it turned out to be wrong. A stale locally-recorded expiry, clock skew, or a server-side revocation all surfaced as a bare 401 instead of a refresh.
  • ApiClient now refreshes a session-sourced token once and retries after a 401, reusing the same refresh and persist logic the existing pre-request refresh already uses. This applies uniformly to REST calls and MCP tool calls since both share the same client. Concurrent requests on one client share a single refresh instead of racing separate ones.
  • An explicit --token-stdin or GUSTO_ACCESS_TOKEN token is unaffected. It still fails loudly on a 401 rather than silently falling back to a stored session.

Linked issue

Test plan

  • bun run test:all passes locally
  • Manual run of touched commands works against sandbox
  • --agent and --human output verified where touched

DCO

  • Every commit is signed off (git commit -s) per the DCO

Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
…h-on-401

Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
@cbento-gusto cbento-gusto changed the title Refresh and retry once on a 401 Refresh token and retry once on a 401 Sep 9, 2026
@cbento-gusto
cbento-gusto marked this pull request as ready for review September 9, 2026 22:59
@cbento-gusto
cbento-gusto requested review from a team and ashieh as code owners September 9, 2026 22:59

@tmfahey tmfahey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The core mechanism looks right, and the concurrency tests are better than most - the gated "401 arrives after a sibling already refreshed" case is exactly the one an in-flight guard alone misses, and it's good to see it pinned. Routing both refresh failures through tokenRefreshFailedError is the right call too.

Two things I'd want settled before this merges. The reconciliation catch in reconcileAfterFailedRefresh reports the first failure's reason, so a transient second failure comes out as grant_rejected and tells the user to replace a refresh token that's still good. And callMcpTool builds its client through buildApiClient without the new hook, so the MCP surface keeps the old behavior while REST commands recover.

The rest is smaller: credentialRejected and AGENTS.md still describe a world where no refresh happened, the comment at session.ts:102-105 now contradicts the function you added below it, ARCHITECTURE.md:43 still claims POST/PUT are never retried, and no test covers the 401 retry on a request with a body.

Comment thread src/lib/oauth/session.ts Outdated
Comment thread src/lib/api-context.ts Outdated
Comment thread src/lib/handle-api-error.ts
Comment thread src/lib/oauth/session.ts Outdated
Comment thread src/lib/api-client.ts
Comment thread src/lib/api-client.test.ts
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>

@tmfahey tmfahey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing structural from me. The refresh/retry shape holds up, the single in-flight refresh does what it claims, and stamping refreshed onto AuthContext is the right call - it keeps the 401 wording honest without teaching handle-api-error.ts anything about OAuth.

Three small things inline: one clause in the new refreshed-token message that points at the one thing a successful refresh rules out, two sibling docs that still describe the old behaviour, and a question about which shape the MCP gateway actually returns for an expired session.

Comment thread src/lib/handle-api-error.ts Outdated
Comment thread ARCHITECTURE.md
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
…h-on-401

# Conflicts:
#	src/lib/api-client.ts

Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
@cbento-gusto

Copy link
Copy Markdown
Contributor Author

@tmfahey, great question. I'm not exactly sure of the answer. I would think an expired session would surface as a real 401 from the MCP gateway (which this change would address). I'll dig more into it separately from this PR.

…h-on-401

# Conflicts:
#	AGENTS.md

Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
tmfahey
tmfahey previously approved these changes Sep 21, 2026

@tmfahey tmfahey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One non blocking nit otherwise looks good

Posted by 📡 PR Radar for @tmfahey

Comment thread src/lib/api-context.ts Outdated
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>

@tmfahey tmfahey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The shared reactiveRefreshHook puts both surfaces on the same gate, and the refreshed-token wording in rejectedCredential now points people at the right recovery. LGTM

@cbento-gusto
cbento-gusto merged commit 7f2d0bd into main Sep 24, 2026
24 checks passed
@cbento-gusto
cbento-gusto deleted the reactive-token-refresh-on-401 branch September 24, 2026 16:56
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.

2 participants