Single-flight OAuth token refresh, shared by every provider.
A token endpoint that ROTATES the refresh_token (Anthropic, OpenAI Codex, MCP
servers) answers HTTP 400 invalid_grant when two exchanges spend one refresh
token. A 401 storm (each iteration's retry plus the limits poll) caused exactly
that race and killed whole turns. So refresh is serialized PER CREDENTIAL STORE,
never globally: each make-file-refresher owns its lock, and an optional OS file
lock serializes processes. Under the lock, a caller reuses a result that another
caller just saved, so a burst of N 401s runs ONE exchange.
USE: a file-backed rotating store calls make-file-refresher. It returns a 0/1-arg
fn for :provider/get-token-fn / :provider/refresh-token-fn. A store with its own
cache (GitHub Copilot) calls single-flight! and log-refresh! itself.
Single-flight OAuth token refresh, shared by every provider. A token endpoint that ROTATES the refresh_token (Anthropic, OpenAI Codex, MCP servers) answers HTTP 400 `invalid_grant` when two exchanges spend one refresh token. A 401 storm (each iteration's retry plus the limits poll) caused exactly that race and killed whole turns. So refresh is serialized PER CREDENTIAL STORE, never globally: each `make-file-refresher` owns its lock, and an optional OS file lock serializes processes. Under the lock, a caller reuses a result that another caller just saved, so a burst of N 401s runs ONE exchange. USE: a file-backed rotating store calls `make-file-refresher`. It returns a 0/1-arg fn for `:provider/get-token-fn` / `:provider/refresh-token-fn`. A store with its own cache (GitHub Copilot) calls `single-flight!` and `log-refresh!` itself.
(auth-json-str auth-state)Encode provider auth state with the canonical total JSON codec.
Encode provider auth state with the canonical total JSON codec.
(call-with-file-lock lock-path f)(call-with-file-lock lock-path timeout-ms f)Run f while holding an OS advisory lock on lock-path, serializing the
critical section ACROSS processes (e.g. two vis JVMs — a source-runtime client and
its gateway daemon — sharing one credential file). The caller must already
hold the in-process monitor, since a single JVM cannot hold two overlapping
locks on the same file. f gets true when it holds the lock, false when it
runs unlocked, and nil for a blank lock-path.
f runs EXACTLY ONCE, always, and its exception propagates untouched — a
retry here would re-run a ROTATING token exchange with an already-spent
refresh token. Any locking failure (unsupported filesystem, IO error,
overlapping lock), or a peer still holding the lock after timeout-ms,
degrades to running f UNLOCKED rather than blocking authentication.
The single exception is an INTERRUPT while waiting for a peer: the caller is
being cancelled, so f does NOT run and InterruptedException propagates
with the flag restored. The lock file handle is closed on EVERY exit path,
including that one — an fd left to the GC would also leave the advisory lock
held for an unbounded time.
Run `f` while holding an OS advisory lock on `lock-path`, serializing the critical section ACROSS processes (e.g. two vis JVMs — a source-runtime client and its gateway daemon — sharing one credential file). The caller must already hold the in-process monitor, since a single JVM cannot hold two overlapping locks on the same file. `f` gets true when it holds the lock, false when it runs unlocked, and nil for a blank `lock-path`. `f` runs EXACTLY ONCE, always, and its exception propagates untouched — a retry here would re-run a ROTATING token exchange with an already-spent refresh token. Any locking failure (unsupported filesystem, IO error, overlapping lock), or a peer still holding the lock after `timeout-ms`, degrades to running `f` UNLOCKED rather than blocking authentication. The single exception is an INTERRUPT while waiting for a peer: the caller is being cancelled, so `f` does NOT run and `InterruptedException` propagates with the flag restored. The lock file handle is closed on EVERY exit path, including that one — an fd left to the GC would also leave the advisory lock held for an unbounded time.
How long to wait for the CROSS-PROCESS advisory lock before giving up and running unlocked. A peer's own exchange takes a second or two, so this is generous; the point is that a peer that STALLED (or died holding the lock on a filesystem that leaks it) must never freeze provider auth in this JVM forever — the caller reaches here holding the in-process monitor, so a wedged acquisition blocks EVERY refresh here, not just this one.
How long to wait for the CROSS-PROCESS advisory lock before giving up and running unlocked. A peer's own exchange takes a second or two, so this is generous; the point is that a peer that STALLED (or died holding the lock on a filesystem that leaks it) must never freeze provider auth in this JVM forever — the caller reaches here holding the in-process monitor, so a wedged acquisition blocks EVERY refresh here, not just this one.
A refresh persisted/produced within this window is REUSED instead of re-run. Wide enough to absorb a 401 burst (every iteration's retry + the limits poll), short enough that a genuinely-stale token still refreshes promptly.
A refresh persisted/produced within this window is REUSED instead of re-run. Wide enough to absorb a 401 burst (every iteration's retry + the limits poll), short enough that a genuinely-stale token still refreshes promptly.
(fresh-within? saved-at-ms)(fresh-within? saved-at-ms window-ms)True when saved-at-ms (epoch millis, or nil) is within window-ms of
now — i.e. another caller persisted these creds this recently, so they
are safe to reuse rather than running another (rotating) exchange.
True when `saved-at-ms` (epoch millis, or nil) is within `window-ms` of now — i.e. another caller persisted these creds this recently, so they are safe to reuse rather than running another (rotating) exchange.
(log-refresh! {:keys [store outcome trigger started-ms error refresh-token
next-refresh-token locked]})Log ONE refresh exchange of credential store store as ::token-refresh.
outcome is :refreshed, :superseded (refused, but another writer replaced the
credential), :rejected (a sign-in is needed), :failed, :persist-failed (the
rotated token is lost) or :no-refresh-token. locked is false when the exchange
ran without its file lock. The record holds no token, body or error message:
only the HTTP status, the error type and class, and token fingerprints.
Log ONE refresh exchange of credential store `store` as `::token-refresh`. `outcome` is :refreshed, :superseded (refused, but another writer replaced the credential), :rejected (a sign-in is needed), :failed, :persist-failed (the rotated token is lost) or :no-refresh-token. `locked` is false when the exchange ran without its file lock. The record holds no token, body or error message: only the HTTP status, the error type and class, and token fingerprints.
(make-file-refresher {:keys [store load saved-at usable? refresh-token exchange!
persist! on-rejected! ->token no-token!
reuse-window-ms lock-path]})Build a 0/1-arg single-flight refresh fn for a FILE-backed credential
store whose token endpoint ROTATES the refresh_token (Anthropic,
OpenAI Codex, MCP servers). Owns its own lock; reuses usable saved creds
instead of running a second (rotating) exchange — UNLESS the reusable token
equals the one just rejected (pass that token when calling the returned fn on
401 recovery), so a server-rotated but locally-fresh token is re-exchanged
rather than handed back dead. Every exchange is logged under :store through
log-refresh!; a reuse is not.
opts — all required unless noted:
:store keyword or string the store name in the log
:load (fn [] -> creds-map|nil) read persisted creds
:saved-at (fn [creds] -> epoch-ms|nil) persist timestamp; required
only without :usable?
:refresh-token (fn [creds] -> string|nil) the rotating refresh token
:exchange! (fn [refresh-token] -> creds) the rotating HTTP call
:persist! (fn [creds] -> creds) save + STAMP saved-at
:->token (fn [creds] -> token-map) runtime provider-token shape
:no-token! (fn [] -> throws) when no refresh token on file
:usable? (optional) (fn [creds] -> bool) reuse test; default: saved
within :reuse-window-ms
:on-rejected! (optional) (fn [creds]) after a refused grant, before the
sign-in error; default: persist :reauth-required true
:reuse-window-ms (optional) override the default window
:lock-path (optional) file path for an OS advisory lock that
serializes the rotating exchange ACROSS processes
(two vis JVMs sharing one credential file). Without it,
only in-process callers serialize and two processes can
still race the rotation into HTTP 400 invalid_grant.
Returns the provider-token map produced by :->token.
Build a 0/1-arg single-flight refresh fn for a FILE-backed credential
store whose token endpoint ROTATES the refresh_token (Anthropic,
OpenAI Codex, MCP servers). Owns its own lock; reuses usable saved creds
instead of running a second (rotating) exchange — UNLESS the reusable token
equals the one just rejected (pass that token when calling the returned fn on
401 recovery), so a server-rotated but locally-fresh token is re-exchanged
rather than handed back dead. Every exchange is logged under `:store` through
[[log-refresh!]]; a reuse is not.
opts — all required unless noted:
:store keyword or string the store name in the log
:load (fn [] -> creds-map|nil) read persisted creds
:saved-at (fn [creds] -> epoch-ms|nil) persist timestamp; required
only without `:usable?`
:refresh-token (fn [creds] -> string|nil) the rotating refresh token
:exchange! (fn [refresh-token] -> creds) the rotating HTTP call
:persist! (fn [creds] -> creds) save + STAMP saved-at
:->token (fn [creds] -> token-map) runtime provider-token shape
:no-token! (fn [] -> throws) when no refresh token on file
:usable? (optional) (fn [creds] -> bool) reuse test; default: saved
within `:reuse-window-ms`
:on-rejected! (optional) (fn [creds]) after a refused grant, before the
sign-in error; default: persist `:reauth-required true`
:reuse-window-ms (optional) override the default window
:lock-path (optional) file path for an OS advisory lock that
serializes the rotating exchange ACROSS processes
(two vis JVMs sharing one credential file). Without it,
only in-process callers serialize and two processes can
still race the rotation into HTTP 400 `invalid_grant`.
Returns the provider-token map produced by `:->token`.(new-lock)A fresh monitor object. One per credential store, owned by the refresher built around it — never share a lock across providers.
A fresh monitor object. One per credential store, owned by the refresher built around it — never share a lock across providers.
(reauthentication-error? error)True when a refresh grant requires a new sign-in.
True when a refresh grant requires a new sign-in.
(reauthentication-status credentials)Return the saved rejection verdict, or nil when sign-in is not required.
Return the saved rejection verdict, or nil when sign-in is not required.
(require-usable-credentials! credentials)Reject a saved invalid grant before token reuse or another refresh exchange.
Reject a saved invalid grant before token reuse or another refresh exchange.
(single-flight! lock reuse refresh!)Serialize refresh! on lock. Once the lock is held, call reuse
first: if it returns a non-nil result, another caller already refreshed
while this thread waited for the lock, so return that WITHOUT a second
exchange; otherwise run refresh! and return its result. reuse and
refresh! are 0-arg fns; reuse returns the reusable token map or nil.
Serialize `refresh!` on `lock`. Once the lock is held, call `reuse` first: if it returns a non-nil result, another caller already refreshed while this thread waited for the lock, so return that WITHOUT a second exchange; otherwise run `refresh!` and return its result. `reuse` and `refresh!` are 0-arg fns; `reuse` returns the reusable token map or nil.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |