Skip to content

Commit 6e3394a

Browse files
Add configurable maxBackoffMs for rate limit retries (#103)
* Add configurable maxBackoffMs for rate limit retries Allow callers to configure the maximum backoff threshold for 429 retry logic via a new useMaxBackoffMs() builder method. Setting maxBackoffMs to 0 disables retries entirely. The default (32000ms) preserves existing behavior.
1 parent 526018f commit 6e3394a

3 files changed

Lines changed: 141 additions & 7 deletions

File tree

.github/workflows/java-ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ jobs:
3333
--file duo-client/pom.xml
3434
- name: Test with Maven
3535
run: >
36-
mvn test
37-
--batch-mode
36+
mvn test
37+
--batch-mode
3838
-file duo-client/pom.xml
3939
- name: Lint with checkstyle
4040
run: mvn checkstyle:check

duo-client/src/main/java/com/duosecurity/client/Http.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class Http {
4242
private Headers.Builder headers;
4343
private SortedMap<String, Object> params = new TreeMap<String, Object>();
4444
protected int sigVersion = 5;
45+
private long maxBackoffMs = MAX_BACKOFF_MS;
4546
private Random random = new Random();
4647
private OkHttpClient httpClient;
4748
private SortedMap<String, String> additionalDuoHeaders = new TreeMap<String, String>();
@@ -314,7 +315,7 @@ private Response executeRequest(Request request) throws Exception {
314315
long backoffMs = INITIAL_BACKOFF_MS;
315316
while (true) {
316317
Response response = httpClient.newCall(request).execute();
317-
if (response.code() != RATE_LIMIT_ERROR_CODE || backoffMs > MAX_BACKOFF_MS) {
318+
if (response.code() != RATE_LIMIT_ERROR_CODE || backoffMs > maxBackoffMs) {
318319
return response;
319320
}
320321

@@ -327,6 +328,13 @@ protected void sleep(long ms) throws Exception {
327328
Thread.sleep(ms);
328329
}
329330

331+
protected void setMaxBackoffMs(long maxBackoffMs) {
332+
if (maxBackoffMs < 0) {
333+
throw new IllegalArgumentException("maxBackoffMs must be >= 0");
334+
}
335+
this.maxBackoffMs = maxBackoffMs;
336+
}
337+
330338
public void signRequest(String ikey, String skey)
331339
throws UnsupportedEncodingException {
332340
signRequest(ikey, skey, sigVersion);
@@ -529,6 +537,7 @@ protected abstract static class ClientBuilder<T extends Http> {
529537
private final String uri;
530538

531539
private int timeout = DEFAULT_TIMEOUT_SECS;
540+
private long maxBackoffMs = MAX_BACKOFF_MS;
532541
private String[] caCerts = null;
533542
private SortedMap<String, String> additionalDuoHeaders = new TreeMap<String, String>();
534543
private Map<String, String> headers = new HashMap<String, String>();
@@ -558,6 +567,32 @@ public ClientBuilder<T> useTimeout(int timeout) {
558567
return this;
559568
}
560569

570+
/**
571+
* Set the maximum base backoff time in milliseconds for rate limit (429) retries.
572+
* When a request receives a 429 response, the client retries with exponential
573+
* backoff until the base backoff exceeds this threshold. Note that actual sleep
574+
* time includes up to 1000ms of random jitter on top of the base backoff.
575+
* Setting to 0 disables retries (as does any value below the initial
576+
* backoff of 1000ms). Default is 32000ms (32 seconds).
577+
*
578+
* <p>Note: When using method chaining from outside this package (e.g. with
579+
* {@code AuthBuilder} or {@code AdminBuilder}), assign the builder to a variable
580+
* and call methods separately, then call {@code build()}. This is a known
581+
* limitation of all {@code ClientBuilder} methods.
582+
*
583+
* @param maxBackoffMs the maximum base backoff in milliseconds (must be >= 0)
584+
* @return the Builder
585+
* @throws IllegalArgumentException if maxBackoffMs is negative
586+
*/
587+
public ClientBuilder<T> useMaxBackoffMs(long maxBackoffMs) {
588+
if (maxBackoffMs < 0) {
589+
throw new IllegalArgumentException("maxBackoffMs must be >= 0");
590+
}
591+
this.maxBackoffMs = maxBackoffMs;
592+
593+
return this;
594+
}
595+
561596
/**
562597
* Provide custom CA certificates for certificate pinning.
563598
*
@@ -604,6 +639,7 @@ public ClientBuilder<T> addHeader(String name, String value) {
604639
*/
605640
public T build() {
606641
T duoClient = createClient(method, host, uri, timeout);
642+
duoClient.setMaxBackoffMs(maxBackoffMs);
607643
if (caCerts != null) {
608644
duoClient.useCustomCertificates(caCerts);
609645
}

duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,8 @@ public class HttpRateLimitRetryTest {
2626

2727
private final int RANDOM_INT = 234;
2828

29-
@Before
30-
public void before() throws Exception {
31-
http = new Http.HttpBuilder("GET", "example.test", "/foo/bar").build();
32-
http = Mockito.spy(http);
29+
private void setupHttp(Http client) throws Exception {
30+
http = Mockito.spy(client);
3331

3432
Field httpClientField = Http.class.getDeclaredField("httpClient");
3533
httpClientField.setAccessible(true);
@@ -39,6 +37,12 @@ public void before() throws Exception {
3937
Mockito.doNothing().when(http).sleep(Mockito.any(Long.class));
4038
}
4139

40+
@Before
41+
public void before() throws Exception {
42+
Http client = new Http.HttpBuilder("GET", "example.test", "/foo/bar").build();
43+
setupHttp(client);
44+
}
45+
4246
@Test
4347
public void testSingleRateLimitRetry() throws Exception {
4448
final List<Response> responses = new ArrayList<Response>();
@@ -128,4 +132,98 @@ public Call answer(InvocationOnMock invocationOnMock) throws Throwable {
128132
assertEquals(16000L + RANDOM_INT, (long) sleepTimes.get(4));
129133
assertEquals(32000L + RANDOM_INT, (long) sleepTimes.get(5));
130134
}
135+
136+
@Test
137+
public void testMaxBackoffZeroDisablesRetry() throws Exception {
138+
Http customHttp = new Http.HttpBuilder("GET", "example.test", "/foo/bar")
139+
.useMaxBackoffMs(0)
140+
.build();
141+
setupHttp(customHttp);
142+
143+
final List<Response> responses = new ArrayList<Response>();
144+
145+
Mockito.when(httpClient.newCall(Mockito.any(Request.class))).thenAnswer(new Answer<Call>() {
146+
@Override
147+
public Call answer(InvocationOnMock invocationOnMock) throws Throwable {
148+
Call call = Mockito.mock(Call.class);
149+
150+
Response resp = new Response.Builder()
151+
.protocol(Protocol.HTTP_2)
152+
.code(429)
153+
.request((Request) invocationOnMock.getArguments()[0])
154+
.message("HTTP 429")
155+
.build();
156+
responses.add(resp);
157+
Mockito.when(call.execute()).thenReturn(resp);
158+
159+
return call;
160+
}
161+
});
162+
163+
Response actualRes = http.executeHttpRequest();
164+
assertEquals(1, responses.size());
165+
assertEquals(429, actualRes.code());
166+
167+
// Verify no sleep was called
168+
Mockito.verify(http, Mockito.never()).sleep(Mockito.any(Long.class));
169+
}
170+
171+
@Test
172+
public void testMaxBackoffCustomLimit() throws Exception {
173+
Http customHttp = new Http.HttpBuilder("GET", "example.test", "/foo/bar")
174+
.useMaxBackoffMs(4000)
175+
.build();
176+
setupHttp(customHttp);
177+
178+
final List<Response> responses = new ArrayList<Response>();
179+
180+
Mockito.when(httpClient.newCall(Mockito.any(Request.class))).thenAnswer(new Answer<Call>() {
181+
@Override
182+
public Call answer(InvocationOnMock invocationOnMock) throws Throwable {
183+
Call call = Mockito.mock(Call.class);
184+
185+
Response resp = new Response.Builder()
186+
.protocol(Protocol.HTTP_2)
187+
.code(429)
188+
.request((Request) invocationOnMock.getArguments()[0])
189+
.message("HTTP 429")
190+
.build();
191+
responses.add(resp);
192+
Mockito.when(call.execute()).thenReturn(resp);
193+
194+
return call;
195+
}
196+
});
197+
198+
// With maxBackoff=4000, retries at 1000, 2000, 4000, then 8000 > 4000 exits
199+
// That's 4 total requests (1 initial + 3 retries)
200+
Response actualRes = http.executeHttpRequest();
201+
assertEquals(4, responses.size());
202+
assertEquals(429, actualRes.code());
203+
204+
ArgumentCaptor<Long> sleepCapture = ArgumentCaptor.forClass(Long.class);
205+
Mockito.verify(http, Mockito.times(3)).sleep(sleepCapture.capture());
206+
List<Long> sleepTimes = sleepCapture.getAllValues();
207+
assertEquals(1000L + RANDOM_INT, (long) sleepTimes.get(0));
208+
assertEquals(2000L + RANDOM_INT, (long) sleepTimes.get(1));
209+
assertEquals(4000L + RANDOM_INT, (long) sleepTimes.get(2));
210+
}
211+
212+
@Test
213+
public void testDefaultMaxBackoffIsUsedWhenNotSpecified() throws Exception {
214+
Http defaultHttp = new Http.HttpBuilder("GET", "example.test", "/foo/bar").build();
215+
216+
Field maxBackoffField = Http.class.getDeclaredField("maxBackoffMs");
217+
maxBackoffField.setAccessible(true);
218+
long actualMaxBackoff = (long) maxBackoffField.get(defaultHttp);
219+
220+
assertEquals(Http.MAX_BACKOFF_MS, actualMaxBackoff);
221+
}
222+
223+
@Test(expected = IllegalArgumentException.class)
224+
public void testMaxBackoffNegativeThrows() {
225+
new Http.HttpBuilder("GET", "example.test", "/foo/bar")
226+
.useMaxBackoffMs(-1)
227+
.build();
228+
}
131229
}

0 commit comments

Comments
 (0)