From 24f8e665d49d730074aac736a1b46bf0244c766a Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Sun, 9 Dec 2012 00:30:35 -0800 Subject: [PATCH] added RetryAfterException and placed in default exception handling --- .../HeaderToRetryAfterException.java | 123 ++++++++++++++++ .../org/jclouds/rest/RetryAfterException.java | 101 ++++++++++++++ .../MapHttp4xxCodesToExceptions.java | 12 ++ .../rest/functions/PropagateIfRetryAfter.java | 36 +++++ .../HeaderToRetryAfterExceptionTest.java | 131 ++++++++++++++++++ .../MapHttp4xxCodesToExceptionsTest.java | 82 ++++++----- 6 files changed, 447 insertions(+), 38 deletions(-) create mode 100644 core/src/main/java/org/jclouds/http/functions/HeaderToRetryAfterException.java create mode 100644 core/src/main/java/org/jclouds/rest/RetryAfterException.java create mode 100644 core/src/main/java/org/jclouds/rest/functions/PropagateIfRetryAfter.java create mode 100644 core/src/test/java/org/jclouds/http/functions/HeaderToRetryAfterExceptionTest.java diff --git a/core/src/main/java/org/jclouds/http/functions/HeaderToRetryAfterException.java b/core/src/main/java/org/jclouds/http/functions/HeaderToRetryAfterException.java new file mode 100644 index 0000000000..22ad810749 --- /dev/null +++ b/core/src/main/java/org/jclouds/http/functions/HeaderToRetryAfterException.java @@ -0,0 +1,123 @@ +/** + * Licensed to jclouds, Inc. (jclouds) under one or more + * contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. jclouds 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. + */ +package org.jclouds.http.functions; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import javax.inject.Inject; + +import org.jclouds.date.DateCodec; +import org.jclouds.date.DateCodecFactory; +import org.jclouds.http.HttpResponse; +import org.jclouds.http.HttpResponseException; +import org.jclouds.rest.RetryAfterException; +import org.jclouds.rest.functions.PropagateIfRetryAfter; + +import com.google.common.annotations.Beta; +import com.google.common.base.Optional; +import com.google.common.base.Ticker; +import com.google.common.net.HttpHeaders; + +/** + * propagates as {@link RetryAfterException} if a Throwable is an + * {@link HttpResponseException} with a {@link HttpResponse set} and a valid + * {@link HttpHeaders#RETRY_AFTER} header set. + * + * @author Adrian Cole + * @see Retry-After + * format + */ +@Beta +public final class HeaderToRetryAfterException implements PropagateIfRetryAfter { + + private final Ticker ticker; + private final DateCodec dateCodec; + + /** + * + * @param ticker + * how to read current time + * @param dateParser + * how to parse the {@link HttpHeaders#RETRY_AFTER} header, if it + * is a Date. + * @return + */ + public static HeaderToRetryAfterException create(Ticker ticker, DateCodec dateCodec) { + return new HeaderToRetryAfterException(ticker, dateCodec); + } + + /** + * uses {@link Ticker#systemTicker()} and {@link DateCodecFactory#rfc822()} + */ + @Inject + private HeaderToRetryAfterException(DateCodecFactory factory) { + this(Ticker.systemTicker(), factory.rfc822()); + } + + private HeaderToRetryAfterException(Ticker ticker, DateCodec dateCodec) { + this.ticker = checkNotNull(ticker, "ticker"); + this.dateCodec = checkNotNull(dateCodec, "dateCodec"); + } + + @Override + public Void apply(Throwable in) { + if (!(in instanceof HttpResponseException)) + return null; + HttpResponse response = HttpResponseException.class.cast(in).getResponse(); + if (response == null) + return null; + + // https://tools.ietf.org/html/rfc2616#section-14.37 + String retryAfter = response.getFirstHeaderOrNull(HttpHeaders.RETRY_AFTER); + if (retryAfter != null) { + Optional retryException = tryCreateRetryAfterException(in, retryAfter); + if (retryException.isPresent()) + throw retryException.get(); + } + + return null; + } + + /** + * returns a {@link RetryAfterException} if parameter {@code retryAfter} + * corresponds to known formats. + * + * @see Retry-After + * format + */ + public Optional tryCreateRetryAfterException(Throwable in, String retryAfter) { + checkNotNull(in, "throwable"); + checkNotNull(retryAfter, "retryAfter"); + + if (retryAfter.matches("^[0-9]+$")) + return Optional.of(new RetryAfterException(in, Integer.parseInt(retryAfter))); + try { + long retryTimeMillis = dateCodec.toDate(retryAfter).getTime(); + long currentTimeMillis = NANOSECONDS.toMillis(ticker.read()); + return Optional.of(new RetryAfterException(in, (int) MILLISECONDS.toSeconds(retryTimeMillis + - currentTimeMillis))); + } catch (IllegalArgumentException e) { + // ignored + } + return Optional.absent(); + } +} \ No newline at end of file diff --git a/core/src/main/java/org/jclouds/rest/RetryAfterException.java b/core/src/main/java/org/jclouds/rest/RetryAfterException.java new file mode 100644 index 0000000000..1000c121ed --- /dev/null +++ b/core/src/main/java/org/jclouds/rest/RetryAfterException.java @@ -0,0 +1,101 @@ +/** + * Licensed to jclouds, Inc. (jclouds) under one or more + * contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. jclouds 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. + */ +package org.jclouds.rest; + +import com.google.common.net.HttpHeaders; +import com.google.common.primitives.Ints; + +/** + * This exception is raised when an http endpoint returns with a response + * telling the caller to make the same request after a certain period of time. + * + * Typically, this is returned with a {@code 503} status code, as specified in + * the {@link HttpHeaders#RETRY_AFTER} header. + */ +public class RetryAfterException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Delta in seconds + */ + private final int seconds; + + /** + * Construct an exception instance to happen at a time in the future + * + * @param message + * message + * @param seconds + * retry after delta. Negative values are converted to zero + */ + public RetryAfterException(String message, int seconds) { + super(message); + this.seconds = Ints.max(seconds, 0); + } + + /** + * Construct an exception instance to happen at a time in the future + * + * @param cause + * cause + * @param seconds + * retry after delta. Negative values are converted to zero + */ + public RetryAfterException(Throwable cause, int seconds) { + super(defaultMessage(seconds = Ints.max(seconds, 0)), cause); + this.seconds = seconds; + } + + private static String defaultMessage(int seconds) { + switch (seconds) { + case 0: + return "retry now"; + case 1: + return "retry in 1 second"; + default: + return String.format("retry in %d seconds", seconds); + } + } + + /** + * Construct an exception instance to happen at a time in the future + * + * @param message + * message + * @param cause + * cause + * @param seconds + * retry after delta. Negative values are converted to zero + */ + public RetryAfterException(String message, Throwable cause, int seconds) { + super(message, cause); + this.seconds = Ints.max(seconds, 0); + } + + /** + * Get the value of the retry time + * + * @return the retry time, in seconds. This is always zero or positive. + */ + public int getSeconds() { + return seconds; + } + +} diff --git a/core/src/main/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptions.java b/core/src/main/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptions.java index 6675519683..a082db6a2b 100644 --- a/core/src/main/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptions.java +++ b/core/src/main/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptions.java @@ -18,6 +18,9 @@ */ package org.jclouds.rest.functions; +import static com.google.common.base.Preconditions.checkNotNull; + +import javax.inject.Inject; import javax.inject.Singleton; import org.jclouds.http.HttpResponseException; @@ -34,7 +37,16 @@ import com.google.common.base.Throwables; @Singleton public class MapHttp4xxCodesToExceptions implements Function { + private final PropagateIfRetryAfter propagateIfRetryAfter; + + @Inject + protected MapHttp4xxCodesToExceptions(PropagateIfRetryAfter propagateIfRetryAfter) { + this.propagateIfRetryAfter = checkNotNull(propagateIfRetryAfter, "propagateIfRetryAfter"); + } + + @Override public Object apply(Exception from) { + propagateIfRetryAfter.apply(from); if (from instanceof HttpResponseException) { HttpResponseException responseException = (HttpResponseException) from; if (responseException.getResponse() != null) diff --git a/core/src/main/java/org/jclouds/rest/functions/PropagateIfRetryAfter.java b/core/src/main/java/org/jclouds/rest/functions/PropagateIfRetryAfter.java new file mode 100644 index 0000000000..d651c380c8 --- /dev/null +++ b/core/src/main/java/org/jclouds/rest/functions/PropagateIfRetryAfter.java @@ -0,0 +1,36 @@ +/** + * Licensed to jclouds, Inc. (jclouds) under one or more + * contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. jclouds 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. + */ +package org.jclouds.rest.functions; + +import org.jclouds.http.functions.HeaderToRetryAfterException; +import org.jclouds.rest.RetryAfterException; + +import com.google.common.base.Function; +import com.google.inject.ImplementedBy; + +/** + * propagates as {@link RetryAfterException} if a Throwable contains information + * such as a retry offset. + * + * @author Adrian Cole + */ +@ImplementedBy(HeaderToRetryAfterException.class) +public interface PropagateIfRetryAfter extends Function { + +} \ No newline at end of file diff --git a/core/src/test/java/org/jclouds/http/functions/HeaderToRetryAfterExceptionTest.java b/core/src/test/java/org/jclouds/http/functions/HeaderToRetryAfterExceptionTest.java new file mode 100644 index 0000000000..4224bce703 --- /dev/null +++ b/core/src/test/java/org/jclouds/http/functions/HeaderToRetryAfterExceptionTest.java @@ -0,0 +1,131 @@ +package org.jclouds.http.functions; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import org.jclouds.date.DateCodec; +import org.jclouds.date.internal.DateServiceDateCodecFactory.DateServiceRfc822Codec; +import org.jclouds.date.internal.SimpleDateFormatDateService; +import org.jclouds.http.HttpCommand; +import org.jclouds.http.HttpRequest; +import org.jclouds.http.HttpResponse; +import org.jclouds.http.HttpResponseException; +import org.jclouds.rest.RetryAfterException; +import org.testng.annotations.Test; + +import com.google.common.base.Function; +import com.google.common.base.Ticker; +import com.google.common.net.HttpHeaders; + +/** + * + * @author Adrian Cole + */ +@Test(groups = "unit") +public class HeaderToRetryAfterExceptionTest { + + public void testArbitraryExceptionDoesntPropagate(){ + fn.apply(new RuntimeException()); + } + + public void testHttpResponseExceptionWithoutResponseDoesntPropagate(){ + fn.apply(new HttpResponseException("message", command, null)); + } + + public void testHttpResponseExceptionWithoutRetryAfterHeaderDoesntPropagate(){ + fn.apply(new HttpResponseException(command, HttpResponse.builder().statusCode(500).build())); + } + + public void testHttpResponseExceptionWithMalformedRetryAfterHeaderDoesntPropagate(){ + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "Fri, 31 Dec 1999 23:59:59 ZBW").build())); + } + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry now") + public void testHttpResponseExceptionWithRetryAfterDate() { + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "Fri, 31 Dec 1999 23:59:59 GMT").build())); + } + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry in 700 seconds") + public void testHttpResponseExceptionWithRetryAfterOffset(){ + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "700").build())); + } + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry in 86400 seconds") + public void testHttpResponseExceptionWithRetryAfterPastIsZero(){ + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "Sun, 2 Jan 2000 00:00:00 GMT").build())); + } + + public static HttpCommand command = new HttpCommand() { + + @Override + public int getRedirectCount() { + return 0; + } + + @Override + public int incrementRedirectCount() { + return 0; + } + + @Override + public boolean isReplayable() { + return false; + } + + @Override + public Exception getException() { + return null; + } + + @Override + public int getFailureCount() { + return 0; + } + + @Override + public int incrementFailureCount() { + return 0; + } + + @Override + public void setException(Exception exception) { + + } + + @Override + public HttpRequest getCurrentRequest() { + return HttpRequest.builder().method("GET").endpoint("http://stub").build(); + } + + @Override + public void setCurrentRequest(HttpRequest request) { + + } + + }; + + static DateCodec rfc822 = new DateServiceRfc822Codec(new SimpleDateFormatDateService()); + + static Ticker y2k = new Ticker(){ + + @Override + public long read() { + return MILLISECONDS.toNanos(rfc822.toDate("Sat, 1 Jan 2000 00:00:00 GMT").getTime()); + } + + }; + + public static HeaderToRetryAfterException fn = HeaderToRetryAfterException.create(y2k, rfc822); + + +} diff --git a/core/src/test/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptionsTest.java b/core/src/test/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptionsTest.java index 7a48286332..365ca92ad4 100644 --- a/core/src/test/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptionsTest.java +++ b/core/src/test/java/org/jclouds/rest/functions/MapHttp4xxCodesToExceptionsTest.java @@ -18,20 +18,16 @@ */ package org.jclouds.rest.functions; -import static org.easymock.EasyMock.createMock; -import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.replay; -import static org.easymock.EasyMock.verify; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.fail; - +import org.jclouds.http.HttpCommand; import org.jclouds.http.HttpResponse; import org.jclouds.http.HttpResponseException; +import org.jclouds.http.functions.HeaderToRetryAfterExceptionTest; import org.jclouds.rest.AuthorizationException; import org.jclouds.rest.ResourceNotFoundException; +import org.jclouds.rest.RetryAfterException; import org.testng.annotations.Test; -import com.google.common.base.Function; +import com.google.common.net.HttpHeaders; /** * @@ -40,42 +36,52 @@ import com.google.common.base.Function; @Test(groups = { "unit" }) public class MapHttp4xxCodesToExceptionsTest { - @Test - public void test401And403ToAuthorizationException() { - assertCodeMakes(401, AuthorizationException.class); - assertCodeMakes(403, AuthorizationException.class); + @Test(expectedExceptions = AuthorizationException.class) + public void test401ToAuthorizationException() { + fn.apply(new HttpResponseException(command, HttpResponse.builder().statusCode(401).build())); } - @Test + @Test(expectedExceptions = AuthorizationException.class) + public void test403ToAuthorizationException() { + fn.apply(new HttpResponseException(command, HttpResponse.builder().statusCode(403).build())); + } + + @Test(expectedExceptions = ResourceNotFoundException.class) public void test404ToResourceNotFoundException() { - assertCodeMakes(404, ResourceNotFoundException.class); + fn.apply(new HttpResponseException(command, HttpResponse.builder().statusCode(404).build())); } - @Test + @Test(expectedExceptions = IllegalStateException.class) public void test409ToIllegalStateException() { - assertCodeMakes(409, IllegalStateException.class); + fn.apply(new HttpResponseException(command, HttpResponse.builder().statusCode(409).build())); } - - private void assertCodeMakes(int statuscode, Class expected) { - Function function = new MapHttp4xxCodesToExceptions(); - HttpResponseException responseException = createMock(HttpResponseException.class); - - HttpResponse response = createMock(HttpResponse.class); - expect(response.getStatusCode()).andReturn(statuscode).atLeastOnce(); - expect(responseException.getResponse()).andReturn(response).atLeastOnce(); - - replay(responseException); - replay(response); - - try { - function.apply(responseException); - fail(); - } catch (Exception e) { - assertEquals(e.getClass(), expected); - } - - verify(responseException); - verify(response); + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry now") + public void testHttpResponseExceptionWithRetryAfterDate() { + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "Fri, 31 Dec 1999 23:59:59 GMT").build())); } - + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry in 700 seconds") + public void testHttpResponseExceptionWithRetryAfterOffset(){ + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "700").build())); + } + + @Test(expectedExceptions = RetryAfterException.class, expectedExceptionsMessageRegExp = "retry in 86400 seconds") + public void testHttpResponseExceptionWithRetryAfterPastIsZero(){ + fn.apply(new HttpResponseException(command, + HttpResponse.builder() + .statusCode(503) + .addHeader(HttpHeaders.RETRY_AFTER, "Sun, 2 Jan 2000 00:00:00 GMT").build())); + } + + MapHttp4xxCodesToExceptions fn = new MapHttp4xxCodesToExceptions(HeaderToRetryAfterExceptionTest.fn); + + HttpCommand command = HeaderToRetryAfterExceptionTest.command; + }