From a89756462e006299c6c1e1f7cabefad277577dec Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Mon, 23 Feb 2026 15:33:25 -0500 Subject: [PATCH 1/2] 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. --- .../java/com/duosecurity/client/Http.java | 24 ++++- .../client/HttpRateLimitRetryTest.java | 88 ++++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/duo-client/src/main/java/com/duosecurity/client/Http.java b/duo-client/src/main/java/com/duosecurity/client/Http.java index fadb3fa..4f9c05a 100644 --- a/duo-client/src/main/java/com/duosecurity/client/Http.java +++ b/duo-client/src/main/java/com/duosecurity/client/Http.java @@ -42,6 +42,7 @@ public class Http { private Headers.Builder headers; private SortedMap params = new TreeMap(); protected int sigVersion = 5; + private long maxBackoffMs = MAX_BACKOFF_MS; private Random random = new Random(); private OkHttpClient httpClient; private SortedMap additionalDuoHeaders = new TreeMap(); @@ -314,7 +315,7 @@ private Response executeRequest(Request request) throws Exception { long backoffMs = INITIAL_BACKOFF_MS; while (true) { Response response = httpClient.newCall(request).execute(); - if (response.code() != RATE_LIMIT_ERROR_CODE || backoffMs > MAX_BACKOFF_MS) { + if (response.code() != RATE_LIMIT_ERROR_CODE || backoffMs > maxBackoffMs) { return response; } @@ -327,6 +328,10 @@ protected void sleep(long ms) throws Exception { Thread.sleep(ms); } + protected void setMaxBackoffMs(long maxBackoffMs) { + this.maxBackoffMs = maxBackoffMs; + } + public void signRequest(String ikey, String skey) throws UnsupportedEncodingException { signRequest(ikey, skey, sigVersion); @@ -529,6 +534,7 @@ protected abstract static class ClientBuilder { private final String uri; private int timeout = DEFAULT_TIMEOUT_SECS; + private long maxBackoffMs = MAX_BACKOFF_MS; private String[] caCerts = null; private SortedMap additionalDuoHeaders = new TreeMap(); private Map headers = new HashMap(); @@ -558,6 +564,21 @@ public ClientBuilder useTimeout(int timeout) { return this; } + /** + * Set the maximum backoff time in milliseconds for rate limit (429) retries. + * When a request receives a 429 response, the client retries with exponential + * backoff until the backoff exceeds this threshold. Setting to 0 disables retries. + * Default is 32000ms (32 seconds). + * + * @param maxBackoffMs the maximum backoff in milliseconds + * @return the Builder + */ + public ClientBuilder useMaxBackoffMs(long maxBackoffMs) { + this.maxBackoffMs = maxBackoffMs; + + return this; + } + /** * Provide custom CA certificates for certificate pinning. * @@ -604,6 +625,7 @@ public ClientBuilder addHeader(String name, String value) { */ public T build() { T duoClient = createClient(method, host, uri, timeout); + duoClient.setMaxBackoffMs(maxBackoffMs); if (caCerts != null) { duoClient.useCustomCertificates(caCerts); } diff --git a/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java b/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java index 5955e2c..03d7fe0 100644 --- a/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java +++ b/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java @@ -26,10 +26,8 @@ public class HttpRateLimitRetryTest { private final int RANDOM_INT = 234; - @Before - public void before() throws Exception { - http = new Http.HttpBuilder("GET", "example.test", "/foo/bar").build(); - http = Mockito.spy(http); + private void setupHttp(Http client) throws Exception { + http = Mockito.spy(client); Field httpClientField = Http.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); @@ -39,6 +37,12 @@ public void before() throws Exception { Mockito.doNothing().when(http).sleep(Mockito.any(Long.class)); } + @Before + public void before() throws Exception { + Http client = new Http.HttpBuilder("GET", "example.test", "/foo/bar").build(); + setupHttp(client); + } + @Test public void testSingleRateLimitRetry() throws Exception { final List responses = new ArrayList(); @@ -128,4 +132,80 @@ public Call answer(InvocationOnMock invocationOnMock) throws Throwable { assertEquals(16000L + RANDOM_INT, (long) sleepTimes.get(4)); assertEquals(32000L + RANDOM_INT, (long) sleepTimes.get(5)); } + + @Test + public void testMaxBackoffZeroDisablesRetry() throws Exception { + Http customHttp = new Http.HttpBuilder("GET", "example.test", "/foo/bar") + .useMaxBackoffMs(0) + .build(); + setupHttp(customHttp); + + final List responses = new ArrayList(); + + Mockito.when(httpClient.newCall(Mockito.any(Request.class))).thenAnswer(new Answer() { + @Override + public Call answer(InvocationOnMock invocationOnMock) throws Throwable { + Call call = Mockito.mock(Call.class); + + Response resp = new Response.Builder() + .protocol(Protocol.HTTP_2) + .code(429) + .request((Request) invocationOnMock.getArguments()[0]) + .message("HTTP 429") + .build(); + responses.add(resp); + Mockito.when(call.execute()).thenReturn(resp); + + return call; + } + }); + + Response actualRes = http.executeHttpRequest(); + assertEquals(1, responses.size()); + assertEquals(429, actualRes.code()); + + // Verify no sleep was called + Mockito.verify(http, Mockito.never()).sleep(Mockito.any(Long.class)); + } + + @Test + public void testMaxBackoffCustomLimit() throws Exception { + Http customHttp = new Http.HttpBuilder("GET", "example.test", "/foo/bar") + .useMaxBackoffMs(4000) + .build(); + setupHttp(customHttp); + + final List responses = new ArrayList(); + + Mockito.when(httpClient.newCall(Mockito.any(Request.class))).thenAnswer(new Answer() { + @Override + public Call answer(InvocationOnMock invocationOnMock) throws Throwable { + Call call = Mockito.mock(Call.class); + + Response resp = new Response.Builder() + .protocol(Protocol.HTTP_2) + .code(429) + .request((Request) invocationOnMock.getArguments()[0]) + .message("HTTP 429") + .build(); + responses.add(resp); + Mockito.when(call.execute()).thenReturn(resp); + + return call; + } + }); + + // With maxBackoff=4000, retries at 1000, 2000, 4000, then 8000 > 4000 exits + // That's 4 total requests (1 initial + 3 retries) + Response actualRes = http.executeHttpRequest(); + assertEquals(4, responses.size()); + assertEquals(429, actualRes.code()); + + ArgumentCaptor sleepCapture = ArgumentCaptor.forClass(Long.class); + Mockito.verify(http, Mockito.times(3)).sleep(sleepCapture.capture()); + List sleepTimes = sleepCapture.getAllValues(); + assertEquals(1000L + RANDOM_INT, (long) sleepTimes.get(0)); + assertEquals(2000L + RANDOM_INT, (long) sleepTimes.get(1)); + assertEquals(4000L + RANDOM_INT, (long) sleepTimes.get(2)); + } } From 8360f33db1bea9e4718ab2c96dd979f1b0e74ca2 Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Wed, 25 Feb 2026 12:10:09 -0500 Subject: [PATCH 2/2] Add input validation and improve docs for maxBackoffMs - Reject negative values in both useMaxBackoffMs() builder method and setMaxBackoffMs() setter with IllegalArgumentException - Clarify Javadoc that maxBackoffMs is the base backoff threshold before jitter (actual sleep includes up to 1000ms random jitter) - Add test for negative value rejection --- .../main/java/com/duosecurity/client/Http.java | 16 ++++++++++++---- .../client/HttpRateLimitRetryTest.java | 7 +++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/duo-client/src/main/java/com/duosecurity/client/Http.java b/duo-client/src/main/java/com/duosecurity/client/Http.java index 4f9c05a..ec13cf3 100644 --- a/duo-client/src/main/java/com/duosecurity/client/Http.java +++ b/duo-client/src/main/java/com/duosecurity/client/Http.java @@ -329,6 +329,9 @@ protected void sleep(long ms) throws Exception { } protected void setMaxBackoffMs(long maxBackoffMs) { + if (maxBackoffMs < 0) { + throw new IllegalArgumentException("maxBackoffMs must be >= 0"); + } this.maxBackoffMs = maxBackoffMs; } @@ -565,15 +568,20 @@ public ClientBuilder useTimeout(int timeout) { } /** - * Set the maximum backoff time in milliseconds for rate limit (429) retries. + * Set the maximum base backoff time in milliseconds for rate limit (429) retries. * When a request receives a 429 response, the client retries with exponential - * backoff until the backoff exceeds this threshold. Setting to 0 disables retries. - * Default is 32000ms (32 seconds). + * backoff until the base backoff exceeds this threshold. Note that actual sleep + * time includes up to 1000ms of random jitter on top of the base backoff. + * Setting to 0 disables retries. Default is 32000ms (32 seconds). * - * @param maxBackoffMs the maximum backoff in milliseconds + * @param maxBackoffMs the maximum base backoff in milliseconds (must be >= 0) * @return the Builder + * @throws IllegalArgumentException if maxBackoffMs is negative */ public ClientBuilder useMaxBackoffMs(long maxBackoffMs) { + if (maxBackoffMs < 0) { + throw new IllegalArgumentException("maxBackoffMs must be >= 0"); + } this.maxBackoffMs = maxBackoffMs; return this; diff --git a/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java b/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java index 03d7fe0..baf41fb 100644 --- a/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java +++ b/duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryTest.java @@ -208,4 +208,11 @@ public Call answer(InvocationOnMock invocationOnMock) throws Throwable { assertEquals(2000L + RANDOM_INT, (long) sleepTimes.get(1)); assertEquals(4000L + RANDOM_INT, (long) sleepTimes.get(2)); } + + @Test(expected = IllegalArgumentException.class) + public void testMaxBackoffNegativeThrows() { + new Http.HttpBuilder("GET", "example.test", "/foo/bar") + .useMaxBackoffMs(-1) + .build(); + } }