-
Notifications
You must be signed in to change notification settings - Fork 44
Add configurable maxBackoffMs for rate limit retries #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
a897564
Add configurable maxBackoffMs for rate limit retries
jeffreyparker 8360f33
Add input validation and improve docs for maxBackoffMs
jeffreyparker fcf5cd8
Add unit test confirming default maxBackoffMs of 32000 is used when n…
jeffreyparker 341b367
Add integration tests for rate limit retry and backoff using MockWebS…
jeffreyparker cb216dd
Close intermediate 429 responses before retry to prevent resource leaks
jeffreyparker 03fa38c
Improve useMaxBackoffMs Javadoc accuracy and document chaining limita…
jeffreyparker 63376d6
Move integration tests to maven-failsafe-plugin
jeffreyparker 50405c5
Remove redundant reflection-based default maxBackoffMs test
jeffreyparker a481565
Run integration tests in CI by using mvn verify
jeffreyparker 9c2c8d5
Remove integration test and associated CI/Maven scaffolding
jeffreyparker 02d70ea
Remove intermediate 429 response close from retry loop
jeffreyparker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
duo-client/src/test/java/com/duosecurity/client/HttpRateLimitRetryIntegrationIT.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| package com.duosecurity.client; | ||
|
|
||
| import okhttp3.OkHttpClient; | ||
| import okhttp3.Response; | ||
| import okhttp3.mockwebserver.MockResponse; | ||
| import okhttp3.mockwebserver.MockWebServer; | ||
| import okhttp3.tls.HandshakeCertificates; | ||
| import okhttp3.tls.HeldCertificate; | ||
| import org.junit.After; | ||
| import org.junit.Before; | ||
| import org.junit.Test; | ||
| import org.mockito.Mockito; | ||
|
|
||
| import java.lang.reflect.Field; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
|
|
||
| public class HttpRateLimitRetryIntegrationIT { | ||
|
|
||
| private MockWebServer server; | ||
| private HandshakeCertificates clientCerts; | ||
|
|
||
| @Before | ||
| public void setUp() throws Exception { | ||
| HeldCertificate serverCert = new HeldCertificate.Builder() | ||
| .addSubjectAlternativeName("localhost") | ||
| .build(); | ||
|
|
||
| HandshakeCertificates serverCerts = new HandshakeCertificates.Builder() | ||
| .heldCertificate(serverCert) | ||
| .build(); | ||
|
|
||
| clientCerts = new HandshakeCertificates.Builder() | ||
| .addTrustedCertificate(serverCert.certificate()) | ||
| .build(); | ||
|
|
||
| server = new MockWebServer(); | ||
| server.useHttps(serverCerts.sslSocketFactory(), false); | ||
| server.start(); | ||
| } | ||
|
|
||
| @After | ||
| public void tearDown() throws Exception { | ||
| server.shutdown(); | ||
| } | ||
|
|
||
| /** | ||
| * Builds an Http spy pointing at the MockWebServer, with sleep() stubbed out to avoid real | ||
| * delays and the OkHttpClient replaced with one that trusts the test certificate. | ||
| * | ||
| * <p>The builder must be constructed with host "localhost" (no port) so that CertificatePinner | ||
| * accepts the pattern. This method then sets the real host (with port) and replaces the | ||
| * OkHttpClient via reflection before the spy is used. | ||
| */ | ||
| private Http buildSpyHttp(Http.ClientBuilder<Http> builder) throws Exception { | ||
| Http spy = Mockito.spy(builder.build()); | ||
| Mockito.doNothing().when(spy).sleep(Mockito.any(Long.class)); | ||
|
|
||
| // Point the host at the MockWebServer port (CertificatePinner rejects host:port patterns, | ||
| // so the builder uses "localhost" and we fix it here after construction). | ||
| Field hostField = Http.class.getDeclaredField("host"); | ||
| hostField.setAccessible(true); | ||
| hostField.set(spy, "localhost:" + server.getPort()); | ||
|
|
||
| // Replace the OkHttpClient with one configured to trust the test certificate | ||
| OkHttpClient testClient = new OkHttpClient.Builder() | ||
| .sslSocketFactory(clientCerts.sslSocketFactory(), clientCerts.trustManager()) | ||
| .build(); | ||
|
|
||
| Field httpClientField = Http.class.getDeclaredField("httpClient"); | ||
| httpClientField.setAccessible(true); | ||
| httpClientField.set(spy, testClient); | ||
|
|
||
| return spy; | ||
| } | ||
|
|
||
| private Http.HttpBuilder defaultBuilder() { | ||
| // Use "localhost" without a port — CertificatePinner rejects host:port patterns. | ||
| // buildSpyHttp sets the real host (with port) via reflection after construction. | ||
| return new Http.HttpBuilder("GET", "localhost", "/foo/bar"); | ||
| } | ||
|
|
||
| @Test | ||
| public void testSingleRateLimitRetry() throws Exception { | ||
| server.enqueue(new MockResponse().setResponseCode(429)); | ||
| server.enqueue(new MockResponse().setResponseCode(200)); | ||
|
|
||
| Http http = buildSpyHttp(defaultBuilder()); | ||
| Response response = http.executeHttpRequest(); | ||
|
|
||
| assertEquals(200, response.code()); | ||
| assertEquals(2, server.getRequestCount()); | ||
| Mockito.verify(http, Mockito.times(1)).sleep(Mockito.any(Long.class)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testRateLimitExhaustsDefaultMaxBackoff() throws Exception { | ||
| // Enqueue more responses than will ever be consumed | ||
| for (int i = 0; i < 10; i++) { | ||
| server.enqueue(new MockResponse().setResponseCode(429)); | ||
| } | ||
|
|
||
| Http http = buildSpyHttp(defaultBuilder()); | ||
| Response response = http.executeHttpRequest(); | ||
|
|
||
| assertEquals(429, response.code()); | ||
| // Default max backoff (32s): sleeps at 1s, 2s, 4s, 8s, 16s, 32s = 6 sleeps, 7 total requests | ||
| assertEquals(7, server.getRequestCount()); | ||
| Mockito.verify(http, Mockito.times(6)).sleep(Mockito.any(Long.class)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCustomMaxBackoffLimitsRetries() throws Exception { | ||
| for (int i = 0; i < 10; i++) { | ||
| server.enqueue(new MockResponse().setResponseCode(429)); | ||
| } | ||
|
|
||
| Http http = buildSpyHttp(defaultBuilder().useMaxBackoffMs(4000)); | ||
| Response response = http.executeHttpRequest(); | ||
|
|
||
| assertEquals(429, response.code()); | ||
| // maxBackoff=4000: sleeps at 1s, 2s, 4s = 3 sleeps, 4 total requests (next would be 8s > 4s) | ||
| assertEquals(4, server.getRequestCount()); | ||
| Mockito.verify(http, Mockito.times(3)).sleep(Mockito.any(Long.class)); | ||
| } | ||
|
|
||
| @Test | ||
| public void testMaxBackoffZeroDisablesRetry() throws Exception { | ||
| server.enqueue(new MockResponse().setResponseCode(429)); | ||
|
|
||
| Http http = buildSpyHttp(defaultBuilder().useMaxBackoffMs(0)); | ||
| Response response = http.executeHttpRequest(); | ||
|
|
||
| assertEquals(429, response.code()); | ||
| assertEquals(1, server.getRequestCount()); | ||
| Mockito.verify(http, Mockito.never()).sleep(Mockito.any(Long.class)); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.