Skip to content

Commit bed160f

Browse files
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
1 parent 1ba5439 commit bed160f

1 file changed

Lines changed: 221 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
Thread.sleep(delayMs);
107+
108+
response = delegate.send(request, responseBodyHandler);
109+
if (!isRetriable(response.statusCode())) {
110+
return response;
111+
}
112+
}
113+
114+
// Exhausted retries — return the last response so the caller can throw ApiException
115+
return response;
116+
}
117+
118+
@Override
119+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
120+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
121+
return sendAsyncWithRetry(request, responseBodyHandler, 1);
122+
}
123+
124+
@Override
125+
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
126+
HttpRequest request,
127+
HttpResponse.BodyHandler<T> responseBodyHandler,
128+
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
129+
return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
130+
}
131+
132+
private <T> CompletableFuture<HttpResponse<T>> sendAsyncWithRetry(
133+
HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, int attempt) {
134+
return delegate.sendAsync(request, responseBodyHandler).thenCompose(response -> {
135+
if (!isRetriable(response.statusCode()) || attempt > maxRetries) {
136+
return CompletableFuture.completedFuture(response);
137+
}
138+
long delayMs = computeDelay(response, attempt);
139+
log.log(Level.WARNING, "HTTP {0} received, retrying in {1}ms (attempt {2}/{3})",
140+
new Object[]{response.statusCode(), delayMs, attempt, maxRetries});
141+
Executor delayedExecutor = CompletableFuture.delayedExecutor(
142+
delayMs, java.util.concurrent.TimeUnit.MILLISECONDS);
143+
return CompletableFuture.supplyAsync(() -> null, delayedExecutor)
144+
.thenCompose(ignored -> sendAsyncWithRetry(request, responseBodyHandler, attempt + 1));
145+
});
146+
}
147+
148+
/**
149+
* Computes the delay in milliseconds before the next retry attempt.
150+
* Honours the {@code Retry-After} header when present (integer seconds, non-negative).
151+
* Falls back to exponential backoff with ±20% jitter.
152+
*/
153+
private <T> long computeDelay(HttpResponse<T> response, int attempt) {
154+
Optional<String> retryAfter = response.headers().firstValue("Retry-After");
155+
if (retryAfter.isPresent()) {
156+
try {
157+
long seconds = Long.parseLong(retryAfter.get().trim());
158+
if (seconds >= 0) {
159+
return Math.min(seconds * 1000L, maxDelayMs);
160+
}
161+
} catch (NumberFormatException ignored) {
162+
// Not an integer — fall through to exponential backoff
163+
}
164+
}
165+
// Exponential backoff: initialDelayMs * 2^(attempt-1), capped at maxDelayMs
166+
long base = Math.min(initialDelayMs * (1L << (attempt - 1)), maxDelayMs);
167+
// ±20% jitter
168+
double jitter = (random.nextDouble() * 0.4) - 0.2; // range [-0.2, 0.2]
169+
return Math.max(0, Math.round(base * (1.0 + jitter)));
170+
}
171+
172+
// -------------------------------------------------------------------------
173+
// Delegation of all remaining abstract HttpClient methods
174+
// -------------------------------------------------------------------------
175+
176+
@Override
177+
public Optional<CookieHandler> cookieHandler() {
178+
return delegate.cookieHandler();
179+
}
180+
181+
@Override
182+
public Optional<Duration> connectTimeout() {
183+
return delegate.connectTimeout();
184+
}
185+
186+
@Override
187+
public Redirect followRedirects() {
188+
return delegate.followRedirects();
189+
}
190+
191+
@Override
192+
public Optional<ProxySelector> proxy() {
193+
return delegate.proxy();
194+
}
195+
196+
@Override
197+
public SSLContext sslContext() {
198+
return delegate.sslContext();
199+
}
200+
201+
@Override
202+
public SSLParameters sslParameters() {
203+
return delegate.sslParameters();
204+
}
205+
206+
@Override
207+
public Optional<Authenticator> authenticator() {
208+
return delegate.authenticator();
209+
}
210+
211+
@Override
212+
public Version version() {
213+
return delegate.version();
214+
}
215+
216+
@Override
217+
public Optional<Executor> executor() {
218+
return delegate.executor();
219+
}
220+
221+
}

0 commit comments

Comments
 (0)