Skip to content

Commit e840e52

Browse files
committed
Support background token refresh when using AuthTokenProvider
Supports refreshing an AuthToken in the background before it expires. By default, the background refresh is triggered 5 minutes before the expiration.
1 parent ff64dcc commit e840e52

File tree

11 files changed

+187
-13
lines changed

11 files changed

+187
-13
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
strategy:
1111
fail-fast: false
1212
matrix:
13-
java_version: [17, 21]
13+
java_version: [21]
1414
os: [ubuntu-latest]
1515

1616
steps:

http-client/pom.xml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@
9494

9595
<build>
9696
<plugins>
97+
98+
<plugin> <!-- Multi-Release with 21 -->
99+
<groupId>org.apache.maven.plugins</groupId>
100+
<artifactId>maven-jar-plugin</artifactId>
101+
<version>3.3.0</version>
102+
<configuration>
103+
<archive>
104+
<manifestEntries>
105+
<Multi-Release>true</Multi-Release>
106+
</manifestEntries>
107+
</archive>
108+
</configuration>
109+
</plugin>
110+
97111
<plugin>
98112
<groupId>org.apache.maven.plugins</groupId>
99113
<artifactId>maven-compiler-plugin</artifactId>
@@ -111,9 +125,51 @@
111125
</annotationProcessorPaths>
112126
</configuration>
113127
</execution>
128+
<!-- Compile for base version Java 11 -->
129+
<execution>
130+
<id>base</id>
131+
<goals>
132+
<goal>compile</goal>
133+
</goals>
134+
<configuration>
135+
<release>11</release>
136+
<compileSourceRoots>
137+
<compileSourceRoot>${project.basedir}/src/main/java</compileSourceRoot>
138+
</compileSourceRoots>
139+
</configuration>
140+
</execution>
141+
<!-- Compile for Java 21 -->
142+
<execution>
143+
<id>java21</id>
144+
<goals>
145+
<goal>compile</goal>
146+
</goals>
147+
<configuration>
148+
<release>21</release>
149+
<compileSourceRoots>
150+
<compileSourceRoot>${project.basedir}/src/main/java21</compileSourceRoot>
151+
</compileSourceRoots>
152+
<outputDirectory>${project.build.outputDirectory}/META-INF/versions/21</outputDirectory>
153+
</configuration>
154+
</execution>
114155
</executions>
115156
</plugin>
116157

158+
<!-- generated by avaje inject -->
159+
<plugin>
160+
<groupId>io.avaje</groupId>
161+
<artifactId>avaje-inject-maven-plugin</artifactId>
162+
<version>11.4</version>
163+
<executions>
164+
<execution>
165+
<?m2e execute?>
166+
<phase>process-sources</phase>
167+
<goals>
168+
<goal>provides</goal>
169+
</goals>
170+
</execution>
171+
</executions>
172+
</plugin>
117173
</plugins>
118174
</build>
119175
</project>

http-client/src/main/java/io/avaje/http/client/AuthToken.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.avaje.http.client;
22

3+
import java.time.Duration;
34
import java.time.Instant;
45

56
/**
@@ -19,6 +20,11 @@ public interface AuthToken {
1920
*/
2021
boolean isExpired();
2122

23+
/**
24+
* Return the duration until expiry.
25+
*/
26+
Duration expiration();
27+
2228
/**
2329
* Create an return a AuthToken with the given token and time it is valid until.
2430
*/
@@ -51,5 +57,10 @@ public String token() {
5157
public boolean isExpired() {
5258
return Instant.now().isAfter(validUntil);
5359
}
60+
61+
@Override
62+
public Duration expiration() {
63+
return Duration.between(Instant.now(), validUntil);
64+
}
5465
}
5566
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.avaje.http.client;
2+
3+
import java.util.concurrent.ExecutorService;
4+
import java.util.concurrent.Executors;
5+
import java.util.concurrent.TimeUnit;
6+
7+
final class BGInvoke {
8+
9+
static void invoke(Runnable task) {
10+
ExecutorService executor = Executors.newSingleThreadExecutor();
11+
try {
12+
executor.submit(task);
13+
} finally {
14+
executor.shutdown();
15+
try {
16+
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
17+
executor.shutdownNow();
18+
}
19+
} catch (InterruptedException e) {
20+
executor.shutdownNow();
21+
Thread.currentThread().interrupt();
22+
}
23+
}
24+
}
25+
}

http-client/src/main/java/io/avaje/http/client/DHttpClientBuilder.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.net.CookieManager;
1212
import java.net.ProxySelector;
1313
import java.time.Duration;
14+
import java.time.temporal.ChronoUnit;
1415
import java.util.ArrayList;
1516
import java.util.Collections;
1617
import java.util.List;
@@ -36,6 +37,7 @@ final class DHttpClientBuilder implements HttpClient.Builder, HttpClient.Builder
3637
private RetryHandler retryHandler;
3738
private Function<HttpException, RuntimeException> errorHandler;
3839
private AuthTokenProvider authTokenProvider;
40+
private Duration backgroundRefreshDuration = Duration.of(5, ChronoUnit.MINUTES);
3941

4042
private CookieHandler cookieHandler = new CookieManager();
4143
private java.net.http.HttpClient.Redirect redirect = java.net.http.HttpClient.Redirect.NORMAL;
@@ -185,6 +187,7 @@ private DHttpClientContext buildClient() {
185187
errorHandler,
186188
buildListener(),
187189
authTokenProvider,
190+
backgroundRefreshDuration,
188191
buildIntercept());
189192
}
190193

@@ -257,6 +260,12 @@ public HttpClient.Builder authTokenProvider(AuthTokenProvider authTokenProvider)
257260
return this;
258261
}
259262

263+
@Override
264+
public HttpClient.Builder backgroundTokenRefresh(Duration backgroundRefreshDuration) {
265+
this.backgroundRefreshDuration = backgroundRefreshDuration;
266+
return this;
267+
}
268+
260269
@Override
261270
public HttpClient.Builder cookieHandler(CookieHandler cookieHandler) {
262271
this.cookieHandler = cookieHandler;

http-client/src/main/java/io/avaje/http/client/DHttpClientContext.java

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package io.avaje.http.client;
22

3+
import io.avaje.applog.AppLog;
4+
35
import java.lang.invoke.MethodHandles;
46
import java.lang.invoke.MethodType;
57
import java.lang.reflect.Type;
68
import java.net.http.HttpHeaders;
79
import java.net.http.HttpRequest;
810
import java.net.http.HttpResponse;
911
import java.time.Duration;
12+
import java.time.Instant;
1013
import java.util.Collections;
1114
import java.util.List;
1215
import java.util.Map;
@@ -18,8 +21,12 @@
1821
import java.util.stream.Collectors;
1922
import java.util.stream.Stream;
2023

24+
import static java.lang.System.Logger.Level.WARNING;
25+
2126
final class DHttpClientContext implements HttpClient, SpiHttpClient {
2227

28+
private static final System.Logger log = AppLog.getLogger("io.avaje.http.client");
29+
2330
static final String AUTHORIZATION = "Authorization";
2431
private static final String BEARER = "Bearer ";
2532

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

3746
private final LongAdder metricResTotal = new LongAdder();
3847
private final LongAdder metricResError = new LongAdder();
@@ -50,6 +59,7 @@ final class DHttpClientContext implements HttpClient, SpiHttpClient {
5059
Function<HttpException, RuntimeException> errorHandler,
5160
RequestListener requestListener,
5261
AuthTokenProvider authTokenProvider,
62+
Duration backgroundRefreshDuration,
5363
RequestIntercept intercept) {
5464
this.httpClient = httpClient;
5565
this.baseUrl = baseUrl;
@@ -59,6 +69,7 @@ final class DHttpClientContext implements HttpClient, SpiHttpClient {
5969
this.errorHandler = errorHandler;
6070
this.requestListener = requestListener;
6171
this.authTokenProvider = authTokenProvider;
72+
this.backgroundRefreshDuration = backgroundRefreshDuration;
6273
this.withAuthToken = authTokenProvider != null;
6374
this.requestIntercept = intercept;
6475
}
@@ -328,14 +339,45 @@ void beforeRequest(DHttpClientRequest request) {
328339
}
329340

330341
private String authToken() {
331-
AuthToken authToken = tokenRef.get();
332-
if (authToken == null || authToken.isExpired()) {
333-
authToken = authTokenProvider.obtainToken(request().skipAuthToken());
334-
tokenRef.set(authToken);
342+
final AuthToken authToken = tokenRef.get();
343+
if (authToken == null) {
344+
return obtainNewAuthToken();
345+
}
346+
final Duration expiration = authToken.expiration();
347+
if (expiration.isNegative()) {
348+
return obtainNewAuthToken();
335349
}
350+
if (backgroundRefreshDuration != null && expiration.compareTo(backgroundRefreshDuration) < 0) {
351+
backgroundTokenRequest();
352+
}
353+
return authToken.token();
354+
}
355+
356+
private String obtainNewAuthToken() {
357+
final AuthToken authToken = authTokenProvider.obtainToken(request().skipAuthToken());
358+
tokenRef.set(authToken);
336359
return authToken.token();
337360
}
338361

362+
private void backgroundTokenRequest() {
363+
final Instant lease = backgroundRefreshLease.get();
364+
if (lease != null && Instant.now().isBefore(lease)) {
365+
// a refresh is already in progress
366+
return;
367+
}
368+
// other requests should not trigger a refresh for the next 10 seconds
369+
backgroundRefreshLease.set(Instant.now().plusMillis(10_000));
370+
BGInvoke.invoke(this::backgroundNewTokenTask);
371+
}
372+
373+
private void backgroundNewTokenTask() {
374+
try {
375+
obtainNewAuthToken();
376+
} catch (Exception e) {
377+
log.log(WARNING, "Error refreshing AuthToken in background", e);
378+
}
379+
}
380+
339381
String maxResponseBody(String body) {
340382
return body.length() > 1_000 ? body.substring(0, 1_000) + " <truncated> ..." : body;
341383
}

http-client/src/main/java/io/avaje/http/client/HttpClient.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package io.avaje.http.client;
22

3+
import io.avaje.inject.BeanScope;
4+
5+
import javax.net.ssl.SSLContext;
6+
import javax.net.ssl.SSLParameters;
37
import java.net.Authenticator;
48
import java.net.CookieHandler;
59
import java.net.ProxySelector;
@@ -8,11 +12,6 @@
812
import java.util.concurrent.Executor;
913
import java.util.function.Function;
1014

11-
import javax.net.ssl.SSLContext;
12-
import javax.net.ssl.SSLParameters;
13-
14-
import io.avaje.inject.BeanScope;
15-
1615
/**
1716
* The HTTP client context that we use to build and process requests.
1817
*
@@ -227,6 +226,15 @@ interface Builder {
227226
*/
228227
Builder authTokenProvider(AuthTokenProvider authTokenProvider);
229228

229+
/**
230+
* Duration before token expiry where a background task will refresh the token. Defaults to 5 minutes.
231+
* <p>
232+
* Set to null to disable background token refresh.
233+
*
234+
* @param backgroundTokenRefresh The duration before token expiry that triggers a background refresh.
235+
*/
236+
Builder backgroundTokenRefresh(Duration backgroundTokenRefresh);
237+
230238
/**
231239
* Set the underlying HttpClient to use.
232240
* <p>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.avaje.http.client;
2+
3+
import java.util.concurrent.Executors;
4+
5+
final class BGInvoke {
6+
7+
static void invoke(Runnable task) {
8+
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
9+
executor.submit(task);
10+
}
11+
}
12+
}

http-client/src/test/java/io/avaje/http/client/AuthTokenTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import java.net.http.HttpResponse;
1111
import java.time.Instant;
12+
import java.time.temporal.ChronoUnit;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
1215

1316
public class AuthTokenTest {
1417

@@ -41,6 +44,15 @@ public AuthToken obtainToken(HttpClientRequest tokenRequest) {
4144
}
4245
}
4346

47+
@Test
48+
void expiration() {
49+
Instant plus = Instant.now().plus(120, ChronoUnit.SECONDS);
50+
AuthToken authToken = AuthToken.of("foo", plus);
51+
52+
assertThat(authToken.isExpired()).isFalse();
53+
assertThat(authToken.expiration().toSeconds()).isBetween(118L, 120L);
54+
}
55+
4456
@Disabled
4557
@Test
4658
void sendEmail() {

http-client/src/test/java/io/avaje/http/client/DHttpClientContextTest.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.avaje.http.client;
22

3-
import org.example.github.BasicClientInterface;
43
import org.junit.jupiter.api.Test;
54

65
import java.nio.charset.StandardCharsets;
@@ -9,7 +8,7 @@
98

109
class DHttpClientContextTest {
1110

12-
private final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null);
11+
private final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null, null);
1312

1413
@Test
1514
void gzip_gzipDecode() {

http-client/src/test/java/io/avaje/http/client/DHttpClientRequestTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
class DHttpClientRequestTest {
1212

13-
final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null);
13+
final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null, null, null);
1414

1515
@Test
1616
void suppressLogging_listenerEvent_expect_suppressedPayloadContent() {

0 commit comments

Comments
 (0)