Skip to content

Support background token refresh when using AuthTokenProvider #592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 22, 2025
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
java_version: [17, 21]
java_version: [21]
os: [ubuntu-latest]

steps:
Expand Down
56 changes: 56 additions & 0 deletions http-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@

<build>
<plugins>

<plugin> <!-- Multi-Release with 21 -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</archive>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
Expand All @@ -111,9 +125,51 @@
</annotationProcessorPaths>
</configuration>
</execution>
<!-- Compile for base version Java 11 -->
<execution>
<id>base</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<release>11</release>
<compileSourceRoots>
<compileSourceRoot>${project.basedir}/src/main/java</compileSourceRoot>
</compileSourceRoots>
</configuration>
</execution>
<!-- Compile for Java 21 -->
<execution>
<id>java21</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<release>21</release>
<compileSourceRoots>
<compileSourceRoot>${project.basedir}/src/main/java21</compileSourceRoot>
</compileSourceRoots>
<outputDirectory>${project.build.outputDirectory}/META-INF/versions/21</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>

<!-- generated by avaje inject -->
<plugin>
<groupId>io.avaje</groupId>
<artifactId>avaje-inject-maven-plugin</artifactId>
<version>11.4</version>
<executions>
<execution>
<?m2e execute?>
<phase>process-sources</phase>
<goals>
<goal>provides</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
11 changes: 11 additions & 0 deletions http-client/src/main/java/io/avaje/http/client/AuthToken.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.avaje.http.client;

import java.time.Duration;
import java.time.Instant;

/**
Expand All @@ -19,6 +20,11 @@ public interface AuthToken {
*/
boolean isExpired();

/**
* Return the duration until expiry.
*/
Duration expiration();

/**
* Create an return a AuthToken with the given token and time it is valid until.
*/
Expand Down Expand Up @@ -51,5 +57,10 @@ public String token() {
public boolean isExpired() {
return Instant.now().isAfter(validUntil);
}

@Override
public Duration expiration() {
return Duration.between(Instant.now(), validUntil);
}
}
}
25 changes: 25 additions & 0 deletions http-client/src/main/java/io/avaje/http/client/BGInvoke.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.avaje.http.client;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

final class BGInvoke {

static void invoke(Runnable task) {
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
executor.submit(task);
} finally {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.net.CookieManager;
import java.net.ProxySelector;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand All @@ -36,6 +37,7 @@ final class DHttpClientBuilder implements HttpClient.Builder, HttpClient.Builder
private RetryHandler retryHandler;
private Function<HttpException, RuntimeException> errorHandler;
private AuthTokenProvider authTokenProvider;
private Duration backgroundRefreshDuration = Duration.of(5, ChronoUnit.MINUTES);

private CookieHandler cookieHandler = new CookieManager();
private java.net.http.HttpClient.Redirect redirect = java.net.http.HttpClient.Redirect.NORMAL;
Expand Down Expand Up @@ -185,6 +187,7 @@ private DHttpClientContext buildClient() {
errorHandler,
buildListener(),
authTokenProvider,
backgroundRefreshDuration,
buildIntercept());
}

Expand Down Expand Up @@ -257,6 +260,12 @@ public HttpClient.Builder authTokenProvider(AuthTokenProvider authTokenProvider)
return this;
}

@Override
public HttpClient.Builder backgroundTokenRefresh(Duration backgroundRefreshDuration) {
this.backgroundRefreshDuration = backgroundRefreshDuration;
return this;
}

@Override
public HttpClient.Builder cookieHandler(CookieHandler cookieHandler) {
this.cookieHandler = cookieHandler;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package io.avaje.http.client;

import io.avaje.applog.AppLog;

import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Type;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand All @@ -18,8 +21,12 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static java.lang.System.Logger.Level.WARNING;

final class DHttpClientContext implements HttpClient, SpiHttpClient {

private static final System.Logger log = AppLog.getLogger("io.avaje.http.client");

static final String AUTHORIZATION = "Authorization";
private static final String BEARER = "Bearer ";

Expand All @@ -33,6 +40,8 @@ final class DHttpClientContext implements HttpClient, SpiHttpClient {
private final boolean withAuthToken;
private final AuthTokenProvider authTokenProvider;
private final AtomicReference<AuthToken> tokenRef = new AtomicReference<>();
private final AtomicReference<Instant> backgroundRefreshLease = new AtomicReference<>();
private final Duration backgroundRefreshDuration;

private final LongAdder metricResTotal = new LongAdder();
private final LongAdder metricResError = new LongAdder();
Expand All @@ -50,6 +59,7 @@ final class DHttpClientContext implements HttpClient, SpiHttpClient {
Function<HttpException, RuntimeException> errorHandler,
RequestListener requestListener,
AuthTokenProvider authTokenProvider,
Duration backgroundRefreshDuration,
RequestIntercept intercept) {
this.httpClient = httpClient;
this.baseUrl = baseUrl;
Expand All @@ -59,6 +69,7 @@ final class DHttpClientContext implements HttpClient, SpiHttpClient {
this.errorHandler = errorHandler;
this.requestListener = requestListener;
this.authTokenProvider = authTokenProvider;
this.backgroundRefreshDuration = backgroundRefreshDuration;
this.withAuthToken = authTokenProvider != null;
this.requestIntercept = intercept;
}
Expand Down Expand Up @@ -328,14 +339,45 @@ void beforeRequest(DHttpClientRequest request) {
}

private String authToken() {
AuthToken authToken = tokenRef.get();
if (authToken == null || authToken.isExpired()) {
authToken = authTokenProvider.obtainToken(request().skipAuthToken());
tokenRef.set(authToken);
final AuthToken authToken = tokenRef.get();
if (authToken == null) {
return obtainNewAuthToken();
}
final Duration expiration = authToken.expiration();
if (expiration.isNegative()) {
return obtainNewAuthToken();
}
if (backgroundRefreshDuration != null && expiration.compareTo(backgroundRefreshDuration) < 0) {
backgroundTokenRequest();
}
return authToken.token();
}

private String obtainNewAuthToken() {
final AuthToken authToken = authTokenProvider.obtainToken(request().skipAuthToken());
tokenRef.set(authToken);
return authToken.token();
}

private void backgroundTokenRequest() {
final Instant lease = backgroundRefreshLease.get();
if (lease != null && Instant.now().isBefore(lease)) {
// a refresh is already in progress
return;
}
// other requests should not trigger a refresh for the next 10 seconds
backgroundRefreshLease.set(Instant.now().plusMillis(10_000));
BGInvoke.invoke(this::backgroundNewTokenTask);
}

private void backgroundNewTokenTask() {
try {
obtainNewAuthToken();
} catch (Exception e) {
log.log(WARNING, "Error refreshing AuthToken in background", e);
}
}

String maxResponseBody(String body) {
return body.length() > 1_000 ? body.substring(0, 1_000) + " <truncated> ..." : body;
}
Expand Down
18 changes: 13 additions & 5 deletions http-client/src/main/java/io/avaje/http/client/HttpClient.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package io.avaje.http.client;

import io.avaje.inject.BeanScope;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
Expand All @@ -8,11 +12,6 @@
import java.util.concurrent.Executor;
import java.util.function.Function;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;

import io.avaje.inject.BeanScope;

/**
* The HTTP client context that we use to build and process requests.
*
Expand Down Expand Up @@ -227,6 +226,15 @@ interface Builder {
*/
Builder authTokenProvider(AuthTokenProvider authTokenProvider);

/**
* Duration before token expiry where a background task will refresh the token. Defaults to 5 minutes.
* <p>
* Set to null to disable background token refresh.
*
* @param backgroundTokenRefresh The duration before token expiry that triggers a background refresh.
*/
Builder backgroundTokenRefresh(Duration backgroundTokenRefresh);

/**
* Set the underlying HttpClient to use.
* <p>
Expand Down
12 changes: 12 additions & 0 deletions http-client/src/main/java21/io/avaje/http/client/BGInvoke.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.avaje.http.client;

import java.util.concurrent.Executors;

final class BGInvoke {

static void invoke(Runnable task) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task);
}
}
}
12 changes: 12 additions & 0 deletions http-client/src/test/java/io/avaje/http/client/AuthTokenTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

import java.net.http.HttpResponse;
import java.time.Instant;
import java.time.temporal.ChronoUnit;

import static org.assertj.core.api.Assertions.assertThat;

public class AuthTokenTest {

Expand Down Expand Up @@ -41,6 +44,15 @@ public AuthToken obtainToken(HttpClientRequest tokenRequest) {
}
}

@Test
void expiration() {
Instant plus = Instant.now().plus(120, ChronoUnit.SECONDS);
AuthToken authToken = AuthToken.of("foo", plus);

assertThat(authToken.isExpired()).isFalse();
assertThat(authToken.expiration().toSeconds()).isBetween(118L, 120L);
}

@Disabled
@Test
void sendEmail() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.avaje.http.client;

import org.example.github.BasicClientInterface;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;
Expand All @@ -9,7 +8,7 @@

class DHttpClientContextTest {

private final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null);
private final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null, null);

@Test
void gzip_gzipDecode() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

class DHttpClientRequestTest {

final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null);
final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null, null);

@Test
void suppressLogging_listenerEvent_expect_suppressedPayloadContent() {
Expand Down