Skip to content

Commit fe9f603

Browse files
committed
fix(java): thread reliability via sync
1 parent 5f53c21 commit fe9f603

4 files changed

Lines changed: 141 additions & 75 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ public class Console {
139139
}
140140
```
141141

142-
> Token refresh note: `getAccessToken()` (or `getAccessToken(false)`) returns the cached token while valid. Call `getAccessToken(true)` to bypass the cache and force retrieval of a new token.
142+
> Token refresh note: `getAccessToken()` (or `getAccessToken(false)`) returns the cached token while valid. Call `getAccessToken(true)` to bypass the cache and force retrieval of a new token. (See [Access Token Proactive Expiry Offset](#access-token-proactive-expiry-offset) for details on token expiry handling.)
143143
144144
### Configure a Proxy
145145

@@ -187,16 +187,16 @@ The `ConfidentialClient` refreshes access tokens proactively before their actual
187187
Default behaviour:
188188
- A 30 second (30,000 ms) proactive offset is applied automatically.
189189
- Calls to `getAccessToken()` (or `getAccessToken(false)`) reuse the cached token while it is still considered valid under this adjusted expiry.
190-
- `getAccessToken(true)` always forces a fresh token (bypasses cache).
190+
- `getAccessToken(true)` forces a fresh token unless one was very recently refreshed (within 5 seconds) to avoid unnecessary duplicate requests from concurrent threads.
191191

192192
You can override the proactive offset by configuring it in `RequestOptions`:
193193

194194
#### Example
195195
```java
196-
RequestOptions options10s = RequestOptions.builder()
197-
.accessTokenExpiryOffsetMillis(90_000L) // 90 seconds
196+
RequestOptions options = RequestOptions.builder()
197+
.accessTokenExpiryOffset(Duration.ofSeconds(90)) // 90 seconds
198198
.build();
199-
ConfidentialClient client10s = new ConfidentialClient("./path/to/config.json", options10s);
199+
ConfidentialClient client = new ConfidentialClient("./path/to/config.json", options);
200200
```
201201

202202
## Modules

src/main/java/com/factset/sdk/utils/authentication/ConfidentialClient.java

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.util.List;
3434
import java.util.Objects;
3535
import java.util.concurrent.TimeUnit;
36+
import java.time.Duration; // added for internal duration usage
3637
import org.slf4j.Logger;
3738
import org.slf4j.LoggerFactory;
3839

@@ -51,7 +52,8 @@ public class ConfidentialClient implements OAuth2Client {
5152
private long jwsIssuedAt;
5253
private long accessTokenExpireTime;
5354
private AccessToken accessToken;
54-
private final long accessTokenExpiryOffsetMillis;
55+
private final Duration accessTokenExpiryOffset;
56+
private long lastRefreshTime;
5557

5658
/**
5759
* Creates a new ConfidentialClient. When setting up the OAuth 2.0 client, this constructor reaches out to
@@ -120,7 +122,7 @@ public ConfidentialClient(final Configuration config, RequestOptions requestOpti
120122
this.config = config;
121123
LOGGER.debug("Finished initialising configuration");
122124
this.requestOptions = requestOptions == null ? RequestOptions.builder().build() : requestOptions;
123-
this.accessTokenExpiryOffsetMillis = this.requestOptions.getAccessTokenExpiryOffsetMillis();
125+
this.accessTokenExpiryOffset = this.requestOptions.getAccessTokenExpiryOffset();
124126
this.requestProviderMetadata();
125127
}
126128

@@ -184,18 +186,31 @@ protected ConfidentialClient(final Configuration config, final TokenRequestBuild
184186
/**
185187
* Returns an access token that can be used for authentication. If the cache contains a valid access token,
186188
* it's returned. Otherwise, a new access token is retrieved from FactSet's authorization server.
187-
* If forceRefresh is true, always fetches a new token regardless of cache.
189+
* If forceRefresh is true, fetches a new token unless one was very recently refreshed (within 5 seconds)
190+
* to avoid unnecessary duplicate requests from concurrent threads.
188191
*
189192
* @param forceRefresh If true, forces fetching a new token from the server.
190193
* @return The access token in string format.
191194
* @throws AccessTokenException If it can't make a successful request or parse the TokenRequest.
192195
* @throws SigningJwsException If the signing of the JWS fails.
193196
*/
194-
public String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException {
195-
if (!forceRefresh && this.isCachedTokenValid()) {
196-
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
197-
return this.accessToken.toString();
197+
public synchronized String getAccessToken(boolean forceRefresh) throws AccessTokenException, SigningJwsException {
198+
if (this.isCachedTokenValid()) {
199+
if (!forceRefresh) {
200+
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
201+
return this.accessToken.toString();
202+
}
203+
204+
long currentTime = System.currentTimeMillis();
205+
206+
// Implementing a grace period of 5 seconds to avoid unnecessary token refreshes
207+
boolean recentlyRefreshed = (currentTime - this.lastRefreshTime) < 5000;
208+
if (recentlyRefreshed) {
209+
LOGGER.debug("Force refresh requested but token was recently refreshed within grace period, returning cached token");
210+
return this.accessToken.toString();
211+
}
198212
}
213+
199214
return this.fetchAccessToken();
200215
}
201216

@@ -210,7 +225,7 @@ public String getAccessToken(boolean forceRefresh) throws AccessTokenException,
210225
* @throws SigningJwsException If the signing of the JWS fails.
211226
*/
212227
@Override
213-
public String getAccessToken() throws AccessTokenException, SigningJwsException {
228+
public synchronized String getAccessToken() throws AccessTokenException, SigningJwsException {
214229
if (this.isCachedTokenValid()) {
215230
LOGGER.info("Retrieved access token which expires in: {} seconds", TimeUnit.MILLISECONDS.toSeconds(this.accessTokenExpireTime - System.currentTimeMillis()));
216231
return this.accessToken.toString();
@@ -249,7 +264,7 @@ private void requestProviderMetadata() throws AuthServerMetadataContentException
249264
new TokenRequestBuilder().uri(this.providerMetadata.getTokenEndpointURI());
250265
}
251266

252-
private boolean isCachedTokenValid() {
267+
private synchronized boolean isCachedTokenValid() {
253268
if (this.accessToken == null) {
254269
return false;
255270
}
@@ -283,21 +298,13 @@ private String fetchAccessToken() throws AccessTokenException, SigningJwsExcepti
283298

284299
if (tokenRes.indicatesSuccess()) {
285300
this.accessToken = tokenRes.toSuccessResponse().getTokens().getAccessToken();
286-
long lifetimeMillis = TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime());
287-
long rawOffset = this.accessTokenExpiryOffsetMillis;
288-
289-
long clampedOffset;
290-
if (rawOffset >= 899_000L) {
291-
clampedOffset = 899_000L - 1;
292-
LOGGER.warn("Proactive expiry offset {}ms >= 899 seconds. Clamped to {}ms.", rawOffset, clampedOffset);
293-
} else {
294-
clampedOffset = rawOffset;
295-
}
296-
297-
long effectiveLifetime = lifetimeMillis - clampedOffset;
301+
long lifetimeMillis = java.util.concurrent.TimeUnit.SECONDS.toMillis(this.accessToken.getLifetime());
302+
long offsetMillis = this.accessTokenExpiryOffset.toMillis();
303+
long effectiveLifetime = lifetimeMillis - offsetMillis;
298304
this.accessTokenExpireTime = this.jwsIssuedAt + effectiveLifetime;
299-
LOGGER.info("Fetched access token (serverLifetime={}s, offsetApplied={}ms, effectiveLifetime={}ms)",
300-
this.accessToken.getLifetime(), clampedOffset, effectiveLifetime);
305+
LOGGER.info("Fetched access token (serverLifetime={}s, configuredOffset={}ms, effectiveLifetime={}ms)",
306+
this.accessToken.getLifetime(), offsetMillis, effectiveLifetime);
307+
this.lastRefreshTime = System.currentTimeMillis();
301308
return this.accessToken.toString();
302309
}
303310

src/main/java/com/factset/sdk/utils/authentication/RequestOptions.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import javax.net.ssl.HttpsURLConnection;
88
import javax.net.ssl.SSLSocketFactory;
99
import java.net.Proxy;
10+
import java.time.Duration;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
1013

1114
@Value
1215
@Builder
@@ -23,6 +26,30 @@ public class RequestOptions {
2326
@Builder.Default
2427
String userAgent = "fds-sdk/java/utils/1.1.5 (" + System.getProperty("os.name") + "; Java" + System.getProperty("java.version") + ")";
2528

29+
/**
30+
* Maximum allowed proactive refresh offset (894 seconds).
31+
*/
32+
public static final Duration MAX_PROACTIVE_OFFSET = Duration.ofSeconds(894);
33+
34+
private static final Logger LOG = LoggerFactory.getLogger(RequestOptions.class);
35+
2636
@Builder.Default
27-
long accessTokenExpiryOffsetMillis = 30_000L;
37+
Duration accessTokenExpiryOffset = Duration.ofSeconds(30);
38+
39+
40+
public static RequestOptionsBuilder builder() {
41+
return new RequestOptionsBuilder() {
42+
43+
@Override
44+
public RequestOptionsBuilder accessTokenExpiryOffset(Duration d) {
45+
if (d == null) throw new IllegalArgumentException("accessTokenExpiryOffset cannot be null");
46+
if (d.compareTo(MAX_PROACTIVE_OFFSET) > 0) {
47+
LOG.warn("Configured accessTokenExpiryOffset {} exceeds max {}; clamped to {}.", d, MAX_PROACTIVE_OFFSET, MAX_PROACTIVE_OFFSET);
48+
return super.accessTokenExpiryOffset(MAX_PROACTIVE_OFFSET);
49+
}
50+
51+
return super.accessTokenExpiryOffset(d);
52+
}
53+
};
54+
}
2855
}

0 commit comments

Comments
 (0)