Skip to content

Commit 74fb34a

Browse files
Retries on rate limits (#16)
* Update .gitignore * feat(quick-1): add RetryingHttpClient with exponential backoff and Retry-After support - Wraps a delegate HttpClient and retries 429 responses up to maxRetries times - Honours Retry-After header (integer seconds) when present - Falls back to exponential backoff with ±20% jitter - Both send() and sendAsync() implement retry logic - isRetriable() is protected for subclass extensibility * feat(quick-1): wire RetryingHttpClient into ThingsboardClient.Builder - Add retryEnabled, maxRetries, initialRetryDelayMs, maxRetryDelayMs fields to Builder - installRetryingClient() replaces both auth.httpClient and auth.apiClient.builder - Auth refresh/re-login calls (AuthManager.httpClient) now benefit from retry - ThingsboardApi HTTP calls get a RetryingHttpClient via the replaced ApiClient builder - Make AuthManager.httpClient non-final and package-private to allow replacement - Update class Javadoc with retry examples and new Builder methods * test(quick-1): add unit tests for RetryingHttpClient and copy updated common sources to CE - 5 unit tests covering: no-retry on 200, retry then success, retry exhaustion, Retry-After header respect, and isRetriable correctness - Copy updated RetryingHttpClient and ThingsboardClient from common/ into ce/ (mirrors what generate-client.sh does; common/ is the source of truth) * Refactor rate-limit retry: fix overflow, rename API, simplify wiring - Fix long overflow in exponential backoff by clamping shift to 30 - Rename retryEnabled -> retryOnRateLimit for clarity - Replace anonymous HttpClient.Builder hack with RetryableApiClient subclass that overrides getHttpClient(), eliminating 60 lines - Restore AuthManager.httpClient to private final - Unify Builder.build() into a single linear flow * Address PR review: close response body on retry, clamp jitter, move test - Close response body before retrying to avoid leaking connections - Clamp jittered delay to maxDelayMs so it remains a hard ceiling - Add comment explaining push-promise sendAsync skips retry - Move RetryingHttpClientTest from ce/ to common/ where the source lives
1 parent d5c54de commit 74fb34a

File tree

10 files changed

+1549
-29
lines changed

10 files changed

+1549
-29
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ pom.xml.versionsBackup
2323
*/.run/**
2424
.run/**
2525
.run
26-
.claude/
26+
.claude
27+
.planning
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* Copyright © 2026-2026 ThingsBoard, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.thingsboard.client;
17+
18+
import lombok.extern.java.Log;
19+
20+
import java.io.IOException;
21+
import java.net.Authenticator;
22+
import java.net.CookieHandler;
23+
import java.net.ProxySelector;
24+
import java.net.http.HttpClient;
25+
import java.net.http.HttpRequest;
26+
import java.net.http.HttpResponse;
27+
import java.time.Duration;
28+
import java.util.Optional;
29+
import java.util.Random;
30+
import java.util.concurrent.CompletableFuture;
31+
import java.util.concurrent.Executor;
32+
import java.util.logging.Level;
33+
import javax.net.ssl.SSLContext;
34+
import javax.net.ssl.SSLParameters;
35+
36+
/**
37+
* An {@link HttpClient} wrapper that automatically retries requests that receive a retriable
38+
* HTTP status code (429 Too Many Requests by default) using exponential backoff with jitter.
39+
*
40+
* <p>The {@code Retry-After} response header is honoured when present: if it contains a
41+
* non-negative integer, that number of seconds is used as the retry delay (capped to
42+
* {@code maxDelayMs}).
43+
*
44+
* <p>After exhausting all retry attempts the final (still-retriable) response is returned to the
45+
* caller so that the upstream code (e.g. {@code ThingsboardApi}) can throw an
46+
* {@link ApiException} with the correct HTTP status code.
47+
*
48+
* <p>Obtain an instance via the static factory:
49+
* <pre>{@code
50+
* RetryingHttpClient client = RetryingHttpClient.wrap(HttpClient.newHttpClient(), 3, 1000L, 30_000L);
51+
* }</pre>
52+
*/
53+
@Log
54+
public class RetryingHttpClient extends HttpClient {
55+
56+
private final HttpClient delegate;
57+
private final int maxRetries;
58+
private final long initialDelayMs;
59+
private final long maxDelayMs;
60+
private final Random random = new Random();
61+
62+
private RetryingHttpClient(HttpClient delegate, int maxRetries, long initialDelayMs, long maxDelayMs) {
63+
this.delegate = delegate;
64+
this.maxRetries = maxRetries;
65+
this.initialDelayMs = initialDelayMs;
66+
this.maxDelayMs = maxDelayMs;
67+
}
68+
69+
/**
70+
* Creates a new {@code RetryingHttpClient} that wraps the given delegate.
71+
*
72+
* @param delegate the underlying {@link HttpClient} to delegate to
73+
* @param maxRetries maximum number of retry attempts (not counting the initial request)
74+
* @param initialDelayMs initial backoff delay in milliseconds
75+
* @param maxDelayMs maximum backoff delay in milliseconds
76+
* @return a new {@code RetryingHttpClient}
77+
*/
78+
public static RetryingHttpClient wrap(HttpClient delegate, int maxRetries, long initialDelayMs, long maxDelayMs) {
79+
return new RetryingHttpClient(delegate, maxRetries, initialDelayMs, maxDelayMs);
80+
}
81+
82+
/**
83+
* Returns {@code true} if the given status code should trigger a retry.
84+
* Override in subclasses to add additional retriable status codes.
85+
*
86+
* @param statusCode the HTTP response status code
87+
* @return {@code true} for retriable status codes (429 by default)
88+
*/
89+
protected boolean isRetriable(int statusCode) {
90+
return statusCode == 429;
91+
}
92+
93+
@Override
94+
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler)
95+
throws IOException, InterruptedException {
96+
HttpResponse<T> response = delegate.send(request, responseBodyHandler);
97+
98+
if (!isRetriable(response.statusCode())) {
99+
return response;
100+
}
101+
102+
for (int attempt = 1; attempt <= maxRetries; attempt++) {
103+
long delayMs = computeDelay(response, attempt);
104+
log.log(Level.WARNING, "HTTP {0} received, retrying in {1}ms (attempt {2}/{3})",
105+
new Object[]{response.statusCode(), delayMs, attempt, maxRetries});
106+
closeBody(response);
107+
Thread.sleep(delayMs);
108+
109+
response = delegate.send(request, responseBodyHandler);
110+
if (!isRetriable(response.statusCode())) {
111+
return response;
112+
}
113+
}
114+
115+
// Exhausted retries — return the last response so the caller can throw ApiException
116+
return response;
117+
}
118+
119+
@Override
120+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
121+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
122+
return sendAsyncWithRetry(request, responseBodyHandler, 1);
123+
}
124+
125+
// Push-promise variant delegates without retry: server-push semantics are incompatible
126+
// with request-level retry, and this overload is not used by the generated API code.
127+
@Override
128+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
129+
HttpRequest request,
130+
HttpResponse.BodyHandler<T> responseBodyHandler,
131+
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
132+
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
133+
}
134+
135+
private <T> CompletableFuture<HttpResponse<T>> sendAsyncWithRetry(
136+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int attempt) {
137+
return delegate.sendAsync(request, responseBodyHandler).thenCompose(response -> {
138+
if (!isRetriable(response.statusCode()) || attempt > maxRetries) {
139+
return CompletableFuture.completedFuture(response);
140+
}
141+
long delayMs = computeDelay(response, attempt);
142+
log.log(Level.WARNING, "HTTP {0} received, retrying in {1}ms (attempt {2}/{3})",
143+
new Object[]{response.statusCode(), delayMs, attempt, maxRetries});
144+
closeBody(response);
145+
Executor delayedExecutor = CompletableFuture.delayedExecutor(
146+
delayMs, java.util.concurrent.TimeUnit.MILLISECONDS);
147+
return CompletableFuture.supplyAsync(() -> null, delayedExecutor)
148+
.thenCompose(ignored -> sendAsyncWithRetry(request, responseBodyHandler, attempt + 1));
149+
});
150+
}
151+
152+
/**
153+
* Closes the response body if it is {@link AutoCloseable} (e.g. {@code InputStream}-backed responses)
154+
* to free the underlying connection before retrying.
155+
*/
156+
private static <T> void closeBody(HttpResponse<T> response) {
157+
if (response.body() instanceof AutoCloseable c) {
158+
try {
159+
c.close();
160+
} catch (Exception ignored) {
161+
}
162+
}
163+
}
164+
165+
/**
166+
* Computes the delay in milliseconds before the next retry attempt.
167+
* Honours the {@code Retry-After} header when present (integer seconds, non-negative).
168+
* Falls back to exponential backoff with ±20% jitter.
169+
*/
170+
private <T> long computeDelay(HttpResponse<T> response, int attempt) {
171+
Optional<String> retryAfter = response.headers().firstValue("Retry-After");
172+
if (retryAfter.isPresent()) {
173+
try {
174+
long seconds = Long.parseLong(retryAfter.get().trim());
175+
if (seconds >= 0) {
176+
return Math.min(seconds * 1000L, maxDelayMs);
177+
}
178+
} catch (NumberFormatException ignored) {
179+
// Not an integer — fall through to exponential backoff
180+
}
181+
}
182+
// Exponential backoff: initialDelayMs * 2^(attempt-1), capped at maxDelayMs
183+
int shift = Math.min(attempt - 1, 30); // prevent long overflow on large attempt values
184+
long base = Math.min(initialDelayMs * (1L << shift), maxDelayMs);
185+
// ±20% jitter, clamped so maxDelayMs remains a hard ceiling
186+
double jitter = (random.nextDouble() * 0.4) - 0.2; // range [-0.2, 0.2]
187+
return Math.min(maxDelayMs, Math.max(0, Math.round(base * (1.0 + jitter))));
188+
}
189+
190+
// -------------------------------------------------------------------------
191+
// Delegation of all remaining abstract HttpClient methods
192+
// -------------------------------------------------------------------------
193+
194+
@Override
195+
public Optional<CookieHandler> cookieHandler() {
196+
return delegate.cookieHandler();
197+
}
198+
199+
@Override
200+
public Optional<Duration> connectTimeout() {
201+
return delegate.connectTimeout();
202+
}
203+
204+
@Override
205+
public Redirect followRedirects() {
206+
return delegate.followRedirects();
207+
}
208+
209+
@Override
210+
public Optional<ProxySelector> proxy() {
211+
return delegate.proxy();
212+
}
213+
214+
@Override
215+
public SSLContext sslContext() {
216+
return delegate.sslContext();
217+
}
218+
219+
@Override
220+
public SSLParameters sslParameters() {
221+
return delegate.sslParameters();
222+
}
223+
224+
@Override
225+
public Optional<Authenticator> authenticator() {
226+
return delegate.authenticator();
227+
}
228+
229+
@Override
230+
public Version version() {
231+
return delegate.version();
232+
}
233+
234+
@Override
235+
public Optional<Executor> executor() {
236+
return delegate.executor();
237+
}
238+
239+
}

ce/src/main/java/org/thingsboard/client/ThingsboardClient.java

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
* <li>Automatic token refresh before expiry</li>
4242
* <li>Automatic re-login when the refresh token expires</li>
4343
* <li>Client-server clock skew compensation</li>
44+
* <li>Automatic retry on HTTP 429 (Too Many Requests) with exponential backoff</li>
4445
* </ul>
4546
*
4647
* <pre>{@code
@@ -56,6 +57,22 @@
5657
* .apiKey("your-api-key")
5758
* .build();
5859
*
60+
* // Tuning rate-limit retry behaviour (retry is enabled by default)
61+
* ThingsboardClient client = ThingsboardClient.builder()
62+
* .url("http://localhost:8080")
63+
* .credentials("tenant@thingsboard.org", "password")
64+
* .maxRetries(5)
65+
* .initialRetryDelayMs(500L)
66+
* .maxRetryDelayMs(60_000L)
67+
* .build();
68+
*
69+
* // Disable rate-limit retry
70+
* ThingsboardClient client = ThingsboardClient.builder()
71+
* .url("http://localhost:8080")
72+
* .credentials("tenant@thingsboard.org", "password")
73+
* .retryOnRateLimit(false)
74+
* .build();
75+
*
5976
* // All generated API methods are available directly
6077
* Device device = client.getDeviceById(deviceId);
6178
* }</pre>
@@ -96,6 +113,10 @@ public static class Builder {
96113
private String username;
97114
private String password;
98115
private String apiKey;
116+
private boolean retryOnRateLimit = true;
117+
private int maxRetries = 3;
118+
private long initialRetryDelayMs = 1000L;
119+
private long maxRetryDelayMs = 30_000L;
99120

100121
private Builder() {}
101122

@@ -115,23 +136,82 @@ public Builder apiKey(String apiKey) {
115136
return this;
116137
}
117138

139+
/**
140+
* Enables or disables automatic retry on rate-limit (HTTP 429) responses.
141+
* Enabled by default.
142+
*/
143+
public Builder retryOnRateLimit(boolean retryOnRateLimit) {
144+
this.retryOnRateLimit = retryOnRateLimit;
145+
return this;
146+
}
147+
148+
/**
149+
* Maximum number of retry attempts after an HTTP 429 response.
150+
* Default: 3.
151+
*/
152+
public Builder maxRetries(int maxRetries) {
153+
this.maxRetries = maxRetries;
154+
return this;
155+
}
156+
157+
/**
158+
* Initial backoff delay in milliseconds for the first retry.
159+
* Subsequent retries use exponential backoff with ±20% jitter.
160+
* Default: 1000 ms.
161+
*/
162+
public Builder initialRetryDelayMs(long initialRetryDelayMs) {
163+
this.initialRetryDelayMs = initialRetryDelayMs;
164+
return this;
165+
}
166+
167+
/**
168+
* Maximum backoff delay in milliseconds. The computed delay is capped at this value.
169+
* Default: 30 000 ms.
170+
*/
171+
public Builder maxRetryDelayMs(long maxRetryDelayMs) {
172+
this.maxRetryDelayMs = maxRetryDelayMs;
173+
return this;
174+
}
175+
118176
public ThingsboardClient build() throws ApiException {
119177
if (url == null) {
120178
throw new IllegalArgumentException("url is required");
121179
}
122-
if (apiKey != null) {
123-
return new ThingsboardClient(new AuthManager(url, AuthType.API_KEY, apiKey));
124-
}
125-
AuthManager auth = new AuthManager(url, AuthType.JWT, null);
180+
ApiClient apiClient = retryOnRateLimit
181+
? new RetryableApiClient(maxRetries, initialRetryDelayMs, maxRetryDelayMs)
182+
: new ApiClient();
183+
AuthType authType = apiKey != null ? AuthType.API_KEY : AuthType.JWT;
184+
AuthManager auth = new AuthManager(url, authType, apiKey, apiClient);
126185
ThingsboardClient client = new ThingsboardClient(auth);
127-
if (username != null) {
186+
if (authType == AuthType.JWT && username != null) {
128187
client.login(username, password);
129188
}
130189
return client;
131190
}
132191

133192
}
134193

194+
/**
195+
* ApiClient subclass that wraps every built HttpClient with retry-on-429 logic.
196+
*/
197+
private static class RetryableApiClient extends ApiClient {
198+
199+
private final int maxRetries;
200+
private final long initialDelayMs;
201+
private final long maxDelayMs;
202+
203+
RetryableApiClient(int maxRetries, long initialDelayMs, long maxDelayMs) {
204+
this.maxRetries = maxRetries;
205+
this.initialDelayMs = initialDelayMs;
206+
this.maxDelayMs = maxDelayMs;
207+
}
208+
209+
@Override
210+
public HttpClient getHttpClient() {
211+
return RetryingHttpClient.wrap(super.getHttpClient(), maxRetries, initialDelayMs, maxDelayMs);
212+
}
213+
}
214+
135215
/**
136216
* Authenticates with ThingsBoard using username and password.
137217
* The JWT token is automatically applied to all subsequent API calls.
@@ -178,9 +258,9 @@ private record TokenInfo(String token, String refreshToken, long tokenExpTs,
178258
private volatile String password;
179259
private volatile boolean refreshing;
180260

181-
AuthManager(String url, AuthType authType, String apiKey) {
261+
AuthManager(String url, AuthType authType, String apiKey, ApiClient apiClient) {
182262
this.authType = authType;
183-
this.apiClient = new ApiClient();
263+
this.apiClient = apiClient;
184264
apiClient.updateBaseUri(url);
185265
this.baseUrl = apiClient.getBaseUri();
186266
this.httpClient = apiClient.getHttpClient();

0 commit comments

Comments
 (0)