Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ pom.xml.versionsBackup
*/.run/**
.run/**
.run
.claude/
.claude
.planning
222 changes: 222 additions & 0 deletions ce/src/main/java/org/thingsboard/client/RetryingHttpClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Copyright © 2026-2026 ThingsBoard, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.client;

import lombok.extern.java.Log;

import java.io.IOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.logging.Level;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;

/**
* An {@link HttpClient} wrapper that automatically retries requests that receive a retriable
* HTTP status code (429 Too Many Requests by default) using exponential backoff with jitter.
*
* <p>The {@code Retry-After} response header is honoured when present: if it contains a
* non-negative integer, that number of seconds is used as the retry delay (capped to
* {@code maxDelayMs}).
*
* <p>After exhausting all retry attempts the final (still-retriable) response is returned to the
* caller so that the upstream code (e.g. {@code ThingsboardApi}) can throw an
* {@link ApiException} with the correct HTTP status code.
*
* <p>Obtain an instance via the static factory:
* <pre>{@code
* RetryingHttpClient client = RetryingHttpClient.wrap(HttpClient.newHttpClient(), 3, 1000L, 30_000L);
* }</pre>
*/
@Log
public class RetryingHttpClient extends HttpClient {

private final HttpClient delegate;
private final int maxRetries;
private final long initialDelayMs;
private final long maxDelayMs;
private final Random random = new Random();

private RetryingHttpClient(HttpClient delegate, int maxRetries, long initialDelayMs, long maxDelayMs) {
this.delegate = delegate;
this.maxRetries = maxRetries;
this.initialDelayMs = initialDelayMs;
this.maxDelayMs = maxDelayMs;
}

/**
* Creates a new {@code RetryingHttpClient} that wraps the given delegate.
*
* @param delegate the underlying {@link HttpClient} to delegate to
* @param maxRetries maximum number of retry attempts (not counting the initial request)
* @param initialDelayMs initial backoff delay in milliseconds
* @param maxDelayMs maximum backoff delay in milliseconds
* @return a new {@code RetryingHttpClient}
*/
public static RetryingHttpClient wrap(HttpClient delegate, int maxRetries, long initialDelayMs, long maxDelayMs) {
return new RetryingHttpClient(delegate, maxRetries, initialDelayMs, maxDelayMs);
}

/**
* Returns {@code true} if the given status code should trigger a retry.
* Override in subclasses to add additional retriable status codes.
*
* @param statusCode the HTTP response status code
* @return {@code true} for retriable status codes (429 by default)
*/
protected boolean isRetriable(int statusCode) {
return statusCode == 429;
}

@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
throws IOException, InterruptedException {
HttpResponse<T> response = delegate.send(request, responseBodyHandler);

if (!isRetriable(response.statusCode())) {
return response;
}

for (int attempt = 1; attempt <= maxRetries; attempt++) {
long delayMs = computeDelay(response, attempt);
log.log(Level.WARNING, "HTTP {0} received, retrying in {1}ms (attempt {2}/{3})",
new Object[]{response.statusCode(), delayMs, attempt, maxRetries});
Thread.sleep(delayMs);

response = delegate.send(request, responseBodyHandler);
if (!isRetriable(response.statusCode())) {
return response;
}
}

// Exhausted retries — return the last response so the caller can throw ApiException
return response;
}

@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return sendAsyncWithRetry(request, responseBodyHandler, 1);
}

@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler,
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
}

private <T> CompletableFuture<HttpResponse<T>> sendAsyncWithRetry(
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int attempt) {
return delegate.sendAsync(request, responseBodyHandler).thenCompose(response -> {
if (!isRetriable(response.statusCode()) || attempt > maxRetries) {
return CompletableFuture.completedFuture(response);
}
long delayMs = computeDelay(response, attempt);
log.log(Level.WARNING, "HTTP {0} received, retrying in {1}ms (attempt {2}/{3})",
new Object[]{response.statusCode(), delayMs, attempt, maxRetries});
Executor delayedExecutor = CompletableFuture.delayedExecutor(
delayMs, java.util.concurrent.TimeUnit.MILLISECONDS);
return CompletableFuture.supplyAsync(() -> null, delayedExecutor)
.thenCompose(ignored -> sendAsyncWithRetry(request, responseBodyHandler, attempt + 1));
});
}

/**
* Computes the delay in milliseconds before the next retry attempt.
* Honours the {@code Retry-After} header when present (integer seconds, non-negative).
* Falls back to exponential backoff with ±20% jitter.
*/
private <T> long computeDelay(HttpResponse<T> response, int attempt) {
Optional<String> retryAfter = response.headers().firstValue("Retry-After");
if (retryAfter.isPresent()) {
try {
long seconds = Long.parseLong(retryAfter.get().trim());
if (seconds >= 0) {
return Math.min(seconds * 1000L, maxDelayMs);
}
} catch (NumberFormatException ignored) {
// Not an integer — fall through to exponential backoff
}
}
// Exponential backoff: initialDelayMs * 2^(attempt-1), capped at maxDelayMs
int shift = Math.min(attempt - 1, 30); // prevent long overflow on large attempt values
long base = Math.min(initialDelayMs * (1L << shift), maxDelayMs);
// ±20% jitter
double jitter = (random.nextDouble() * 0.4) - 0.2; // range [-0.2, 0.2]
return Math.max(0, Math.round(base * (1.0 + jitter)));
}

// -------------------------------------------------------------------------
// Delegation of all remaining abstract HttpClient methods
// -------------------------------------------------------------------------

@Override
public Optional<CookieHandler> cookieHandler() {
return delegate.cookieHandler();
}

@Override
public Optional<Duration> connectTimeout() {
return delegate.connectTimeout();
}

@Override
public Redirect followRedirects() {
return delegate.followRedirects();
}

@Override
public Optional<ProxySelector> proxy() {
return delegate.proxy();
}

@Override
public SSLContext sslContext() {
return delegate.sslContext();
}

@Override
public SSLParameters sslParameters() {
return delegate.sslParameters();
}

@Override
public Optional<Authenticator> authenticator() {
return delegate.authenticator();
}

@Override
public Version version() {
return delegate.version();
}

@Override
public Optional<Executor> executor() {
return delegate.executor();
}

}
94 changes: 87 additions & 7 deletions ce/src/main/java/org/thingsboard/client/ThingsboardClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
* <li>Automatic token refresh before expiry</li>
* <li>Automatic re-login when the refresh token expires</li>
* <li>Client-server clock skew compensation</li>
* <li>Automatic retry on HTTP 429 (Too Many Requests) with exponential backoff</li>
* </ul>
*
* <pre>{@code
Expand All @@ -56,6 +57,22 @@
* .apiKey("your-api-key")
* .build();
*
* // Tuning rate-limit retry behaviour (retry is enabled by default)
* ThingsboardClient client = ThingsboardClient.builder()
* .url("http://localhost:8080")
* .credentials("tenant@thingsboard.org", "password")
* .maxRetries(5)
* .initialRetryDelayMs(500L)
* .maxRetryDelayMs(60_000L)
* .build();
*
* // Disable rate-limit retry
* ThingsboardClient client = ThingsboardClient.builder()
* .url("http://localhost:8080")
* .credentials("tenant@thingsboard.org", "password")
* .retryOnRateLimit(false)
* .build();
*
* // All generated API methods are available directly
* Device device = client.getDeviceById(deviceId);
* }</pre>
Expand Down Expand Up @@ -96,6 +113,10 @@ public static class Builder {
private String username;
private String password;
private String apiKey;
private boolean retryOnRateLimit = true;
private int maxRetries = 3;
private long initialRetryDelayMs = 1000L;
private long maxRetryDelayMs = 30_000L;

private Builder() {}

Expand All @@ -115,23 +136,82 @@ public Builder apiKey(String apiKey) {
return this;
}

/**
* Enables or disables automatic retry on rate-limit (HTTP 429) responses.
* Enabled by default.
*/
public Builder retryOnRateLimit(boolean retryOnRateLimit) {
this.retryOnRateLimit = retryOnRateLimit;
return this;
}

/**
* Maximum number of retry attempts after an HTTP 429 response.
* Default: 3.
*/
public Builder maxRetries(int maxRetries) {
this.maxRetries = maxRetries;
return this;
}

/**
* Initial backoff delay in milliseconds for the first retry.
* Subsequent retries use exponential backoff with ±20% jitter.
* Default: 1000 ms.
*/
public Builder initialRetryDelayMs(long initialRetryDelayMs) {
this.initialRetryDelayMs = initialRetryDelayMs;
return this;
}

/**
* Maximum backoff delay in milliseconds. The computed delay is capped at this value.
* Default: 30 000 ms.
*/
public Builder maxRetryDelayMs(long maxRetryDelayMs) {
this.maxRetryDelayMs = maxRetryDelayMs;
return this;
}

public ThingsboardClient build() throws ApiException {
if (url == null) {
throw new IllegalArgumentException("url is required");
}
if (apiKey != null) {
return new ThingsboardClient(new AuthManager(url, AuthType.API_KEY, apiKey));
}
AuthManager auth = new AuthManager(url, AuthType.JWT, null);
ApiClient apiClient = retryOnRateLimit
? new RetryableApiClient(maxRetries, initialRetryDelayMs, maxRetryDelayMs)
: new ApiClient();
AuthType authType = apiKey != null ? AuthType.API_KEY : AuthType.JWT;
AuthManager auth = new AuthManager(url, authType, apiKey, apiClient);
ThingsboardClient client = new ThingsboardClient(auth);
if (username != null) {
if (authType == AuthType.JWT && username != null) {
client.login(username, password);
}
return client;
}

}

/**
* ApiClient subclass that wraps every built HttpClient with retry-on-429 logic.
*/
private static class RetryableApiClient extends ApiClient {

private final int maxRetries;
private final long initialDelayMs;
private final long maxDelayMs;

RetryableApiClient(int maxRetries, long initialDelayMs, long maxDelayMs) {
this.maxRetries = maxRetries;
this.initialDelayMs = initialDelayMs;
this.maxDelayMs = maxDelayMs;
}

@Override
public HttpClient getHttpClient() {
return RetryingHttpClient.wrap(super.getHttpClient(), maxRetries, initialDelayMs, maxDelayMs);
}
}

/**
* Authenticates with ThingsBoard using username and password.
* The JWT token is automatically applied to all subsequent API calls.
Expand Down Expand Up @@ -178,9 +258,9 @@ private record TokenInfo(String token, String refreshToken, long tokenExpTs,
private volatile String password;
private volatile boolean refreshing;

AuthManager(String url, AuthType authType, String apiKey) {
AuthManager(String url, AuthType authType, String apiKey, ApiClient apiClient) {
this.authType = authType;
this.apiClient = new ApiClient();
this.apiClient = apiClient;
apiClient.updateBaseUri(url);
this.baseUrl = apiClient.getBaseUri();
this.httpClient = apiClient.getHttpClient();
Expand Down
Loading
Loading