RefreshGuard
in package
Shared-promise clear-on-both-paths helper (CONTRACT.md §9, D-06).
self::settle() wraps a refresh call's raw PromiseInterface so that,
regardless of success or failure, the caller's $onClear closure runs exactly
once — clearing the caller's own stored promise slot (e.g. Session::$refreshPromise)
so the NEXT 401 always starts a brand-new refresh attempt (§9.3: no retry loop; a
failed refresh is never cached for the next caller, mirroring the C# sibling
Axiam.Sdk.Auth.RefreshGuard's "never cache a faulted refresh" invariant).
PHP has no cross-object mutable-reference primitive that would let this helper
safely OWN a mutable promise-slot on behalf of multiple unrelated session objects
(unlike C#'s field-holding RefreshGuard class), so the slot itself stays on the
owning session (Axiam\Sdk\Session today; a future gRPC session would hold its own
field the same way). What this class DOES factor out — and what every such session
must apply identically — is the clear-on-both-paths bookkeeping and the
normalize-to-AuthError failure translation, so REST and (later) gRPC never drift
on that one piece of behavior (D-06's "ONE mechanism" requirement).
Table of Contents
Methods
- join() : mixed
- Wait for an ALREADY-IN-FLIGHT shared refresh to settle and return its outcome — the waiter's half of CONTRACT.md §9 rule 2 (F-06).
- settle() : PromiseInterface
Methods
join()
Wait for an ALREADY-IN-FLIGHT shared refresh to settle and return its outcome — the waiter's half of CONTRACT.md §9 rule 2 (F-06).
public
static join(PromiseInterface $shared) : mixed
A caller that finds the guard occupied MUST end up holding the leader's outcome:
AXIAM refresh tokens are opaque, server-stored and single-use with rotation, so a
second wire call would replay an already-consumed token. The obvious way to wait —
$shared->wait() — is exactly what must NOT be done here, and is why this method
exists:
GuzzleHttp\Promise\Promise::wait() does not "wait" in the concurrent sense. It
drives the promise: it takes the underlying promise's wait function, NULLS it,
and runs it. The leader already did that when it started its own wire call, so a
second wait() from another fiber/coroutine walks the same wait list, finds a
pending promise with no wait function left, and rejects the leader's promise
with "Cannot wait on a promise that has no internal wait function". That kills
the in-flight refresh for everyone AND frees the guard slot mid-flight, so the
next caller starts a second POST /oauth2/token with a refresh token the leader
has already spent — the precise failure §9 rule 2 forbids.
So this observes rather than drives: it registers a callback on the shared promise, drains Guzzle's task queue (which is all that is needed in the overwhelmingly common case — a promise that has already settled and merely has deferred callbacks outstanding), and otherwise yields to whatever scheduler is driving the leader until the outcome arrives. Rule 6(a)/(d) fall out of this by construction: the callback is registered BEFORE any yielding, so the outcome cannot be missed no matter when the leader clears the slot, and a caller that arrives after full settlement never gets here — it acquires the free guard and refreshes afresh.
Parameters
- $shared : PromiseInterface
-
The in-flight refresh promise returned by
Session::refreshGuard()'spromisekey. Already normalized by self::settle(), so its failure mode is AuthError.
Tags
Return values
mixed —The leader's fulfilment value — for oidc_refresh, the decoded
TokenResponse array of the ONE wire call every caller shares.
settle()
public
static settle(PromiseInterface $refreshCall, callable(): void $onClear[, callable(mixed): mixed|null $onSuccess = null ]) : PromiseInterface
Parameters
- $refreshCall : PromiseInterface
-
The raw in-flight refresh request promise.
- $onClear : callable(): void
-
Clears the caller's stored promise slot. Invoked exactly once, on EITHER the success or the failure path — never on both, never on neither.
- $onSuccess : callable(mixed): mixed|null = null
-
Optional success-path transform (e.g. CSRF-token capture) run AFTER
$onClear, before the settled value is handed back to the caller.