@@ -209,6 +209,135 @@ void exchangeOAuth2Token_concurrentRequests_singleExchange(WireMockRuntimeInfo w
209209 verify (1 , postRequestedFor (urlEqualTo ("/oauth/token" )));
210210 }
211211
212+ /**
213+ * Regression: after a successful exchange, a second wave of concurrent callers must see the
214+ * cached snapshot and NOT trigger a redundant exchange.
215+ * This covers a race where thread A reads an invalid snapshot, thread B completes the exchange,
216+ * and thread A then enters the slow path synchronized block - but still returns the cached
217+ * token without issuing another request.
218+ */
219+ @ Test
220+ void getAccessToken_cachedTokenHit (WireMockRuntimeInfo wm ) throws Exception {
221+ stubFor (post (urlEqualTo ("/oauth/token" ))
222+ .willReturn (ok (String .format ("{\" access_token\" :\" %s\" ,\" expires_in\" :3600}" , ACCESS_TOKEN ))
223+ .withFixedDelay (100 )));
224+
225+ OAuth2Client client = newOAuth2Client (wm .getHttpBaseUrl (), false );
226+
227+ // Wave 1 — triggers the only exchange.
228+ int wave1Count = 3 ;
229+ CountDownLatch wave1Start = new CountDownLatch (1 );
230+ CountDownLatch wave1Done = new CountDownLatch (wave1Count );
231+ List <String > wave1Tokens = Collections .synchronizedList (new ArrayList <>());
232+ List <Throwable > failures = Collections .synchronizedList (new ArrayList <>());
233+
234+ for (int i = 0 ; i < wave1Count ; i ++) {
235+ new Thread (() -> {
236+ try {
237+ wave1Start .await ();
238+ wave1Tokens .add (client .getAccessToken ().get ());
239+ } catch (Exception e ) {
240+ failures .add (e );
241+ } finally {
242+ wave1Done .countDown ();
243+ }
244+ })
245+ .start ();
246+ }
247+ wave1Start .countDown ();
248+ assertTrue (wave1Done .await (2 , TimeUnit .SECONDS ), "wave 1 did not complete in time" );
249+ assertTrue (failures .isEmpty (), "wave 1 should not have failures" );
250+
251+ // Wave 2 — arrives after the exchange has completed; must use the cached token.
252+ int wave2Count = 5 ;
253+ CountDownLatch wave2Start = new CountDownLatch (1 );
254+ CountDownLatch wave2Done = new CountDownLatch (wave2Count );
255+ List <String > wave2Tokens = Collections .synchronizedList (new ArrayList <>());
256+
257+ for (int i = 0 ; i < wave2Count ; i ++) {
258+ new Thread (() -> {
259+ try {
260+ wave2Start .await ();
261+ wave2Tokens .add (client .getAccessToken ().get ());
262+ } catch (Exception e ) {
263+ failures .add (e );
264+ } finally {
265+ wave2Done .countDown ();
266+ }
267+ })
268+ .start ();
269+ }
270+ wave2Start .countDown ();
271+ assertTrue (wave2Done .await (2 , TimeUnit .SECONDS ), "wave 2 did not complete in time" );
272+
273+ assertEquals (List .of (), failures , "no thread should have thrown" );
274+ assertEquals (wave1Count , wave1Tokens .size ());
275+ assertEquals (wave2Count , wave2Tokens .size ());
276+ assertTrue (wave1Tokens .stream ().allMatch (ACCESS_TOKEN ::equals ));
277+ assertTrue (wave2Tokens .stream ().allMatch (ACCESS_TOKEN ::equals ));
278+
279+ // Only one exchange ever happened across both waves.
280+ verify (1 , postRequestedFor (urlEqualTo ("/oauth/token" )));
281+ }
282+
283+ /**
284+ * Regression: a late wave of callers that arrives while the exchange is still in-flight
285+ * must join the existing future rather than starting a second exchange.
286+ */
287+ @ Test
288+ void getAccessToken_joinInFlightExchange (WireMockRuntimeInfo wm ) throws Exception {
289+ stubFor (post (urlEqualTo ("/oauth/token" ))
290+ .willReturn (ok (String .format ("{\" access_token\" :\" %s\" ,\" expires_in\" :3600}" , ACCESS_TOKEN ))
291+ .withFixedDelay (300 )));
292+
293+ OAuth2Client client = newOAuth2Client (wm .getHttpBaseUrl (), false );
294+
295+ List <String > allTokens = Collections .synchronizedList (new ArrayList <>());
296+ List <Throwable > failures = Collections .synchronizedList (new ArrayList <>());
297+
298+ // Wave 1 — triggers the exchange (300 ms delay).
299+ int wave1Count = 2 ;
300+ CountDownLatch wave1Start = new CountDownLatch (1 );
301+ CountDownLatch allDone = new CountDownLatch (wave1Count + 3 );
302+
303+ for (int i = 0 ; i < wave1Count ; i ++) {
304+ new Thread (() -> {
305+ try {
306+ wave1Start .await ();
307+ allTokens .add (client .getAccessToken ().get ());
308+ } catch (Exception e ) {
309+ failures .add (e );
310+ } finally {
311+ allDone .countDown ();
312+ }
313+ })
314+ .start ();
315+ }
316+ wave1Start .countDown ();
317+
318+ // Wave 2 — arrives 50 ms later while exchange is still in-flight.
319+ Thread .sleep (50 );
320+ int wave2Count = 3 ;
321+ for (int i = 0 ; i < wave2Count ; i ++) {
322+ new Thread (() -> {
323+ try {
324+ allTokens .add (client .getAccessToken ().get ());
325+ } catch (Exception e ) {
326+ failures .add (e );
327+ } finally {
328+ allDone .countDown ();
329+ }
330+ })
331+ .start ();
332+ }
333+
334+ assertTrue (allDone .await (5 , TimeUnit .SECONDS ), "threads did not complete in time" );
335+ assertEquals (List .of (), failures , "no thread should have thrown" );
336+ assertEquals (wave1Count + wave2Count , allTokens .size ());
337+ assertTrue (allTokens .stream ().allMatch (ACCESS_TOKEN ::equals ));
338+ verify (1 , postRequestedFor (urlEqualTo ("/oauth/token" )));
339+ }
340+
212341 @ Test
213342 public void apiTokenIssuer_invalidScheme () {
214343 // When
0 commit comments