Skip to content

Commit 2e5cb83

Browse files
Add configurable retry with backoff for 5xx, network errors, and opt-in 429
1 parent b70e7e1 commit 2e5cb83

6 files changed

Lines changed: 319 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99
### Added
10+
- Automatic retries with exponential backoff for transient network errors and 5xx server responses, configurable via `IpregistryConfig` (`retryMaxAttempts`, `retryInterval`, `retryOnServerError`, `retryOnTooManyRequests`). Retries on 429 Too Many Requests are disabled by default and honor the `Retry-After` header when enabled.
1011
- `IpregistryClient` now implements `Closeable` to release the underlying HTTP connection pool.
1112

1213
### Fixed

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ InMemoryCache cache =
140140

141141
You can also provide your own cache implementation by implementing the _IpregistryCache_ interface.
142142

143+
## Retries
144+
145+
Failed requests are automatically retried with an exponential backoff. By default, up to 3 retries are performed on transient network errors and 5xx server responses.
146+
147+
Because Ipregistry does not rate limit by default (rate limiting is opt-in per API key), retries on _429 Too Many Requests_ responses are **disabled by default**. Enable them if your API key is configured with a rate limit and you want the client to wait and retry (honoring the `Retry-After` header when present):
148+
149+
```java
150+
IpregistryConfig config =
151+
IpregistryConfig.builder()
152+
.apiKey("YOUR_API_KEY")
153+
.retryMaxAttempts(3) // 0 disables retries entirely
154+
.retryInterval(1000) // base backoff in milliseconds
155+
.retryOnServerError(true) // retry on 5xx (default: true)
156+
.retryOnTooManyRequests(true) // retry on 429 (default: false)
157+
.build();
158+
```
159+
143160
## Errors
144161

145162
All Ipregistry exceptions inherit the _IpregistryException_ class.

‎src/main/java/co/ipregistry/api/client/IpregistryConfig.java‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,22 @@ public class IpregistryConfig {
4747
* @param connectionKeepAlive connection keep-alive timeout
4848
* @param connectionTimeout connection timeout
4949
* @param socketTimeout socket timeout
50+
* @param retryMaxAttempts the maximum number of automatic retries
51+
* @param retryInterval the base backoff interval, in milliseconds
52+
* @param retryOnServerError whether to retry on 5xx server errors
53+
* @param retryOnTooManyRequests whether to retry on 429 Too Many Requests
5054
*/
51-
public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive, int connectionTimeout, int socketTimeout) {
55+
public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive, int connectionTimeout, int socketTimeout,
56+
int retryMaxAttempts, long retryInterval, boolean retryOnServerError, boolean retryOnTooManyRequests) {
5257
this.apiKey = apiKey;
5358
this.baseUrl = baseUrl;
5459
this.connectionKeepAlive = connectionKeepAlive;
5560
this.connectionTimeout = connectionTimeout;
5661
this.socketTimeout = socketTimeout;
62+
this.retryMaxAttempts = retryMaxAttempts;
63+
this.retryInterval = retryInterval;
64+
this.retryOnServerError = retryOnServerError;
65+
this.retryOnTooManyRequests = retryOnTooManyRequests;
5766
}
5867

5968
/**
@@ -94,4 +103,34 @@ public IpregistryConfig(String apiKey, String baseUrl, int connectionKeepAlive,
94103
@Builder.Default
95104
private final int socketTimeout = 15000;
96105

106+
/**
107+
* The maximum number of times a failed request is automatically retried (in addition to the
108+
* initial attempt). Set to {@code 0} to disable retries entirely. Defaults to 3.
109+
*/
110+
@Builder.Default
111+
private final int retryMaxAttempts = 3;
112+
113+
/**
114+
* The base backoff interval in milliseconds used between retries. Successive retries use an
115+
* exponentially increasing delay ({@code retryInterval * 2^(attempt - 1)}). When a 429 response
116+
* carries a {@code Retry-After} header, that value takes precedence. Defaults to 1000 milliseconds.
117+
*/
118+
@Builder.Default
119+
private final long retryInterval = 1000;
120+
121+
/**
122+
* Whether requests failing with a 5xx server error (or a transient network error) should be
123+
* retried automatically. Defaults to {@code true}.
124+
*/
125+
@Builder.Default
126+
private final boolean retryOnServerError = true;
127+
128+
/**
129+
* Whether requests failing with a 429 Too Many Requests response should be retried automatically.
130+
* Ipregistry does not rate limit by default (it is opt-in per API key), so a 429 usually reflects
131+
* a deliberately configured limit; retrying is therefore disabled by default. Defaults to {@code false}.
132+
*/
133+
@Builder.Default
134+
private final boolean retryOnTooManyRequests = false;
135+
97136
}

‎src/main/java/co/ipregistry/api/client/request/DefaultRequestHandler.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ public DefaultRequestHandler(final IpregistryConfig config, final ObjectMapper o
8989

9090
this.httpClient = HttpClients.custom()
9191
.setDefaultRequestConfig(requestConfig)
92+
.setRetryStrategy(new IpregistryRetryStrategy(
93+
config.getRetryMaxAttempts(),
94+
config.getRetryInterval(),
95+
config.isRetryOnServerError(),
96+
config.isRetryOnTooManyRequests()))
9297
.build();
9398
}
9499

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2019 Ipregistry (https://ipregistry.co).
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+
17+
package co.ipregistry.api.client.request;
18+
19+
import org.apache.hc.client5.http.HttpRequestRetryStrategy;
20+
import org.apache.hc.core5.http.Header;
21+
import org.apache.hc.core5.http.HttpHeaders;
22+
import org.apache.hc.core5.http.HttpRequest;
23+
import org.apache.hc.core5.http.HttpResponse;
24+
import org.apache.hc.core5.http.HttpStatus;
25+
import org.apache.hc.core5.http.protocol.HttpContext;
26+
import org.apache.hc.core5.util.TimeValue;
27+
28+
import javax.net.ssl.SSLException;
29+
import java.io.IOException;
30+
import java.io.InterruptedIOException;
31+
import java.net.ConnectException;
32+
import java.net.NoRouteToHostException;
33+
import java.net.UnknownHostException;
34+
import java.util.Arrays;
35+
import java.util.List;
36+
37+
38+
/**
39+
* Retry strategy used by the Ipregistry HTTP client.
40+
* <p>
41+
* Retries are attempted, up to a configurable maximum, in the following cases:
42+
* <ul>
43+
* <li>transient network failures ({@link IOException}s that are not clearly permanent);</li>
44+
* <li>5xx server error responses, when enabled;</li>
45+
* <li>429 Too Many Requests responses, when enabled.</li>
46+
* </ul>
47+
* Retries use an exponential backoff based on a configurable interval. For 429 responses carrying a
48+
* {@code Retry-After} header expressed in seconds, that value takes precedence over the computed backoff.
49+
*/
50+
public class IpregistryRetryStrategy implements HttpRequestRetryStrategy {
51+
52+
/**
53+
* Exceptions that are considered permanent and therefore never retried.
54+
*/
55+
private static final List<Class<? extends IOException>> NON_RETRIABLE_EXCEPTIONS = Arrays.asList(
56+
InterruptedIOException.class,
57+
UnknownHostException.class,
58+
ConnectException.class,
59+
NoRouteToHostException.class,
60+
SSLException.class);
61+
62+
private final int maxAttempts;
63+
64+
private final long intervalMillis;
65+
66+
private final boolean retryOnServerError;
67+
68+
private final boolean retryOnTooManyRequests;
69+
70+
71+
/**
72+
* Creates a new retry strategy.
73+
*
74+
* @param maxAttempts the maximum number of retries (in addition to the initial attempt).
75+
* @param intervalMillis the base backoff interval, in milliseconds.
76+
* @param retryOnServerError whether to retry on 5xx server errors.
77+
* @param retryOnTooManyRequests whether to retry on 429 Too Many Requests responses.
78+
*/
79+
public IpregistryRetryStrategy(final int maxAttempts, final long intervalMillis,
80+
final boolean retryOnServerError, final boolean retryOnTooManyRequests) {
81+
this.maxAttempts = maxAttempts;
82+
this.intervalMillis = intervalMillis;
83+
this.retryOnServerError = retryOnServerError;
84+
this.retryOnTooManyRequests = retryOnTooManyRequests;
85+
}
86+
87+
@Override
88+
public boolean retryRequest(final HttpRequest request, final IOException exception,
89+
final int execCount, final HttpContext context) {
90+
if (execCount > maxAttempts) {
91+
return false;
92+
}
93+
94+
for (final Class<? extends IOException> nonRetriable : NON_RETRIABLE_EXCEPTIONS) {
95+
if (nonRetriable.isInstance(exception)) {
96+
return false;
97+
}
98+
}
99+
100+
return true;
101+
}
102+
103+
@Override
104+
public boolean retryRequest(final HttpResponse response, final int execCount, final HttpContext context) {
105+
if (execCount > maxAttempts) {
106+
return false;
107+
}
108+
109+
final int code = response.getCode();
110+
111+
if (code == HttpStatus.SC_TOO_MANY_REQUESTS) {
112+
return retryOnTooManyRequests;
113+
}
114+
115+
if (code >= 500 && code < 600) {
116+
return retryOnServerError;
117+
}
118+
119+
return false;
120+
}
121+
122+
@Override
123+
public TimeValue getRetryInterval(final HttpResponse response, final int execCount, final HttpContext context) {
124+
final Header retryAfter = response.getFirstHeader(HttpHeaders.RETRY_AFTER);
125+
126+
if (retryAfter != null && retryAfter.getValue() != null) {
127+
try {
128+
final long seconds = Long.parseLong(retryAfter.getValue().trim());
129+
if (seconds >= 0) {
130+
return TimeValue.ofSeconds(seconds);
131+
}
132+
} catch (final NumberFormatException ignored) {
133+
// Retry-After expressed as an HTTP date is not honored; fall back to exponential backoff.
134+
}
135+
}
136+
137+
return exponentialBackoff(execCount);
138+
}
139+
140+
private TimeValue exponentialBackoff(final int execCount) {
141+
final int shift = Math.min(Math.max(execCount - 1, 0), 30);
142+
return TimeValue.ofMilliseconds(intervalMillis * (1L << shift));
143+
}
144+
145+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright 2019 Ipregistry (https://ipregistry.co).
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+
17+
package co.ipregistry.api.client.request;
18+
19+
import org.apache.hc.core5.http.HttpResponse;
20+
import org.apache.hc.core5.http.message.BasicHttpResponse;
21+
import org.junit.jupiter.api.Assertions;
22+
import org.junit.jupiter.api.Test;
23+
24+
import javax.net.ssl.SSLException;
25+
import java.io.IOException;
26+
import java.net.ConnectException;
27+
import java.net.SocketTimeoutException;
28+
import java.net.UnknownHostException;
29+
30+
class IpregistryRetryStrategyTest {
31+
32+
private static HttpResponse response(final int code) {
33+
return new BasicHttpResponse(code);
34+
}
35+
36+
@Test
37+
void testRetriesOnServerErrorWhenEnabled() {
38+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(3, 1000, true, false);
39+
Assertions.assertTrue(strategy.retryRequest(response(500), 1, null));
40+
Assertions.assertTrue(strategy.retryRequest(response(503), 1, null));
41+
Assertions.assertTrue(strategy.retryRequest(response(599), 1, null));
42+
}
43+
44+
@Test
45+
void testDoesNotRetryServerErrorWhenDisabled() {
46+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(3, 1000, false, false);
47+
Assertions.assertFalse(strategy.retryRequest(response(500), 1, null));
48+
Assertions.assertFalse(strategy.retryRequest(response(503), 1, null));
49+
}
50+
51+
@Test
52+
void testRetriesTooManyRequestsOnlyWhenEnabled() {
53+
Assertions.assertFalse(new IpregistryRetryStrategy(3, 1000, true, false)
54+
.retryRequest(response(429), 1, null));
55+
Assertions.assertTrue(new IpregistryRetryStrategy(3, 1000, true, true)
56+
.retryRequest(response(429), 1, null));
57+
}
58+
59+
@Test
60+
void testDoesNotRetryClientErrorsOrSuccess() {
61+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(3, 1000, true, true);
62+
Assertions.assertFalse(strategy.retryRequest(response(200), 1, null));
63+
Assertions.assertFalse(strategy.retryRequest(response(400), 1, null));
64+
Assertions.assertFalse(strategy.retryRequest(response(401), 1, null));
65+
Assertions.assertFalse(strategy.retryRequest(response(404), 1, null));
66+
}
67+
68+
@Test
69+
void testStopsAfterMaxAttempts() {
70+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(2, 1000, true, true);
71+
Assertions.assertTrue(strategy.retryRequest(response(500), 1, null));
72+
Assertions.assertTrue(strategy.retryRequest(response(500), 2, null));
73+
Assertions.assertFalse(strategy.retryRequest(response(500), 3, null));
74+
}
75+
76+
@Test
77+
void testRetriesTransientIoExceptionButNotPermanentOnes() {
78+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(3, 1000, true, false);
79+
Assertions.assertTrue(strategy.retryRequest(null, new IOException("reset"), 1, null));
80+
81+
Assertions.assertFalse(strategy.retryRequest(null, new SocketTimeoutException(), 1, null));
82+
Assertions.assertFalse(strategy.retryRequest(null, new UnknownHostException(), 1, null));
83+
Assertions.assertFalse(strategy.retryRequest(null, new ConnectException(), 1, null));
84+
Assertions.assertFalse(strategy.retryRequest(null, new SSLException("bad"), 1, null));
85+
86+
Assertions.assertFalse(strategy.retryRequest(null, new IOException("reset"), 4, null));
87+
}
88+
89+
@Test
90+
void testExponentialBackoff() {
91+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(5, 1000, true, true);
92+
Assertions.assertEquals(1000, strategy.getRetryInterval(response(500), 1, null).toMilliseconds());
93+
Assertions.assertEquals(2000, strategy.getRetryInterval(response(500), 2, null).toMilliseconds());
94+
Assertions.assertEquals(4000, strategy.getRetryInterval(response(500), 3, null).toMilliseconds());
95+
}
96+
97+
@Test
98+
void testRetryAfterHeaderTakesPrecedence() {
99+
final IpregistryRetryStrategy strategy = new IpregistryRetryStrategy(5, 1000, true, true);
100+
101+
final HttpResponse withRetryAfter = response(429);
102+
withRetryAfter.setHeader("Retry-After", "5");
103+
Assertions.assertEquals(5000, strategy.getRetryInterval(withRetryAfter, 1, null).toMilliseconds());
104+
105+
// A non-numeric (HTTP-date) Retry-After is not honored and falls back to exponential backoff.
106+
final HttpResponse httpDate = response(429);
107+
httpDate.setHeader("Retry-After", "Wed, 21 Oct 2026 07:28:00 GMT");
108+
Assertions.assertEquals(1000, strategy.getRetryInterval(httpDate, 1, null).toMilliseconds());
109+
}
110+
111+
}

0 commit comments

Comments
 (0)