|
11 | 11 | import java.time.Instant; |
12 | 12 | import java.util.HashMap; |
13 | 13 | import java.util.Map; |
| 14 | +import java.util.concurrent.atomic.AtomicReference; |
14 | 15 | import java.util.concurrent.CompletableFuture; |
15 | 16 |
|
16 | 17 | public class OAuth2Client { |
17 | 18 | private static final String DEFAULT_API_TOKEN_ISSUER_PATH = "/oauth/token"; |
18 | 19 |
|
19 | 20 | private final ApiClient apiClient; |
20 | | - private final AccessToken token = new AccessToken(); |
| 21 | + private final AtomicReference<TokenSnapshot> snapshot = new AtomicReference<>(TokenSnapshot.EMPTY); |
| 22 | + private final AtomicReference<CompletableFuture<String>> inFlight = new AtomicReference<>(); |
21 | 23 | private final CredentialsFlowRequest authRequest; |
22 | 24 | private final Configuration config; |
23 | 25 | private final Telemetry telemetry; |
@@ -45,26 +47,54 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga |
45 | 47 | } |
46 | 48 |
|
47 | 49 | /** |
48 | | - * Gets an access token, handling exchange when necessary. The access token is naively cached in memory until it |
49 | | - * expires. |
| 50 | + * Gets an access token, handling exchange when necessary. The token is cached as an immutable |
| 51 | + * snapshot until it expires. Concurrent calls are deduplicated: only one exchange is in flight |
| 52 | + * at a time; other callers join the same future rather than issuing redundant requests. |
50 | 53 | * |
51 | 54 | * @return An access token in a {@link CompletableFuture} |
52 | 55 | */ |
53 | 56 | public CompletableFuture<String> getAccessToken() throws FgaInvalidParameterException, ApiException { |
54 | | - if (!token.isValid()) { |
55 | | - return exchangeToken().thenCompose(response -> { |
56 | | - token.setToken(response.getAccessToken()); |
57 | | - token.setExpiresAt(Instant.now().plusSeconds(response.getExpiresInSeconds())); |
58 | | - |
59 | | - Map<Attribute, String> attributesMap = new HashMap<>(); |
| 57 | + TokenSnapshot current = snapshot.get(); |
| 58 | + if (current.isValid()) { |
| 59 | + return CompletableFuture.completedFuture(current.token()); |
| 60 | + } |
60 | 61 |
|
61 | | - telemetry.metrics().credentialsRequest(1L, attributesMap); |
| 62 | + CompletableFuture<String> promise = new CompletableFuture<>(); |
| 63 | + if (!inFlight.compareAndSet(null, promise)) { |
| 64 | + // Another thread won the race — join its exchange rather than starting a new one. |
| 65 | + CompletableFuture<String> existing = inFlight.get(); |
| 66 | + return existing != null ? existing : getAccessToken(); |
| 67 | + } |
62 | 68 |
|
63 | | - return CompletableFuture.completedFuture(token.getToken()); |
| 69 | + // This thread owns the exchange. Start it, wiring completion back to `promise`. |
| 70 | + try { |
| 71 | + exchangeToken().whenComplete((response, ex) -> { |
| 72 | + if (ex != null) { |
| 73 | + inFlight.set(null); |
| 74 | + promise.completeExceptionally(ex); |
| 75 | + } else { |
| 76 | + String token = response.getAccessToken(); |
| 77 | + // Write snapshot before clearing the gate so any new caller that arrives |
| 78 | + // after inFlight becomes null immediately sees a valid token. |
| 79 | + snapshot.set( |
| 80 | + new TokenSnapshot( |
| 81 | + token, |
| 82 | + Instant.now().plusSeconds(response.getExpiresInSeconds()))); |
| 83 | + |
| 84 | + telemetry.metrics().credentialsRequest(1L, new HashMap<>()); |
| 85 | + |
| 86 | + // Clear before completing |
| 87 | + inFlight.set(null); |
| 88 | + promise.complete(token); |
| 89 | + } |
64 | 90 | }); |
| 91 | + } catch (Exception e) { |
| 92 | + inFlight.set(null); |
| 93 | + promise.completeExceptionally(e); |
| 94 | + throw e; |
65 | 95 | } |
66 | 96 |
|
67 | | - return CompletableFuture.completedFuture(token.getToken()); |
| 97 | + return promise; |
68 | 98 | } |
69 | 99 |
|
70 | 100 | /** |
|
0 commit comments