diff --git a/pom.xml b/pom.xml index b68409980..1ff466f21 100644 --- a/pom.xml +++ b/pom.xml @@ -99,6 +99,12 @@ 2.0.9 test + + org.wiremock + wiremock + 3.2.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..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,34 +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) - .disableContentCompression() + .setProxy(proxy) + .setRetryStrategy(retryStrategy) .build(); this.httpClientCaching = CachingHttpClients .custom() .setCacheConfig(cacheConfig) + .disableContentCompression() + .setConnectionManager(connectionManager) .setDefaultCredentialsProvider(credentialsProvider) .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..9eceb57c2 --- /dev/null +++ b/src/main/java/se/michaelthelin/spotify/SpotifyHttpRequestRetryStrategy.java @@ -0,0 +1,200 @@ +/* + * ==================================================================== + * 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.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.util.*; + +/** + * Default implementation of the {@link HttpRequestRetryStrategy} interface. + * + * @since 5.0 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public class SpotifyHttpRequestRetryStrategy implements HttpRequestRetryStrategy { + + /** + * 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_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 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);