Repository navigation
Refresh token and retry once on a 401 - #179
Conversation
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>
tmfahey
left a comment
There was a problem hiding this comment.
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.
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
left a comment
There was a problem hiding this comment.
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.
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>
|
@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>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
Signed-off-by: Cody Bentosino <cody.bentosino@gusto.com>
tmfahey
left a comment
There was a problem hiding this comment.
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
Summary
ApiClientnow 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.--token-stdinorGUSTO_ACCESS_TOKENtoken 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:allpasses locally--agentand--humanoutput verified where touchedDCO
git commit -s) per the DCO