Skip to content

Commit 940857d

Browse files
authored
Support background token refresh when using AuthTokenProvider (#592)
Supports refreshing an AuthToken in the background before it expires. By default, the background refresh is triggered 5 minutes before the expiration.
1 parent 7122e34 commit 940857d

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() {

0 commit comments

Comments
 (0)