From e0c3b6b1ca2c4f9157fd1781be5819f0ba518405 Mon Sep 17 00:00:00 2001 From: Sergio Barbero Date: Sun, 2 Jul 2023 22:50:35 +0200 Subject: [PATCH 1/4] ISSUE-344: configuring http client to not retry when Spotify returns 429 --- pom.xml | 6 +++ .../spotify/SpotifyHttpManager.java | 2 + .../playlists/GetPlaylistRequestTest.java | 43 ++++++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b68409980..09edf8d17 100644 --- a/pom.xml +++ b/pom.xml @@ -99,6 +99,12 @@ 2.0.9 test + + com.github.tomakehurst + wiremock-jre8 + 2.35.0 + test + jar diff --git a/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java b/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java index 9ffab8a7e..76ececb06 100644 --- a/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java +++ b/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java @@ -91,6 +91,7 @@ public SpotifyHttpManager(Builder builder) { .custom() .setDefaultCredentialsProvider(credentialsProvider) .setDefaultRequestConfig(requestConfig) + .disableAutomaticRetries() .disableContentCompression() .build(); @@ -98,6 +99,7 @@ public SpotifyHttpManager(Builder builder) { .custom() .setCacheConfig(cacheConfig) .setDefaultCredentialsProvider(credentialsProvider) + .disableAutomaticRetries() .setDefaultRequestConfig(requestConfig) .disableContentCompression() .build(); diff --git a/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java b/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java index b37c84fa3..76fb186aa 100644 --- a/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java +++ b/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java @@ -1,19 +1,25 @@ package se.michaelthelin.spotify.requests.data.playlists; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; import org.apache.hc.core5.http.ParseException; import org.junit.jupiter.api.Test; import se.michaelthelin.spotify.ITest; +import se.michaelthelin.spotify.SpotifyApi; +import se.michaelthelin.spotify.SpotifyHttpManager; import se.michaelthelin.spotify.TestUtil; import se.michaelthelin.spotify.enums.ModelObjectType; import se.michaelthelin.spotify.exceptions.SpotifyWebApiException; +import se.michaelthelin.spotify.exceptions.detailed.TooManyRequestsException; import se.michaelthelin.spotify.model_objects.specification.Playlist; import se.michaelthelin.spotify.requests.data.AbstractDataTest; import java.io.IOException; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.*; +import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.junit.jupiter.api.Assertions.*; +@WireMockTest(httpPort = 9090) public class GetPlaylistRequestTest extends AbstractDataTest { private final GetPlaylistRequest defaultRequest = ITest.SPOTIFY_API .getPlaylist(ITest.ID_PLAYLIST) @@ -28,6 +34,41 @@ public class GetPlaylistRequestTest extends AbstractDataTest { public GetPlaylistRequestTest() throws Exception { } + @Test + public void shouldThrowTooManyRequestExceptionAndNotBlockThread_WhenSpotifyReturns429() throws IOException, ParseException, SpotifyWebApiException { + SpotifyApi spotifyApi = new SpotifyApi.Builder() + .setScheme("http") + .setHost("localhost") + .setPort(9090) + .setClientId("zyuxhfo1c51b5hxjk09x2uhv5n0svgd6g") + .setClientSecret("zudknyqbh3wunbhcvg9uyvo7uwzeu6nne") + .setRedirectUri(SpotifyHttpManager.makeUri("https://example.com/spotify-redirect")) + .setAccessToken("taHZ2SdB-bPA3FsK3D7ZN5npZS47cMy-IEySVEGttOhXmqaVAIo0ESvTCLjLBifhHOHOIuhFUKPW1WMDP7w6dj3MAZdWT8CLI2MkZaXbYLTeoDvXesf2eeiLYPBGdx8tIwQJKgV8XdnzH_DONk") + .setRefreshToken("b0KuPuLw77Z0hQhCsK-GTHoEx_kethtn357V7iqwEpCTIsLgqbBC_vQBTGC6M5rINl0FrqHK-D3cbOsMOlfyVKuQPvpyGcLcxAoLOTpYXc28nVwB7iBq2oKj9G9lHkFOUKn") + .build(); + + String playlistId = "5iZh1symrVbgqWNwXACTZ2"; + stubFor(get("/v1/playlists/"+ playlistId + "?fields=description%2Cowner") + .willReturn(aResponse() + .withBody("Too many requests") + .withHeader("Retry-After", "4397") + .withStatus(429))); // With default http client configuration, this property makes the client to block the thread + + GetPlaylistRequest req = spotifyApi.getPlaylist(playlistId) + .fields("description,owner").build(); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + Callable playlistCall = () -> req.execute(); + + try { + Future submit = executor.submit(playlistCall); + Playlist playlist = submit.get(10, TimeUnit.SECONDS); + } catch (ExecutionException e) { + assertEquals(e.getCause().getClass(), TooManyRequestsException.class); + } catch (TimeoutException | InterruptedException e) { + fail("Timeout, the thread blocked"); + } + } @Test public void shouldComplyWithReference() { assertHasAuthorizationHeader(defaultRequest); From 2b2fb4e47c8747a75627d9c1684918832becee7c Mon Sep 17 00:00:00 2001 From: Jonas Thelemann Date: Sun, 1 Oct 2023 23:32:15 +0200 Subject: [PATCH 2/4] chore(deps): update wiremock --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 09edf8d17..1ff466f21 100644 --- a/pom.xml +++ b/pom.xml @@ -100,9 +100,9 @@ test - com.github.tomakehurst - wiremock-jre8 - 2.35.0 + org.wiremock + wiremock + 3.2.0 test From 803a83f9ecd3d2f6e4bb28bae7e1c6799774642b Mon Sep 17 00:00:00 2001 From: Jonas Thelemann Date: Sun, 1 Oct 2023 23:35:21 +0200 Subject: [PATCH 3/4] feat(http-client): use custom request retry strategy --- .../spotify/SpotifyHttpManager.java | 28 ++- .../SpotifyHttpRequestRetryStrategy.java | 224 ++++++++++++++++++ .../playlists/GetPlaylistRequestTest.java | 3 +- 3 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java diff --git a/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java b/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java index 76ececb06..4dc4901aa 100644 --- a/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java +++ b/src/main/java/se/michaelthelin/spotify/SpotifyHttpManager.java @@ -1,6 +1,7 @@ package se.michaelthelin.spotify; import com.google.gson.*; +import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.cache.CacheResponseStatus; @@ -9,6 +10,7 @@ import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.cookie.StandardCookieSpec; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; @@ -17,6 +19,7 @@ import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; import org.apache.hc.core5.http.*; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.util.Timeout; @@ -72,36 +75,45 @@ public SpotifyHttpManager(Builder builder) { ); } + ConnectionConfig connectionConfig = ConnectionConfig + .custom() + .setConnectTimeout(builder.connectTimeout != null + ? Timeout.ofMilliseconds(builder.connectTimeout) + : ConnectionConfig.DEFAULT.getConnectTimeout()) + .build(); + BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(); + connectionManager.setConnectionConfig(connectionConfig); RequestConfig requestConfig = RequestConfig .custom() .setCookieSpec(StandardCookieSpec.STRICT) - .setProxy(proxy) .setConnectionRequestTimeout(builder.connectionRequestTimeout != null ? Timeout.ofMilliseconds(builder.connectionRequestTimeout) : RequestConfig.DEFAULT.getConnectionRequestTimeout()) - .setConnectTimeout(builder.connectTimeout != null - ? Timeout.ofMilliseconds(builder.connectTimeout) - : RequestConfig.DEFAULT.getConnectTimeout()) .setResponseTimeout(builder.socketTimeout != null ? Timeout.ofMilliseconds(builder.socketTimeout) : RequestConfig.DEFAULT.getResponseTimeout()) .build(); + HttpRequestRetryStrategy retryStrategy = new SpotifyHttpRequestRetryStrategy(); this.httpClient = HttpClients .custom() + .disableContentCompression() + .setConnectionManager(connectionManager) .setDefaultCredentialsProvider(credentialsProvider) .setDefaultRequestConfig(requestConfig) - .disableAutomaticRetries() - .disableContentCompression() + .setProxy(proxy) + .setRetryStrategy(retryStrategy) .build(); this.httpClientCaching = CachingHttpClients .custom() .setCacheConfig(cacheConfig) + .disableContentCompression() + .setConnectionManager(connectionManager) .setDefaultCredentialsProvider(credentialsProvider) - .disableAutomaticRetries() .setDefaultRequestConfig(requestConfig) - .disableContentCompression() + .setProxy(proxy) + .setRetryStrategy(retryStrategy) .build(); } diff --git a/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java b/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java new file mode 100644 index 000000000..d942fbf80 --- /dev/null +++ b/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java @@ -0,0 +1,224 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package se.michaelthelin.spotify; + +import org.apache.hc.client5.http.HttpRequestRetryStrategy; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.concurrent.CancellableDependency; +import org.apache.hc.core5.http.*; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.util.Args; +import org.apache.hc.core5.util.TimeValue; + +import javax.net.ssl.SSLException; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.UnknownHostException; +import java.time.Instant; +import java.util.*; + +/** + * Default implementation of the {@link HttpRequestRetryStrategy} interface. + * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public class SpotifyHttpRequestRetryStrategy implements HttpRequestRetryStrategy { + + public static final SpotifyHttpRequestRetryStrategy INSTANCE = new SpotifyHttpRequestRetryStrategy(); + + /** + * Maximum number of allowed retries + */ + private final int maxRetries; + + /** + * Retry interval between subsequent retries + */ + private final TimeValue defaultRetryInterval; + + /** + * Derived {@code IOExceptions} which shall not be retried + */ + private final Set> nonRetriableIOExceptionClasses; + + /** + * HTTP status codes which shall be retried + */ + private final Set retriableCodes; + + protected SpotifyHttpRequestRetryStrategy( + final int maxRetries, + final TimeValue defaultRetryInterval, + final Collection> clazzes, + final Collection codes) { + Args.notNegative(maxRetries, "maxRetries"); + Args.notNegative(defaultRetryInterval.getDuration(), "defaultRetryInterval"); + this.maxRetries = maxRetries; + this.defaultRetryInterval = defaultRetryInterval; + this.nonRetriableIOExceptionClasses = new HashSet<>(clazzes); + this.retriableCodes = new HashSet<>(codes); + } + + /** + * Create the HTTP request retry strategy using the following list of + * non-retriable I/O exception classes:
+ *
    + *
  • InterruptedIOException
  • + *
  • UnknownHostException
  • + *
  • ConnectException
  • + *
  • ConnectionClosedException
  • + *
  • NoRouteToHostException
  • + *
  • SSLException
  • + *
+ *

+ * and retriable HTTP status codes:
+ *

    + *
  • SC_TOO_MANY_REQUESTS (429)
  • + *
  • SC_SERVICE_UNAVAILABLE (503)
  • + *
+ * + * @param maxRetries how many times to retry; 0 means no retries + * @param defaultRetryInterval the default retry interval between + * subsequent retries if the {@code Retry-After} header is not set + * or invalid. + */ + public SpotifyHttpRequestRetryStrategy( + final int maxRetries, + final TimeValue defaultRetryInterval) { + this(maxRetries, defaultRetryInterval, + Arrays.asList( + InterruptedIOException.class, + UnknownHostException.class, + ConnectException.class, + ConnectionClosedException.class, + NoRouteToHostException.class, + SSLException.class), + List.of( + HttpStatus.SC_SERVICE_UNAVAILABLE)); + } + + /** + * Create the HTTP request retry strategy with a max retry count of 1, + * default retry interval of 1 second, and using the following list of + * non-retriable I/O exception classes:
+ *
    + *
  • InterruptedIOException
  • + *
  • UnknownHostException
  • + *
  • ConnectException
  • + *
  • ConnectionClosedException
  • + *
  • SSLException
  • + *
+ *

+ * and retriable HTTP status codes:
+ *

    + *
  • SC_TOO_MANY_REQUESTS (429)
  • + *
  • SC_SERVICE_UNAVAILABLE (503)
  • + *
+ */ + public SpotifyHttpRequestRetryStrategy() { + this(1, TimeValue.ofSeconds(1L)); + } + + @Override + public boolean retryRequest( + final HttpRequest request, + final IOException exception, + final int execCount, + final HttpContext context) { + Args.notNull(request, "request"); + Args.notNull(exception, "exception"); + + if (execCount > this.maxRetries) { + // Do not retry if over max retries + return false; + } + if (this.nonRetriableIOExceptionClasses.contains(exception.getClass())) { + return false; + } else { + for (final Class rejectException : this.nonRetriableIOExceptionClasses) { + if (rejectException.isInstance(exception)) { + return false; + } + } + } + if (request instanceof CancellableDependency && ((CancellableDependency) request).isCancelled()) { + return false; + } + + // Retry if the request is considered idempotent + return handleAsIdempotent(request); + } + + @Override + public boolean retryRequest( + final HttpResponse response, + final int execCount, + final HttpContext context) { + Args.notNull(response, "response"); + + return execCount <= this.maxRetries && retriableCodes.contains(response.getCode()); + } + + @Override + public TimeValue getRetryInterval( + final HttpResponse response, + final int execCount, + final HttpContext context) { + Args.notNull(response, "response"); + + final Header header = response.getFirstHeader(HttpHeaders.RETRY_AFTER); + TimeValue retryAfter = null; + if (header != null) { + final String value = header.getValue(); + try { + retryAfter = TimeValue.ofSeconds(Long.parseLong(value)); + } catch (final NumberFormatException ignore) { + final Instant retryAfterDate = DateUtils.parseStandardDate(value); + if (retryAfterDate != null) { + retryAfter = + TimeValue.ofMilliseconds(retryAfterDate.toEpochMilli() - System.currentTimeMillis()); + } + } + + if (TimeValue.isPositive(retryAfter)) { + return retryAfter; + } + } + return this.defaultRetryInterval; + } + + protected boolean handleAsIdempotent(final HttpRequest request) { + return Method.isIdempotent(request.getMethod()); + } + +} diff --git a/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java b/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java index 76fb186aa..0548524c2 100644 --- a/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java +++ b/src/test/java/se/michaelthelin/spotify/requests/data/playlists/GetPlaylistRequestTest.java @@ -58,7 +58,7 @@ public void shouldThrowTooManyRequestExceptionAndNotBlockThread_WhenSpotifyRetur .fields("description,owner").build(); ExecutorService executor = Executors.newSingleThreadExecutor(); - Callable playlistCall = () -> req.execute(); + Callable playlistCall = req::execute; try { Future submit = executor.submit(playlistCall); @@ -69,6 +69,7 @@ public void shouldThrowTooManyRequestExceptionAndNotBlockThread_WhenSpotifyRetur fail("Timeout, the thread blocked"); } } + @Test public void shouldComplyWithReference() { assertHasAuthorizationHeader(defaultRequest); From 7683a952cc154155e775f98e41aeb23fa70e9d77 Mon Sep 17 00:00:00 2001 From: Jonas Thelemann Date: Tue, 3 Oct 2023 21:46:33 +0200 Subject: [PATCH 4/4] test(http-request-retry-strategy): verify functionality --- .../SpotifyHttpRequestRetryStrategy.java | 24 --- .../TestDefaultHttpRequestRetryStrategy.java | 166 ++++++++++++++++++ 2 files changed, 166 insertions(+), 24 deletions(-) create mode 100644 src/test/java/se/michaelthelin/spotify/TestDefaultHttpRequestRetryStrategy.java diff --git a/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java b/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java index d942fbf80..9eceb57c2 100644 --- a/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java +++ b/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java @@ -28,7 +28,6 @@ package se.michaelthelin.spotify; import org.apache.hc.client5.http.HttpRequestRetryStrategy; -import org.apache.hc.client5.http.utils.DateUtils; import org.apache.hc.core5.annotation.Contract; import org.apache.hc.core5.annotation.ThreadingBehavior; import org.apache.hc.core5.concurrent.CancellableDependency; @@ -43,7 +42,6 @@ import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.UnknownHostException; -import java.time.Instant; import java.util.*; /** @@ -54,8 +52,6 @@ @Contract(threading = ThreadingBehavior.STATELESS) public class SpotifyHttpRequestRetryStrategy implements HttpRequestRetryStrategy { - public static final SpotifyHttpRequestRetryStrategy INSTANCE = new SpotifyHttpRequestRetryStrategy(); - /** * Maximum number of allowed retries */ @@ -103,7 +99,6 @@ protected SpotifyHttpRequestRetryStrategy( *

* and retriable HTTP status codes:
*

    - *
  • SC_TOO_MANY_REQUESTS (429)
  • *
  • SC_SERVICE_UNAVAILABLE (503)
  • *
* @@ -141,7 +136,6 @@ public SpotifyHttpRequestRetryStrategy( *

* and retriable HTTP status codes:
*

    - *
  • SC_TOO_MANY_REQUESTS (429)
  • *
  • SC_SERVICE_UNAVAILABLE (503)
  • *
*/ @@ -196,24 +190,6 @@ public TimeValue getRetryInterval( final HttpContext context) { Args.notNull(response, "response"); - final Header header = response.getFirstHeader(HttpHeaders.RETRY_AFTER); - TimeValue retryAfter = null; - if (header != null) { - final String value = header.getValue(); - try { - retryAfter = TimeValue.ofSeconds(Long.parseLong(value)); - } catch (final NumberFormatException ignore) { - final Instant retryAfterDate = DateUtils.parseStandardDate(value); - if (retryAfterDate != null) { - retryAfter = - TimeValue.ofMilliseconds(retryAfterDate.toEpochMilli() - System.currentTimeMillis()); - } - } - - if (TimeValue.isPositive(retryAfter)) { - return retryAfter; - } - } return this.defaultRetryInterval; } diff --git a/src/test/java/se/michaelthelin/spotify/TestDefaultHttpRequestRetryStrategy.java b/src/test/java/se/michaelthelin/spotify/TestDefaultHttpRequestRetryStrategy.java new file mode 100644 index 000000000..0ff00ff39 --- /dev/null +++ b/src/test/java/se/michaelthelin/spotify/TestDefaultHttpRequestRetryStrategy.java @@ -0,0 +1,166 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package se.michaelthelin.spotify; + +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.utils.DateUtils; +import org.apache.hc.core5.http.ConnectionClosedException; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.util.TimeValue; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +public class TestDefaultHttpRequestRetryStrategy { + + private SpotifyHttpRequestRetryStrategy retryStrategy; + + @BeforeEach + public void setup() { + this.retryStrategy = new SpotifyHttpRequestRetryStrategy(3, TimeValue.ofMilliseconds(1234L)); + } + + @Test + public void testBasics() { + final HttpResponse response1 = new BasicHttpResponse(503, "Oopsie"); + Assertions.assertTrue(this.retryStrategy.retryRequest(response1, 1, null)); + Assertions.assertTrue(this.retryStrategy.retryRequest(response1, 2, null)); + Assertions.assertTrue(this.retryStrategy.retryRequest(response1, 3, null)); + Assertions.assertFalse(this.retryStrategy.retryRequest(response1, 4, null)); + final HttpResponse response2 = new BasicHttpResponse(500, "Big Time Oopsie"); + Assertions.assertFalse(this.retryStrategy.retryRequest(response2, 1, null)); + final HttpResponse response3 = new BasicHttpResponse(429, "Oopsie"); + Assertions.assertFalse(this.retryStrategy.retryRequest(response3, 1, null)); + Assertions.assertFalse(this.retryStrategy.retryRequest(response3, 2, null)); + Assertions.assertFalse(this.retryStrategy.retryRequest(response3, 3, null)); + Assertions.assertFalse(this.retryStrategy.retryRequest(response3, 4, null)); + + Assertions.assertEquals(TimeValue.ofMilliseconds(1234L), this.retryStrategy.getRetryInterval(response1, 1, null)); + } + + @Test + public void testRetryAfterHeaderAsLong() { + final HttpResponse response = new BasicHttpResponse(503, "Oopsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, "321"); + + Assertions.assertEquals(TimeValue.ofMilliseconds(1234L), this.retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void testRetryAfterHeaderAsDate() { + this.retryStrategy = new SpotifyHttpRequestRetryStrategy(3, TimeValue.ZERO_MILLISECONDS); + final HttpResponse response = new BasicHttpResponse(503, "Oopsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, DateUtils.formatStandardDate(Instant.now().plus(100, ChronoUnit.SECONDS))); + + Assertions.assertTrue(this.retryStrategy.getRetryInterval(response, 3, null).compareTo(TimeValue.ZERO_MILLISECONDS) == 0); + } + + @Test + public void testRetryAfterHeaderAsPastDate() { + final HttpResponse response = new BasicHttpResponse(503, "Oopsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, DateUtils.formatStandardDate(Instant.now().minus(100, ChronoUnit.SECONDS))); + + Assertions.assertEquals(TimeValue.ofMilliseconds(1234L), this.retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void testInvalidRetryAfterHeader() { + final HttpResponse response = new BasicHttpResponse(503, "Oopsie"); + response.setHeader(HttpHeaders.RETRY_AFTER, "Stuff"); + + Assertions.assertEquals(TimeValue.ofMilliseconds(1234L), retryStrategy.getRetryInterval(response, 3, null)); + } + + @Test + public void noRetryOnConnectTimeout() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new SocketTimeoutException(), 1, null)); + } + + @Test + public void noRetryOnConnect() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new ConnectException(), 1, null)); + } + + @Test + public void noRetryOnConnectionClosed() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new ConnectionClosedException(), 1, null)); + } + + @Test + public void noRetryForNoRouteToHostException() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new NoRouteToHostException(), 1, null)); + } + + @Test + public void noRetryOnSSLFailure() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new SSLException("encryption failed"), 1, null)); + } + + @Test + public void noRetryOnUnknownHost() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new UnknownHostException(), 1, null)); + } + + @Test + public void noRetryOnAbortedRequests() { + final HttpGet request = new HttpGet("/"); + request.cancel(); + + Assertions.assertFalse(retryStrategy.retryRequest(request, new IOException(), 1, null)); + } + + @Test + public void retryOnNonAbortedRequests() { + final HttpGet request = new HttpGet("/"); + + Assertions.assertTrue(retryStrategy.retryRequest(request, new IOException(), 1, null)); + } + +}