Nine attempts at the token endpoint over ~6 minutes, every one a 429. No exchange happened, so the refresh token was never consumed and the user's Claude Code login is untouched. A rejection that holds uniformly for six minutes is not throttling, and we have seen this shape before: the Messages API also answers a request it declines with 429 rate_limit_error rather than 401. On these OAuth surfaces a 429 is not evidence of a rate limit. Finding the route that does work would mean guessing at auth endpoints while holding a live refresh token, so we stop instead. The fallback — expired token says to run claude — is a complete feature on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 35 additions and 20 deletions.
| @@ -126,26 +126,41 @@ process lifetime, and is mutex-guarded: streaming and non-streaming requests can | ||
| 126 | 126 | be in flight together, and two concurrent refreshes of the same token is exactly |
| 127 | 127 | the scenario the next section is about. |
| 128 | 128 | |
| 129 | - ## Refresh, and an open question that gates it | |
| 130 | - | |
| 131 | - Refreshing is `POST /v1/oauth/token` with the refresh token. If the endpoint | |
| 132 | - returns a *new* refresh token, then refreshing without writing it back consumes | |
| 133 | - the one Claude Code still has on disk — its copy goes dead and `claude` asks you | |
| 134 | - to log in again. We would have broken a tool outside this repo to save a paste. | |
| 135 | - | |
| 136 | - We do not yet know whether that endpoint rotates. So the first implementation | |
| 137 | - task is a probe, not code: refresh once, compare the returned refresh token to | |
| 138 | - the stored one, report. | |
| 139 | - | |
| 140 | - - **No rotation** — in-memory-only refresh is safe. Build it as described. | |
| 141 | - - **Rotation** — stop. Bring the result back before writing refresh code; the | |
| 142 | - choice then is write-back (with atomic rename and 0600) or no refresh at all, | |
| 143 | - and it is not a choice to make silently inside an implementation task. | |
| 144 | - | |
| 145 | - Until that resolves, the fallback is the honest one: use the stored token, and | |
| 146 | - when it is expired fail with `your Claude Code login has expired, run claude to | |
| 147 | - refresh it`. That is a working feature on its own. Refresh is an improvement on | |
| 148 | - top of it, not a prerequisite. | |
| 129 | + ## Refresh: not shipped, and why | |
| 130 | + | |
| 131 | + The question was whether `POST /v1/oauth/token` rotates refresh tokens. If it | |
| 132 | + does, refreshing without writing the result back consumes the token Claude Code | |
| 133 | + still has on disk — its copy goes dead and `claude` asks you to log in again. | |
| 134 | + We would have broken a tool outside this repo to save a paste. | |
| 135 | + | |
| 136 | + **The probe never got an answer, and that is the answer.** Nine attempts to | |
| 137 | + `https://console.anthropic.com/v1/oauth/token` with the stored refresh token and | |
| 138 | + Claude Code's client id, spread over roughly six minutes, every one of them: | |
| 139 | + | |
| 140 | + ``` | |
| 141 | + HTTP 429 {"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}} | |
| 142 | + ``` | |
| 143 | + | |
| 144 | + No exchange ever happened, so the refresh token was not consumed and nothing | |
| 145 | + was damaged. A rejection that persists uniformly across six minutes is not | |
| 146 | + throttling. And we have already seen this exact pattern once: the Messages API | |
| 147 | + answers a request shape it declines with `429 rate_limit_error` too, rather than | |
| 148 | + a 401. On these OAuth surfaces a 429 is not reliable evidence of a rate limit, | |
| 149 | + which is worth knowing before anyone reads one as "wait and retry". | |
| 150 | + | |
| 151 | + The honest reading is that this request — endpoint, client id, or grant shape — | |
| 152 | + is not the one this credential is refreshed with. Finding the one that is would | |
| 153 | + mean guessing at authentication endpoints while holding someone's live refresh | |
| 154 | + token, which is not a thing to do on a hunch. | |
| 155 | + | |
| 156 | + **So refresh is not implemented.** The shipped behaviour is the fallback, and it | |
| 157 | + is a complete feature: use the stored access token, and when it has expired fail | |
| 158 | + with `your Claude Code login has expired, run claude to refresh it`. Tokens last | |
| 159 | + around eight hours, so the cost is an occasional re-login — paid by running the | |
| 160 | + tool that owns the credential, which is the right place to pay it. | |
| 161 | + | |
| 162 | + If refresh is wanted later, the open question is unchanged and the probe is | |
| 163 | + unchanged; what is needed first is a refresh route that actually responds. | |
| 149 | 164 | |
| 150 | 165 | ## The system prompt |
| 151 | 166 | |