Enhanced HTTP client extension for Java HttpClient with built-in retry logic and JWT token refresh capabilities.
Add the dependency to your build.gradle:
dependencies {
implementation 'io.seqera:lib-httpx:2.6.0'
}Note: Check the project's VERSION file for the current version number.
- Retry Logic: Automatic retry for configurable HTTP status codes (default: 429, 500, 502, 503, 504)
- Authentication Support: Built-in support for JWT Bearer tokens and HTTP Basic authentication
- JWT Token Refresh: Automatic JWT token refresh when receiving 401 Unauthorized responses with configurable cookie policies
- Multi-Session Auth: Support for multiple concurrent authentication sessions via the
HxAuthinterface - Custom Token Storage: Pluggable token store interface for distributed deployments (Redis, database, etc.)
- WWW-Authenticate Support: Automatic handling of HTTP authentication challenges (Basic and Bearer schemes)
- Anonymous Authentication: Fallback to anonymous authentication when credentials aren't provided
- Proxy Support: Authenticated forward-proxy support via
.proxy(...)/.authenticator(...), or anHxProxyConfigvalue applied with.withProxyConfig(...) - Configurable: Customizable retry policies, timeouts, token refresh, authentication settings, and cookie policies
- Generic Integration: Compatible with any
Retryable.Configfor flexible retry configuration - Thread-safe: Safe for concurrent use with atomic token refresh coordination
- Async Support: Support for both synchronous and asynchronous requests
// Create with default configuration
HxClient client = HxClient.newHxClient();
// Create with custom configuration using new builder pattern
HxClient client = HxClient.newBuilder()
.maxAttempts(3)
.retryStatusCodes(Set.of(429, 503))
.build();
// Make HTTP requests
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());// Using new builder pattern (recommended)
HxClient client = HxClient.newBuilder()
.bearerToken("your-jwt-token")
.refreshToken("your-refresh-token")
.refreshTokenUrl("https://api.example.com/oauth/token")
.refreshCookiePolicy(CookiePolicy.ACCEPT_ALL)
.build();
// Using HxConfig directly
HxConfig config = HxConfig.newBuilder()
.bearerToken("your-jwt-token")
.refreshToken("your-refresh-token")
.refreshTokenUrl("https://api.example.com/oauth/token")
.build();
HxClient client = HxClient.newBuilder().config(config).build();// Using HxClient builder (recommended)
HxClient client = HxClient.newBuilder()
.basicAuth("your-username", "your-password")
.build();
// Or using a pre-formatted token
HxClient client = HxClient.newBuilder()
.basicAuth("username:password")
.build();
// Using HxConfig directly
HxConfig config = HxConfig.newBuilder()
.basicAuth("your-username", "your-password")
.build();
HxClient client = HxClient.newBuilder().config(config).build();Authentication Constraints:
- Cannot configure both JWT and Basic authentication simultaneously
- Choose one authentication method - the configuration builder will reject conflicting setups
- Use JWT tokens for modern APIs, Basic auth for legacy systems
The client can automatically handle HTTP authentication challenges (401 responses with WWW-Authenticate headers):
// Enable WWW-Authenticate handling with anonymous authentication fallback
HxConfig config = HxConfig.newBuilder()
.wwwAuthentication(true)
.build();
HxClient client = HxClient.newBuilder().config(config).build();
// With custom authentication callback
HxConfig config = HxConfig.newBuilder()
.wwwAuthentication(true)
.wwwAuthenticationCallback((scheme, realm) -> {
if (scheme == AuthenticationScheme.BASIC) {
return Base64.getEncoder().encodeToString("user:pass".getBytes());
} else if (scheme == AuthenticationScheme.BEARER) {
return "your-bearer-token";
}
return null; // Fall back to anonymous auth
})
.build();
HxClient client = HxClient.newBuilder().config(config).build();Supported Authentication Schemes:
- Basic: Supports both credential-based and anonymous authentication
- Bearer: Attempts to retrieve anonymous tokens from OAuth2 endpoints when no credentials are provided
Anonymous Authentication:
- Basic: Uses empty credentials (base64 encoded
:) - Bearer: Attempts to get anonymous tokens from the authentication endpoint using OAuth2 flow
For applications managing multiple users or authentication contexts, use the HxAuth interface for per-request authentication with automatic token refresh.
HxAuth represents an authentication session. The id() uniquely identifies the session and must remain the same for the entire lifecycle of the auth — even as the access token and refresh token change over time due to token refresh operations. This allows the client to track and coordinate refreshes for the same logical session.
| Method | Description |
|---|---|
id() |
Stable identifier, constant for the auth lifecycle |
accessToken() |
The current JWT access token |
refreshToken() |
The refresh token (nullable) |
refreshUrl() |
Per-auth refresh URL override (nullable, falls back to global config) |
withAccessToken(token) |
Returns an HxAuth with the updated access token (same id) |
withRefreshToken(refresh) |
Returns an HxAuth with the updated refresh token (same id) |
Implementations must ensure id() remains constant — withAccessToken and withRefreshToken return new instances preserving the same id.
Note: The built-in DefaultHxAuth derives its id() from a 32-bit hash of the access token and refresh URL. This is suitable for simple scenarios with a small number of auth sessions. For production multi-tenant deployments, provide a custom HxAuth implementation with explicit, collision-free identifiers (e.g., user ID or tenant ID) to prevent token store collisions. Alternatively, a custom implementation could use a stronger hash such as SHA-256 or SipHash-2-4 (64-bit) for better collision resistance when explicit IDs are not practical.
Implement HxAuth to define the identity strategy and token lifecycle for your application (e.g., key by user ID or tenant). Each HxAuth can also carry its own refreshUrl, allowing different auth sessions to refresh against different endpoints. If refreshUrl() returns null, the global refreshTokenUrl from HxConfig is used as fallback.
public class TenantAuth implements HxAuth {
private final String tenantId;
private final String accessToken;
private final String refreshToken;
public TenantAuth(String tenantId, String accessToken, String refreshToken) {
this.tenantId = tenantId;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
@Override public String id() { return tenantId; } // stable across refreshes
@Override public String accessToken() { return accessToken; }
@Override public String refreshToken() { return refreshToken; }
@Override public String refreshUrl() { return null; } // use global config
@Override
public HxAuth withAccessToken(String token) {
return new TenantAuth(tenantId, token, refreshToken);
}
@Override
public HxAuth withRefreshToken(String refresh) {
return new TenantAuth(tenantId, accessToken, refresh);
}
}
// Create a shared client
HxClient client = HxClient.newBuilder().build();
// Create auth for each user/tenant session
HxAuth user1Auth = new TenantAuth("tenant-1", "user1.jwt.token", "user1-refresh-token");
HxAuth user2Auth = new TenantAuth("tenant-2", "user2.jwt.token", "user2-refresh-token");
// Make requests with per-user authentication
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.GET()
.build();
HttpResponse<String> response1 = client.send(request, user1Auth, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response2 = client.send(request, user2Auth, HttpResponse.BodyHandlers.ofString());
// Async requests also supported
CompletableFuture<HttpResponse<String>> future = client.sendAsync(request, user1Auth, HttpResponse.BodyHandlers.ofString());By default, tokens are stored in an in-memory ConcurrentHashMap. For distributed deployments, provide a custom HxTokenStore:
// Implement custom store (e.g., Redis-backed)
public class RedisTokenStore implements HxTokenStore {
@Override
public HxAuth get(String key) { /* Redis GET */ }
@Override
public void put(String key, HxAuth auth) { /* Redis SET */ }
@Override
public HxAuth putIfAbsent(String key, HxAuth auth) {
// Use Redis SETNX for atomic check-and-set
// Return existing value if present, otherwise store and return auth
}
@Override
public HxAuth remove(String key) { /* Redis DEL */ }
}
// Use custom store
HxTokenStore customStore = new RedisTokenStore();
HxClient client = HxClient.newBuilder()
.tokenStore(customStore)
.build();// Using HxClient builder (recommended)
HxClient client = HxClient.newBuilder()
.maxAttempts(5)
.retryDelay(Duration.ofSeconds(1))
.build();
// Using HxConfig builder for advanced configuration
HxConfig config = HxConfig.newBuilder()
.maxAttempts(5)
.delay(Duration.ofSeconds(1))
.maxDelay(Duration.ofMinutes(2))
.jitter(0.5)
.multiplier(2.0)
.retryStatusCodes(Set.of(429, 500, 502, 503, 504))
.build();
HxClient client = HxClient.newBuilder().config(config).build();config(...) copies every setting of the given configuration, so nothing it carries is lost. Builder
methods called after it override the copied values; those called before it are discarded. To derive a
variant of an existing configuration, start from HxConfig.newBuilder(existing):
HxConfig relaxed = HxConfig.newBuilder(config)
.maxAttempts(2)
.build();Alternatively, subclass HxClient and override shouldRetryOnException(Throwable) to decide
independently of the configuration, or shouldRetryOnException(HttpRequest, Throwable) to decide
per request — see Deciding per request.
By default a request is retried when it fails with an IOException — connection reset, connection
refused, HttpConnectTimeoutException — except an HttpTimeoutException raised after the request
was sent, which is not retried.
That exception to the rule matters because HttpTimeoutException extends IOException: a plain
"retry any IOException" rule retries request timeouts, which makes the real bound on a call
maxAttempts × timeout instead of timeout — with the default of 5 attempts, a 2 minute timeout is a
10 minute worst case. A timeout that elapses after the request was sent is also ambiguous: the server
may have received the request and be processing it, so re-sending can duplicate a non-idempotent
operation. HttpConnectTimeoutException is a subclass raised before the request is sent, so it stays
retryable.
This matches the defaults of comparable clients — OkHttp recovers from a socket timeout only while the
request has not been sent, and Apache HttpClient 5 treats the whole InterruptedIOException family as
non-retriable. The exclusion here is narrower than Apache's: it is scoped to the HttpTimeoutException
that java.net.http raises for a request timeout, so a SocketTimeoutException — an
InterruptedIOException the JDK client does not normally surface — stays retryable.
The rule is available as HxConfig.defaultRetryCondition(Throwable), so a caller can extend it instead
of restating it:
HxConfig config = HxConfig.newBuilder()
.retryCondition(t -> HxConfig.defaultRetryCondition(t) || t instanceof MyTransientException)
.build();To retry request timeouts anyway, say so explicitly:
HxConfig config = HxConfig.newBuilder()
.retryCondition(t -> t instanceof IOException) // includes HttpTimeoutException
.build();retryCondition is a Predicate<Throwable>, so it answers the same way for every request. That is
not enough when the caller has non-idempotent endpoints, because a retry is safe when the request is
idempotent or when it provably never reached the server — and only the first of those is
visible in the request. A bare HttpTimeoutException is the case that forces the distinction: it says
the response never arrived, not that the request never did, so the same exception warrants opposite
answers for a GET and a POST.
Override shouldRetryOnException(HttpRequest, Throwable) — the hook the retry policy consults — to
answer per request:
class MyClient extends HxClient {
MyClient(HttpClient httpClient, HxConfig config) { super(httpClient, config); }
@Override
protected boolean shouldRetryOnException(HttpRequest request, Throwable throwable) {
return "POST".equals(request.method())
// a POST may already have been committed: re-send only what never arrived
? throwable instanceof ConnectException
|| throwable instanceof HttpConnectTimeoutException
|| throwable instanceof UnknownHostException
// a GET can always be re-sent, request timeouts included
: throwable instanceof IOException;
}
}Its default implementation ignores the request and delegates to shouldRetryOnException(Throwable),
so an existing override of that method — or a configured retryCondition — keeps working unchanged.
The response side needs no new hook: shouldRetryOnResponse(HttpResponse) can already reach the
request through HttpResponse.request(), so a subclass can vary the retryable status codes by method
in the same way. That matters for a status like 502, which a load balancer returns after forwarding
the request, versus 503, which it returns when it had no target to forward to.
The two are not quite mirror images, though: shouldRetryOnException receives the request as the caller
built it, before any Authorization header is applied, whereas HttpResponse.request() is the request
as actually sent — post-auth and post-redirect. Decide on the method and URI, which are the same in
both, rather than on headers that are only present on one side.
To reuse the full builder wiring — transport, token refresh, proxy — build a client and wrap it:
HxClient base = HxClient.newBuilder().bearerToken(token).maxAttempts(5).build();
HxClient client = new MyClient(base.getHttpClient(), base.getConfig());Note that this recipe does not carry over a custom tokenStore: the two-argument constructor builds a
fresh default token manager, so a wrapper around a base client configured with .tokenStore(...) gets
private in-memory token state instead of the shared one. Pass the store to the three-argument
HxClient(HttpClient, HxConfig, HxTokenStore) constructor when the subclass needs it.
// Use any existing Retryable.Config with HxClient builder
Retryable.Config retryConfig = Retryable.ofDefaults().config();
HxClient client = HxClient.newBuilder()
.retryConfig(retryConfig)
.bearerToken("your-jwt-token")
.build();
// Or combine with HTTP-specific settings using HxConfig
HxConfig config = HxConfig.newBuilder()
.retryConfig(retryConfig)
.bearerToken("your-jwt-token")
.retryStatusCodes(Set.of(429, 503))
.build();
HxClient client = HxClient.newBuilder().config(config).build();Proxy settings are configured explicitly through the builder, mirroring java.net.http.HttpClient.Builder
— HxClient never reads proxy settings from the environment or system properties on its own. The
.proxy(...) and .authenticator(...) methods map directly onto their HttpClient.Builder counterparts,
so an HxClient can act as a drop-in replacement:
HxClient client = HxClient.newBuilder()
.proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 8080)))
.authenticator(myAuthenticator)
.build();For callers that resolve proxy settings themselves (host, port, optional credentials per protocol and
NO_PROXY entries), HxProxyConfig bundles them into a single value and produces the matching selector
and a proxy-only authenticator (credentials are supplied only for proxy authentication challenges, never
to origin servers). Apply it in one call with .withProxyConfig(...):
HxProxyConfig proxy = HxProxyConfig.newBuilder()
.httpsProxy("proxy.example.com", 8080, "user", "pass")
.noProxy(List.of("internal.example.com"))
.build();
HxClient client = HxClient.newBuilder()
.withProxyConfig(proxy) // no-op if proxy is null
.build();When the client is built from the builder (no explicit httpClient(...)), the internal HTTP clients used
for JWT token refresh and anonymous Bearer token retrieval inherit the same proxy settings. An HttpClient
supplied via httpClient(...) is always used verbatim: proxy settings are neither applied to it nor
propagated to those internal clients.
Important notes:
java.net.http.HttpClientignoresAuthenticator.setDefault(...)— proxy credentials only work when the authenticator is passed to the client builder, which is what.authenticator(...)does.- The JDK disables the Basic scheme for HTTPS CONNECT tunnelling by default
(
jdk.http.auth.tunneling.disabledSchemes=Basicin$JAVA_HOME/conf/net.properties). For Basic proxy authentication of HTTPS traffic, run the JVM with-Djdk.http.auth.tunneling.disabledSchemes=.
Configure cookie handling for JWT token refresh operations:
HxClient client = HxClient.newBuilder()
.bearerToken("your-jwt-token")
.refreshToken("your-refresh-token")
.refreshTokenUrl("https://api.example.com/oauth/token")
.refreshCookiePolicy(CookiePolicy.ACCEPT_ALL)
.build();| Option | Description | Default |
|---|---|---|
maxAttempts |
Maximum number of retry attempts | 5 |
delay |
Initial delay between retries | 500ms |
maxDelay |
Maximum delay between retries | 30s |
jitter |
Random jitter factor (0-1) | 0.25 |
multiplier |
Exponential backoff multiplier | 2.0 |
retryStatusCodes |
HTTP status codes to retry | [429, 500, 502, 503, 504] |
jwtToken |
JWT Bearer token for authentication | null |
refreshToken |
Refresh token for JWT renewal | null |
refreshTokenUrl |
URL for token refresh requests | null |
tokenRefreshTimeout |
Timeout for token refresh requests | 30s |
tokenStore |
Custom token store for multi-session authentication | HxMapTokenStore |
basicAuthToken |
Token for HTTP Basic authentication (username:password format) | null |
refreshCookiePolicy |
Cookie policy for JWT token refresh operations | null |
wwwAuthentication |
Enable WWW-Authenticate challenge handling | false |
wwwAuthenticationCallback |
Callback for providing authentication credentials | null |
proxy |
Proxy selector for routing requests through a forward proxy | null |
authenticator |
Authenticator supplying credentials to an authenticating proxy | null |
HxClient: Main HTTP client with retry, JWT, and WWW-Authenticate functionalityHxConfig: Configuration builder with all available optionsHxProxyConfig: Forward-proxy settings (selector + proxy-only authenticator) assembled from explicit values via its builderHxAuth: Interface for authentication credentials with stable identity across refreshesHxTokenStore: Interface for pluggable token storage (default: in-memory ConcurrentHashMap)HxTokenManager: Thread-safe JWT token lifecycle management with multi-session supportAuthenticationChallenge: Represents a parsed WWW-Authenticate challengeAuthenticationScheme: Enumeration of supported authentication schemes (Basic, Bearer)AuthenticationCallback: Interface for providing authentication credentialsWwwAuthenticateParser: RFC 7235 compliant parser for WWW-Authenticate headers
- Inherit HTTP settings for token refresh client: The internal
HttpClientused for token refresh currently hardcodesHTTP/1.1andRedirect.NORMAL. It should inherit HTTP version, redirect policy, and other settings from the main client configuration. - Reuse refresh
HttpClientinstances: A newHttpClient(with its own thread pool) is created for each token refresh to prevent cross-user cookie leakage in multi-tenant scenarios. Under high concurrency with many auth sessions refreshing simultaneously, this could cause thread and file descriptor pressure. A per-auth-key cached client or a shared client with per-request cookie isolation could reduce this overhead.
lib-retry: Provides the underlying retry mechanism using Failsafedev.failsafe:failsafe: Core retry and circuit breaker librarycom.google.code.gson:gson: For JSON parsing in WWW-Authenticate anonymous token retrieval