From 1fe6c34b58dbe4e0032ce36ff3a4902cc59a0990 Mon Sep 17 00:00:00 2001 From: "Adam J. Weigold" Date: Mon, 5 Feb 2018 17:07:16 -0600 Subject: [PATCH] #53 Implement optional retry logic for CDN failures --- .../kenticocloud/delivery/DeliveryClient.java | 63 +++++++--- .../delivery/DeliveryOptions.java | 17 +++ .../delivery/DeliveryClientTest.java | 111 ++++++++++++++++++ 3 files changed, 173 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/kenticocloud/delivery/DeliveryClient.java b/src/main/java/com/kenticocloud/delivery/DeliveryClient.java index c9d735c4..edefdbd3 100644 --- a/src/main/java/com/kenticocloud/delivery/DeliveryClient.java +++ b/src/main/java/com/kenticocloud/delivery/DeliveryClient.java @@ -55,7 +55,7 @@ public class DeliveryClient { private static final Logger logger = LoggerFactory.getLogger(DeliveryClient.class); - static String SDK_ID; + static String sdkId; static { try { @@ -67,15 +67,15 @@ public class DeliveryClient { repositoryHost = repositoryHost == null ? "localBuild" : repositoryHost; version = version == null ? "0.0.0" : version; packageId = packageId == null ? "com.kenticocloud:delivery-sdk-java" : packageId; - SDK_ID = String.format( + sdkId = String.format( "%s;%s;%s", repositoryHost, packageId, version); - logger.info("SDK ID: {}", SDK_ID); + logger.info("SDK ID: {}", sdkId); } catch (IOException e) { logger.info("Jar manifest read error, setting developer build SDK ID"); - SDK_ID = "localBuild;com.kenticocloud:delivery-sdk-java;0.0.0"; + sdkId = "localBuild;com.kenticocloud:delivery-sdk-java;0.0.0"; } } @@ -140,6 +140,9 @@ public DeliveryClient(DeliveryOptions deliveryOptions, TemplateEngineConfig temp if (deliveryOptions.isUsePreviewApi() && deliveryOptions.getProductionApiKey() != null){ throw new IllegalArgumentException("Cannot provide both a preview API key and a production API key."); } + if (deliveryOptions.getRetryAttempts() < 0) { + throw new IllegalArgumentException("Cannot retry connections less than 0 times."); + } this.deliveryOptions = deliveryOptions; connManager.setMaxTotal(20); connManager.setDefaultMaxPerRoute(20); @@ -393,7 +396,7 @@ protected RequestBuilder addHeaders(RequestBuilder requestBuilder) { ); } requestBuilder.setHeader(HttpHeaders.ACCEPT, "application/json"); - requestBuilder.setHeader("X-KC-SDKID", SDK_ID); + requestBuilder.setHeader("X-KC-SDKID", sdkId); return requestBuilder; } @@ -406,19 +409,43 @@ private String getBaseUrl() { } private T executeRequest(HttpUriRequest request, Class tClass) throws IOException { - String requestUri = request.getURI().toString(); - logger.info("HTTP {} - {} - {}", request.getMethod(), request.getAllHeaders(), requestUri); - JsonNode jsonNode = cacheManager.resolveRequest(requestUri, () -> { - HttpResponse response = httpClient.execute(request); - handleErrorIfNecessary(response); - InputStream inputStream = response.getEntity().getContent(); - JsonNode node = objectMapper.readValue(inputStream, JsonNode.class); - logger.info("{} - {}", response.getStatusLine(), requestUri); - logger.debug("{} - {}:\n{}", request.getMethod(), requestUri, node); - inputStream.close(); - return node; - }); - return objectMapper.treeToValue(jsonNode, tClass); + return executeRequest(request, tClass, 0); + } + + private T executeRequest(HttpUriRequest request, Class tClass, int attemptNumber) throws IOException { + try { + String requestUri = request.getURI().toString(); + logger.info("HTTP {} - {} - {}", request.getMethod(), request.getAllHeaders(), requestUri); + JsonNode jsonNode = cacheManager.resolveRequest(requestUri, () -> { + HttpResponse response = httpClient.execute(request); + handleErrorIfNecessary(response); + InputStream inputStream = response.getEntity().getContent(); + JsonNode node = objectMapper.readValue(inputStream, JsonNode.class); + logger.info("{} - {}", response.getStatusLine(), requestUri); + logger.debug("{} - {}:\n{}", request.getMethod(), requestUri, node); + inputStream.close(); + return node; + }); + return objectMapper.treeToValue(jsonNode, tClass); + } catch (KenticoErrorException kenticoError) { + throw kenticoError; + } catch (Exception ex) { + logger.error("Failed request: {}", ex.getMessage()); + if (attemptNumber < deliveryOptions.getRetryAttempts()) { + int nextAttemptNumber = attemptNumber + 1; + //Perform a binary exponential backoff + int wait = (int) (100 * Math.pow(2, nextAttemptNumber)); + logger.info("Reattempting request after {}ms", wait); + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return executeRequest(request, tClass, nextAttemptNumber); + } else { + throw ex; + } + } } private void handleErrorIfNecessary(HttpResponse response) throws IOException { diff --git a/src/main/java/com/kenticocloud/delivery/DeliveryOptions.java b/src/main/java/com/kenticocloud/delivery/DeliveryOptions.java index 8d40db1c..fcfae87a 100644 --- a/src/main/java/com/kenticocloud/delivery/DeliveryOptions.java +++ b/src/main/java/com/kenticocloud/delivery/DeliveryOptions.java @@ -37,6 +37,7 @@ public class DeliveryOptions { String previewApiKey; boolean usePreviewApi = false; boolean waitForLoadingNewContent = false; + int retryAttempts = 0; /** * Constructs an empty settings instance of {@link DeliveryOptions}. @@ -183,4 +184,20 @@ public boolean isWaitForLoadingNewContent() { public void setWaitForLoadingNewContent(boolean waitForLoadingNewContent) { this.waitForLoadingNewContent = waitForLoadingNewContent; } + + /** + * Get the number of times the client will retry to connect to the Kentico Cloud api on failures per request. + * @return The number of retry attempts on failed responses from Kentico Cloud + */ + public int getRetryAttempts() { + return retryAttempts; + } + + /** + * Sets the number of times the client will try to connect to the Kentico Cloud api on failures per request. + * @param retryAttempts The number of retry attempts. + */ + public void setRetryAttempts(int retryAttempts) { + this.retryAttempts = retryAttempts; + } } diff --git a/src/test/java/com/kenticocloud/delivery/DeliveryClientTest.java b/src/test/java/com/kenticocloud/delivery/DeliveryClientTest.java index c7aec741..18f6738d 100644 --- a/src/test/java/com/kenticocloud/delivery/DeliveryClientTest.java +++ b/src/test/java/com/kenticocloud/delivery/DeliveryClientTest.java @@ -24,6 +24,7 @@ package com.kenticocloud.delivery; +import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -78,6 +79,103 @@ public void testSdkIdHeader() throws Exception { Assert.assertNotNull(item); } + @Test + public void testRetry() throws Exception { + String projectId = "02a70003-e864-464e-b62c-e0ede97deb8c"; + final boolean[] sentError = {false}; + + this.serverBootstrap.registerHandler( + String.format("/%s/%s", projectId, "items/on_roasts"), + (request, response, context) -> { + if (sentError[0]) { + response.setEntity( + new InputStreamEntity( + this.getClass().getResourceAsStream("SampleContentItem.json") + ) + ); + } else { + response.setEntity(new StringEntity("Response Error!")); + sentError[0] = true; + } + }); + HttpHost httpHost = this.start(); + String testServerUri = httpHost.toURI() + "/%s"; + DeliveryOptions deliveryOptions = new DeliveryOptions(); + deliveryOptions.setProjectId(projectId); + deliveryOptions.setProductionEndpoint(testServerUri); + deliveryOptions.setRetryAttempts(1); + + DeliveryClient client = new DeliveryClient(deliveryOptions); + + ContentItemResponse item = client.getItem("on_roasts"); + Assert.assertNotNull(item); + Assert.assertTrue(sentError[0]); + } + + @Test + public void testRetryStops() throws Exception { + String projectId = "02a70003-e864-464e-b62c-e0ede97deb8c"; + final int[] sentErrorCount = {0}; + + this.serverBootstrap.registerHandler( + String.format("/%s/%s", projectId, "items/on_roasts"), + (request, response, context) -> { + response.setEntity(new StringEntity("Response Error!")); + sentErrorCount[0] = sentErrorCount[0] + 1; + }); + HttpHost httpHost = this.start(); + String testServerUri = httpHost.toURI() + "/%s"; + DeliveryOptions deliveryOptions = new DeliveryOptions(); + deliveryOptions.setProjectId(projectId); + deliveryOptions.setProductionEndpoint(testServerUri); + deliveryOptions.setRetryAttempts(1); + + DeliveryClient client = new DeliveryClient(deliveryOptions); + + try { + client.getItem("on_roasts"); + Assert.fail("Expected a failure exception"); + } catch (Exception e) { + Assert.assertTrue(e instanceof JsonParseException); + } + Assert.assertEquals(2, sentErrorCount[0]); + } + + @Test + public void testRetryStopsOnKenticoException() throws Exception { + String projectId = "02a70003-e864-464e-b62c-e0ede97deb8c"; + final int[] sentErrorCount = {0}; + + this.serverBootstrap.registerHandler( + String.format("/%s/%s", projectId, "items/on_roatst"), + (request, response, context) -> { + response.setStatusCode(404); + response.setEntity( + new InputStreamEntity( + this.getClass().getResourceAsStream("SampleKenticoError.json") + ) + ); + sentErrorCount[0] = sentErrorCount[0] + 1; + }); + HttpHost httpHost = this.start(); + String testServerUri = httpHost.toURI() + "/%s"; + DeliveryOptions deliveryOptions = new DeliveryOptions(); + deliveryOptions.setProjectId(projectId); + deliveryOptions.setProductionEndpoint(testServerUri); + deliveryOptions.setRetryAttempts(1); + + DeliveryClient client = new DeliveryClient(deliveryOptions); + + try { + client.getItem("on_roatst"); + Assert.fail("Expected KenticoErrorException"); + } catch (KenticoErrorException e) { + Assert.assertEquals("The requested content item 'on_roatst' was not found.", e.getMessage()); + Assert.assertEquals("The requested content item 'on_roatst' was not found.", e.getKenticoError().getMessage()); + } + Assert.assertEquals(1, sentErrorCount[0]); + } + @Test public void testGetItems() throws Exception { String projectId = "02a70003-e864-464e-b62c-e0ede97deb8c"; @@ -1092,6 +1190,19 @@ public void testExceptionWhenBothProductionApiKeyAndPreviewApiKeyProvided() { } } + @Test + public void testExceptionWhenRetryCountIsLessThanZero() { + DeliveryOptions deliveryOptions = new DeliveryOptions(); + deliveryOptions.setProjectId("02a70003-e864-464e-b62c-e0ede97deb8c"); + deliveryOptions.setRetryAttempts(-1); + try { + DeliveryClient client = new DeliveryClient(deliveryOptions); + Assert.fail("Expected IllegalArgumentException due to negative number provided for retry count"); + } catch (IllegalArgumentException e) { + Assert.assertEquals("Cannot retry connections less than 0 times.", e.getMessage()); + } + } + private Map convertNameValuePairsToMap(List nameValuePairs) { HashMap map = new HashMap<>(); for (NameValuePair nameValuePair : nameValuePairs) {