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_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_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 extends IOException> 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");
+
+ return this.defaultRetryInterval;
+ }
+
+ protected boolean handleAsIdempotent(final HttpRequest request) {
+ return Method.isIdempotent(request.getMethod());
+ }
+
+}
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));
+ }
+
+}
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..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
@@ -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,42 @@ 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);