Skip to content

feat: refresh an expired token and retry instead of failing the request - #34

Merged
JacobAtchley merged 2 commits into
mainfrom
feat/refresh-token-on-auth-failure
Sep 23, 2026
Merged

JacobAtchley merged 2 commits into
mainfrom
feat/refresh-token-on-auth-failure

Conversation

@JacobAtchley

Copy link
Copy Markdown
Owner

Why

boardwalk gets its bearer token from az account get-access-token once, at startup, and holds it for the life of the process. That token is good for about an hour; the program is meant to be left open longer. Past the expiry every request failed with an auth error the user could do nothing about — the az login behind the token was still current, only the token had aged out.

What changed

Client.send now retries once against a fresh token when Azure DevOps refuses the credentials.

  • Refusal has two shapes: a plain 401, and a 203 whose body is the sign-in page. The 203 was not reaching the error path at all — it passed the >= 300 check and was decoded as the payload, failing as malformed JSON.
  • The refresh is single-flight. A request whose token was already replaced by another goroutine picks up the new one instead of minting a second.
  • A write replays its body through req.GetBody, so a PATCH or PUT that hit the expiry is resent intact.
  • One retry only. When the refresh itself fails the login really is gone, and the error names az login rather than retrying.
  • newToken is a test seam in the same style as baseURL: production leaves it nil and falls back to the az CLI.

No UI change — the refresh is invisible unless it fails.

Testing

Six tests in internal/azdo/client_test.go, each checked against a mutation of the code it covers to confirm it fails when the behavior is removed: 401 then success, 203 then success, a retried write resending its body, replayable restoring a drained body, a failed refresh naming az login and stopping, a rejection that survives the refresh being reported after exactly one retry, and eight concurrent rejections sharing a single refresh.

go vet ./... and go test ./... -race are green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TqaFJiW6wTt1daQqZjpBDC

JacobAtchley and others added 2 commits September 22, 2026 21:47
The az token boardwalk starts with is good for about an hour, and the
program is meant to be left open longer than that. Every request after
the expiry was failing with an auth error the user could do nothing
about, since the az login behind the token was still current.

send now retries once against a fresh token when Azure DevOps refuses
the credentials. Refusal has two shapes: a plain 401, and a 203 whose
body is the sign-in page — the second was not even reaching the error
path before, it was being decoded as the payload and failing as
malformed JSON.

The refresh is single-flight: a request whose token was already
replaced by another goroutine takes the new one rather than minting a
second. When the refresh fails, the login itself is gone, and the error
says so rather than retrying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqaFJiW6wTt1daQqZjpBDC
…ssage in the error

The stub client left newToken nil, so a test whose server answered 401
reached the real az CLI: green on a developer's machine, red on a build
agent that has never logged in. It now hands back a token of its own.

A failed refresh was also reporting only itself. The rejected request's
own message is the half that says which rejection it was — a token that
aged out and one that was never scoped for the resource both arrive as
401 — so the error now carries both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqaFJiW6wTt1daQqZjpBDC
@JacobAtchley
JacobAtchley merged commit ba18c3a into main Sep 23, 2026
3 checks passed
@JacobAtchley
JacobAtchley deleted the feat/refresh-token-on-auth-failure branch September 23, 2026 02:52
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.

1 participant