Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,26 @@ public class Example {
}
```

#### Token refresh timing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SoulPancake - checking placement: README.md is autogenerated (it's in .openapi-generator/FILES, and CONTRIBUTING notes changes to those files go through sdk-generator), so as-is this edit will be overwritten on the next generation, right? Could you confirm whether these changes should go to sdk-generator instead?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it should be nice to have the changes in the generator to avoid drift later but it's okay for the maintainers to do that chore instead of the contributors.
So, non-blocking for the PR context.


Client-credentials tokens are cached until their expiry minus a buffer and random jitter.
Both settings default to 300 seconds; jitter is sampled from zero up to, but excluding,
the configured value on each validity check. For short-lived tokens, configure a smaller window:

```java
var config = new ClientConfiguration()
.credentials(new Credentials(new ClientCredentials()
.clientId(System.getenv("FGA_CLIENT_ID"))
.clientSecret(System.getenv("FGA_CLIENT_SECRET"))
.apiTokenIssuer(System.getenv("FGA_API_TOKEN_ISSUER"))))
.tokenExpiryBufferSeconds(30)
.tokenExpiryJitterSeconds(5);
```

Values must be non-negative. Set jitter to zero to disable it. Keep the combined window
below the token lifetime to allow cached tokens to be reused. These client-level settings
are preserved when applying per-request configuration overrides.

### Custom Headers

#### Default Headers
Expand Down Expand Up @@ -1514,4 +1534,3 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for details.
This project is licensed under the Apache-2.0 license. See the [LICENSE](https://github.com/openfga/java-sdk/blob/main/LICENSE) file for more info.

The code in this repo was auto generated by [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) from a template based on the [Java template](https://github.com/OpenAPITools/openapi-generator/tree/master/modules/openapi-generator/src/main/resources/Java), licensed under the [Apache License 2.0](https://github.com/OpenAPITools/openapi-generator/blob/master/LICENSE).

15 changes: 5 additions & 10 deletions src/main/java/dev/openfga/sdk/api/auth/AccessToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import static dev.openfga.sdk.util.StringUtil.isNullOrWhitespace;

import dev.openfga.sdk.constants.FgaConstants;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.ThreadLocalRandom;
Expand All @@ -13,17 +12,13 @@
* even if there is some clock skew or delay between retrieval and use.
*/
record AccessToken(String token, Instant expiresAt) {
private static final int TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC;
// We add some jitter so that token refreshes are less likely to collide
private static final int TOKEN_EXPIRY_JITTER_IN_SEC = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC;

static final AccessToken EMPTY = new AccessToken(null, null);

AccessToken {
expiresAt = expiresAt != null ? expiresAt.truncatedTo(ChronoUnit.SECONDS) : null;
}

boolean isValid() {
boolean isValid(int bufferSeconds, int jitterSeconds) {
if (isNullOrWhitespace(token)) {
return false;
}
Expand All @@ -33,11 +28,11 @@ boolean isValid() {
return true;
}

// A token should be considered valid until 5 minutes before the expiry with some jitter
// to account for multiple calls to `isValid` at the same time and prevent multiple refresh calls
// Refresh before expiry, with optional jitter to spread refreshes across clients.
Instant expiresWithLeeway = expiresAt
.minusSeconds(TOKEN_EXPIRY_BUFFER_THRESHOLD_IN_SEC)
.minusSeconds(ThreadLocalRandom.current().nextInt(TOKEN_EXPIRY_JITTER_IN_SEC))
.minusSeconds(bufferSeconds)
.minusSeconds(
jitterSeconds == 0 ? 0 : ThreadLocalRandom.current().nextInt(jitterSeconds))
Comment thread
dpkass marked this conversation as resolved.
.truncatedTo(ChronoUnit.SECONDS);

return Instant.now().truncatedTo(ChronoUnit.SECONDS).isBefore(expiresWithLeeway);
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class OAuth2Client {
private final CredentialsFlowRequest authRequest;
private final Configuration config;
private final Telemetry telemetry;
private final int tokenExpiryBufferSeconds;
private final int tokenExpiryJitterSeconds;
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than separate fields, these could live in this.config (already this client's config holder). See my notes on the builder and the isValid calls here.

Suggested change
private final int tokenExpiryBufferSeconds;
private final int tokenExpiryJitterSeconds;


/**
* Initializes a new instance of the {@link OAuth2Client} class
Expand All @@ -29,6 +31,8 @@ public class OAuth2Client {
*/
public OAuth2Client(Configuration configuration, ApiClient apiClient) throws FgaInvalidParameterException {
var clientCredentials = configuration.getCredentials().getClientCredentials();
this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds();
this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds();
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than separate fields, these could live in this.config (already this client's config holder). See my notes on the builder and the isValid calls here.

Suggested change
this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds();
this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds();


this.apiClient = apiClient;
this.authRequest =
Expand All @@ -54,15 +58,15 @@ public OAuth2Client(Configuration configuration, ApiClient apiClient) throws Fga
public CompletableFuture<String> getAccessToken() throws FgaInvalidParameterException, ApiException {
// Fast path (lock-free): return cached token if still valid.
AccessToken current = snapshot.get();
if (current.isValid()) {
if (current.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (current.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) {
if (current.isValid(this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) {

Refer to my notes on this here.

return CompletableFuture.completedFuture(current.token());
}

// Slow path: decide under the lock who starts the exchange.
synchronized (this) {
// Double-check: another thread may have refreshed while we waited.
AccessToken rechecked = snapshot.get();
if (rechecked.isValid()) {
if (rechecked.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (rechecked.isValid(tokenExpiryBufferSeconds, tokenExpiryJitterSeconds)) {
if (rechecked.isValid(this.config.getTokenExpiryBufferSeconds(), this.config.getTokenExpiryJitterSeconds())) {

Refer to my notes on this here.

return CompletableFuture.completedFuture(rechecked.token());
}

Expand Down
17 changes: 12 additions & 5 deletions src/main/java/dev/openfga/sdk/api/client/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,7 @@ public void applyAuthHeader(HttpRequest.Builder requestBuilder, Configuration co
}

private OAuth2Client ensureOAuth2Client(Configuration configuration) throws FgaInvalidParameterException {
ClientCredentials cc = configuration.getCredentials().getClientCredentials();
CredentialsCacheKey key = new CredentialsCacheKey(cc);
CredentialsCacheKey key = new CredentialsCacheKey(configuration);
OAuth2Client existing = oAuth2Clients.get(key);
if (existing != null) {
return existing;
Expand All @@ -437,13 +436,18 @@ private static final class CredentialsCacheKey {
private final String apiTokenIssuer;
private final String apiAudience;
private final String scopes;
private final int tokenExpiryBufferSeconds;
private final int tokenExpiryJitterSeconds;

CredentialsCacheKey(ClientCredentials cc) {
CredentialsCacheKey(Configuration configuration) {
ClientCredentials cc = configuration.getCredentials().getClientCredentials();
this.clientId = cc.getClientId();
this.clientSecretHash = sha256(cc.getClientSecret());
this.apiTokenIssuer = cc.getApiTokenIssuer();
this.apiAudience = cc.getApiAudience();
this.scopes = cc.getScopes();
this.tokenExpiryBufferSeconds = configuration.getTokenExpiryBufferSeconds();
this.tokenExpiryJitterSeconds = configuration.getTokenExpiryJitterSeconds();
}

private static byte[] sha256(String value) {
Expand All @@ -463,12 +467,15 @@ public boolean equals(Object o) {
&& Arrays.equals(clientSecretHash, that.clientSecretHash)
&& Objects.equals(apiTokenIssuer, that.apiTokenIssuer)
&& Objects.equals(apiAudience, that.apiAudience)
&& Objects.equals(scopes, that.scopes);
&& Objects.equals(scopes, that.scopes)
&& tokenExpiryBufferSeconds == that.tokenExpiryBufferSeconds
&& tokenExpiryJitterSeconds == that.tokenExpiryJitterSeconds;
}

@Override
public int hashCode() {
int result = Objects.hash(clientId, apiTokenIssuer, apiAudience, scopes);
int result = Objects.hash(
clientId, apiTokenIssuer, apiAudience, scopes, tokenExpiryBufferSeconds, tokenExpiryJitterSeconds);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My suggestion is to take the take buffer and jitter out of the cache key. All the changes in this file are just to add buffer/jitter to the cache key. So can we revet this file to main?

The cache key identifies the token: same credentials -> same token. Buffer and jitter don't have any role in the token the IdP returns, they only affect when we refresh it. So putting them in the key just creates a second cache entry, and a second token exchange, for the same credentials whenever the timing differs. The key should only hold what determines the token.

Nothing's lost (in terms of the functionality added in this PR) by removing them form the cache key: OAuth2Client reads the timing straight from the config when it's built, and ensureOAuth2Client already passes the config through.

result = 31 * result + Arrays.hashCode(clientSecretHash);
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ public ClientConfiguration telemetryConfiguration(TelemetryConfiguration telemet
return this;
}

@Override
public ClientConfiguration tokenExpiryBufferSeconds(int seconds) {
super.tokenExpiryBufferSeconds(seconds);
return this;
}

@Override
public ClientConfiguration tokenExpiryJitterSeconds(int seconds) {
super.tokenExpiryJitterSeconds(seconds);
return this;
}

@Override
public ClientConfiguration defaultHeaders(java.util.Map<String, String> defaultHeaders) {
super.defaultHeaders(defaultHeaders);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public class Configuration implements BaseConfiguration {
private Duration connectTimeout;
private int maxRetries;
private Duration minimumRetryDelay;
private int tokenExpiryBufferSeconds = FgaConstants.TOKEN_EXPIRY_THRESHOLD_BUFFER_IN_SEC;
private int tokenExpiryJitterSeconds = FgaConstants.TOKEN_EXPIRY_JITTER_IN_SEC;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor consistency: Assign the defaults in the constructor - rather than inline initializers.

private Map<String, String> defaultHeaders;
private TelemetryConfiguration telemetryConfiguration;

Expand Down Expand Up @@ -87,6 +89,8 @@ public Configuration override(ConfigurationOverride configurationOverride) {

Credentials overrideCredentials = configurationOverride.getCredentials();
result.credentials(overrideCredentials != null ? overrideCredentials : credentials);
result.tokenExpiryBufferSeconds(tokenExpiryBufferSeconds);
result.tokenExpiryJitterSeconds(tokenExpiryJitterSeconds);
Comment on lines +92 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two lines copy the client-level buffer/jitter as is, unlike every other field in override(). And buffer/jitter are the only two Configuration fields not mirrored in ConfigurationOverride.

Here are my thoughts.

I think the current approach is fine. I don't think these should be per-request overridable. The access token is shared per identity, so "use buffer=X for this one call" has no well-defined meaning on a shared token: whichever request happens to trigger the refresh wins, and it affects every other caller using that token. That's unlike credentials (overriding them yields a genuinely different token) or timeouts/headers (which apply only to your own request).

There is a legitimate case where these values could differ: two credentials whose tokens have very different lifetimes (say 1h vs 5m), where a single buffer is wrong for one of them. But that is per-credential, not per-request, so the natural home would be on the Credentials object (the policy travels with the identity). I'd treat that as a follow-up, not part of this PR.

So my suggestion: keep these as client-level only (don't add them to ConfigurationOverride), and add a short comment here noting that's intentional.

@curfew-marathon, @SoulPancake - Does this make sense or is there something I'm missing? What do you suggest?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment should be picked up after https://github.com/openfga/java-sdk/pull/389/changes#r4154934650 is resolved.

Without these two lines, override() would reset buffer/jitter to the 300 defaults on every request. Nothing in the test file covers that currently. Worth a small direct unit test in ConfigurationTest, in the override_* style, something like:

@Test
void override_preservesTokenExpirySettings() {
    // Given
    Configuration original = new Configuration().tokenExpiryBufferSeconds(42).tokenExpiryJitterSeconds(7);

    // When
    Configuration result = original.override(new ConfigurationOverride());

    // Then
    assertEquals(42, result.getTokenExpiryBufferSeconds());
    assertEquals(7, result.getTokenExpiryJitterSeconds());
}

That matches the existing override_apiUrl / override_userAgent tests. Feel free to also assert the original is unmodified if you want to mirror them fully.


String overrideUserAgent = configurationOverride.getUserAgent();
result.userAgent(overrideUserAgent != null ? overrideUserAgent : userAgent);
Expand Down Expand Up @@ -303,6 +307,41 @@ public Duration getMinimumRetryDelay() {
return minimumRetryDelay;
}

/**
* Sets how many seconds before expiry a cached client-credentials token requires refresh.
* Defaults to 300. This is a client-level setting, preserved by request overrides.
* @throws IllegalArgumentException if seconds is negative.
*/
Comment on lines +310 to +314

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* Sets how many seconds before expiry a cached client-credentials token requires refresh.
* Defaults to 300. This is a client-level setting, preserved by request overrides.
* @throws IllegalArgumentException if seconds is negative.
*/
/**
* Sets how many seconds before expiry a cached client-credentials token requires refresh.
* Defaults to 300. This is a client-level setting, preserved by request overrides.
* <p>Set to 0 to refresh only at the exact expiry instant, which removes the margin that guards
* against clock skew and in-flight request latency. Together with {@code tokenExpiryJitterSeconds}
* this should stay well below the token's lifetime: if their sum meets or exceeds it, every token
* is treated as already-expired and re-exchanged on effectively every call.
*
* @param seconds the refresh buffer in seconds; must be non-negative.
* @return This Configuration instance for method chaining.
* @throws IllegalArgumentException if seconds is negative.
*/

Small consistency nit: The setter has @throws but is missing @param and @return. Could you round add those to match other setters? Thank you.

Also added a note to the reader so they know the behavior at the edges.

public Configuration tokenExpiryBufferSeconds(int seconds) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new buffer/jitter setters and getters don't have unit coverage in ConfigurationTest. Could you please add the relevant tests in ConfigurationTest file?

For each of buffer and jitter: a valid set/read-back (with the assertSame chaining check), the zero case (0 is meaningful here, it disables the buffer / disables jitter), the negative-value IllegalArgumentException (like minimumRetryDelay_negativeDuration_throwsException), and a _hasDefaultValue test asserting 300. And others that I might have missed.

You could refer to the minimumRetryDelay_* cluster and other test cases in the file for reference.

if (seconds < 0) {
throw new IllegalArgumentException("tokenExpiryBufferSeconds must be non-negative");
}
this.tokenExpiryBufferSeconds = seconds;
return this;
}

public int getTokenExpiryBufferSeconds() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Housekeeping nit: Could you add a short Javadoc with @return? Thank you.

return this.tokenExpiryBufferSeconds;
}

/**
* Sets the exclusive upper bound of additional random seconds subtracted on each token expiry check.
* Defaults to 300. Set to zero to disable jitter. This is a client-level setting,
* preserved by request overrides.
* @throws IllegalArgumentException if seconds is negative.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @throws IllegalArgumentException if seconds is negative.
*
* <p>Jitter spreads token refreshes across clients to avoid a thundering herd. Together with
* {@code tokenExpiryBufferSeconds} it should stay well below the token's lifetime: if their sum
* meets or exceeds it, every token is treated as already-expired and re-exchanged on effectively
* every call.
*
* @param seconds the exclusive upper bound of random jitter in seconds; must be non-negative.
* @return This Configuration instance for method chaining.
* @throws IllegalArgumentException if seconds is negative.

Small consistency nit: The setter has @throws but is missing @param and @return. Could you round add those to match other setters? Thank you.

Also added a note to the reader so they know the behavior at the edges.

*/
public Configuration tokenExpiryJitterSeconds(int seconds) {
if (seconds < 0) {
throw new IllegalArgumentException("tokenExpiryJitterSeconds must be non-negative");
}
this.tokenExpiryJitterSeconds = seconds;
return this;
}

public int getTokenExpiryJitterSeconds() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Housekeeping nit: Could you add a short Javadoc with @return? Thank you.

return this.tokenExpiryJitterSeconds;
}

public Configuration defaultHeaders(Map<String, String> defaultHeaders) {
this.defaultHeaders = defaultHeaders;
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.junit.jupiter.api.Assertions.assertEquals;

import dev.openfga.sdk.api.configuration.Configuration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Stream;
Expand Down Expand Up @@ -40,6 +41,9 @@ private static Stream<Arguments> expTimeAndResults() {
@ParameterizedTest(name = "{0}")
void testTokenValid(String name, Instant exp, boolean valid) {
AccessToken snapshot = new AccessToken("token", exp);
assertEquals(valid, snapshot.isValid());
var defaults = new Configuration();
assertEquals(
valid,
snapshot.isValid(defaults.getTokenExpiryBufferSeconds(), defaults.getTokenExpiryJitterSeconds()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move buffer and jitter into the @MethodSource args here instead of reading them off a default Configuration? Right now every case runs at 300/300, so isValid is only ever checked at the old default: the test would still pass even if it ignored the buffer/jitter arguments. With them as inputs we can add cases that prove it honors non-default values, e.g. a token expiring in 5 minutes being invalid at buffer=400 but valid at buffer=30.

We don't need to use Configuration here either, this is a unit test of isValid, which just takes two ints, so coupling it to the config defaults isn't really relevant here.

}
}
51 changes: 44 additions & 7 deletions src/test/java/dev/openfga/sdk/api/client/ApiClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import dev.openfga.sdk.api.configuration.ApiToken;
import dev.openfga.sdk.api.configuration.ClientCredentials;
import dev.openfga.sdk.api.configuration.Configuration;
import dev.openfga.sdk.api.configuration.ConfigurationOverride;
import dev.openfga.sdk.api.configuration.Credentials;
import dev.openfga.sdk.constants.FgaConstants;
import dev.openfga.sdk.errors.ApiException;
Expand All @@ -23,6 +24,8 @@
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;

Expand Down Expand Up @@ -213,8 +216,9 @@ void clientCredentials_failureAsApiException() {
requestBuilder.build().headers().firstValue("Authorization").isPresent());
}

@Test
void clientCredentials_setsAuthHeader() throws Exception {
@ParameterizedTest
@CsvSource({"3600,300,300,1", "300,300,300,2", "300,30,0,1", "300,30,10,1", "20,30,0,2", "300,0,0,1"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could the CsvSource get a header or a short comment on what each column drives (lifetime/buffer/jitter → expected exchanges)? Takes a second to work out why each row is 1 or 2 otherwise. Thank you.

void clientCredentials_setsAuthHeader(int lifetime, int buffer, int jitter, int exchanges) throws Exception {
String clientId = "some-client-id";
String clientSecret = "some-client-secret";
String apiAudience = "some-audience";
Expand All @@ -228,7 +232,9 @@ void clientCredentials_setsAuthHeader() throws Exception {
containsString("client_secret=" + clientSecret),
containsString("audience=" + apiAudience),
containsString("grant_type=client_credentials")))
.doReturn(200, String.format("{\"access_token\":\"%s\",\"expires_in\":3600}", exchangedToken));
.doReturn(
200,
String.format("{\"access_token\":\"%s\",\"expires_in\":%d}", exchangedToken, lifetime));

HttpClient.Builder mockBuilder = mockHttpClientBuilder(mockHttpClient);
ApiClient apiClient = new ApiClient(mockBuilder);
Expand All @@ -239,7 +245,9 @@ void clientCredentials_setsAuthHeader() throws Exception {
.clientId(clientId)
.clientSecret(clientSecret)
.apiAudience(apiAudience)
.apiTokenIssuer(FgaConstants.TEST_ISSUER_URL)));
.apiTokenIssuer(FgaConstants.TEST_ISSUER_URL)))
.tokenExpiryBufferSeconds(buffer)
.tokenExpiryJitterSeconds(jitter);

HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL));
apiClient.applyAuthHeader(requestBuilder, configuration);
Expand All @@ -248,17 +256,46 @@ void clientCredentials_setsAuthHeader() throws Exception {
"Bearer " + exchangedToken,
requestBuilder.build().headers().firstValue("Authorization").orElseThrow());

// A second call should reuse the cached token and not hit the issuer again.
// Reuse the token only while it is outside the configured refresh window.
HttpRequest.Builder secondBuilder = HttpRequest.newBuilder().uri(URI.create(FgaConstants.TEST_API_URL));
apiClient.applyAuthHeader(secondBuilder, configuration);
apiClient.applyAuthHeader(secondBuilder, configuration.override(new ConfigurationOverride()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason an override is used here? An empty one merges to the same config, so it reuses the same cache entry either way.

assertEquals(
"Bearer " + exchangedToken,
secondBuilder.build().headers().firstValue("Authorization").orElseThrow());

mockHttpClient
.verify()
.post(String.format("%s/oauth/token", FgaConstants.TEST_ISSUER_URL))
.called(1);
.called(exchanges);
}

@ParameterizedTest
@CsvSource({"31,10", "30,11"})
void clientCredentials_differentRefreshSettings_useSeparateCaches(int buffer, int jitter) throws Exception {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment should be picked once https://github.com/openfga/java-sdk/pull/389/changes#r4156168685 is resolved.

This test exercises the behavior I flagged over on ApiClient.java (buffer/jitter in the cache key) here: same credentials with different timing producing separate token exchanges. It's tied to that discussion, so once we settle it there and take timing out of the key, this test should be deleted.

Once we remove the above, it'd be worth replacing it with the inverse, a guard that same credentials share one cached client regardless of refresh timing, which is exactly the contract we'd be committing to. Sharing a skeleton below:

@Test
void clientCredentials_differentRefreshSettings_shareOneCache() throws Exception {
    HttpClientMock mockHttpClient = new HttpClientMock();
    mockHttpClient
            .onPost(FgaConstants.TEST_ISSUER_URL + "/oauth/token")
            .doReturn(200, "{\"access_token\":\"token\",\"expires_in\":3600}");
    ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient));

    ClientCredentials credentials = new ClientCredentials()
            .clientId("client")
            .clientSecret("secret")
            .apiTokenIssuer(FgaConstants.TEST_ISSUER_URL);

    Configuration first = new Configuration()
            .credentials(new Credentials(credentials))
            .tokenExpiryBufferSeconds(30)
            .tokenExpiryJitterSeconds(10);
    Configuration second = new Configuration()
            .credentials(new Credentials(credentials))
            .tokenExpiryBufferSeconds(300)
            .tokenExpiryJitterSeconds(0);

    apiClient.applyAuthHeader(HttpRequest.newBuilder(), first);
    apiClient.applyAuthHeader(HttpRequest.newBuilder(), second);

    // Same credentials => one cached client/token, regardless of refresh timing.
    mockHttpClient.verify().post(FgaConstants.TEST_ISSUER_URL + "/oauth/token").called(1);
}

HttpClientMock mockHttpClient = new HttpClientMock();
mockHttpClient
.onPost(FgaConstants.TEST_ISSUER_URL + "/oauth/token")
.doReturn(200, "{\"access_token\":\"token\",\"expires_in\":300}");
ApiClient apiClient = new ApiClient(mockHttpClientBuilder(mockHttpClient));
ClientCredentials credentials = new ClientCredentials()
.clientId("client")
.clientSecret("secret")
.apiTokenIssuer(FgaConstants.TEST_ISSUER_URL);
Configuration configuration = new Configuration()
.credentials(new Credentials(credentials))
.tokenExpiryBufferSeconds(30)
.tokenExpiryJitterSeconds(10);

apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration);
apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration);
configuration.tokenExpiryBufferSeconds(buffer).tokenExpiryJitterSeconds(jitter);
apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration);
apiClient.applyAuthHeader(HttpRequest.newBuilder(), configuration);

mockHttpClient
.verify()
.post(FgaConstants.TEST_ISSUER_URL + "/oauth/token")
.called(2);
}

@Test
Expand Down