diff --git a/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/functions/OverLimitParser.java b/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/functions/OverLimitParser.java new file mode 100644 index 0000000000..85567e12ea --- /dev/null +++ b/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/functions/OverLimitParser.java @@ -0,0 +1,94 @@ +/** + * 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.openstack.nova.v2_0.functions; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Map; + +import javax.annotation.Resource; +import javax.inject.Inject; + +import org.jclouds.json.Json; +import org.jclouds.logging.Logger; + +import com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; + +/** + * + * The expected body contains the time as in this (real) response + * + *
+ *   {
+ * "overLimit" : {
+ *  "code" : 413,
+ *  "message" : "OverLimit Retry...",
+ *  "details" : "Error Details...",
+ *  "retryAt" : "2012-11-14T21:51:28UTC"
+ *  }
+ * }
+ * 
+ * + * or + * + *
+ *    {
+ *      "overLimit": {
+ *        "message": "This request was rate-limited.",
+ *        "code": 413,
+ *        "retryAfter": "54",
+ *        "details": "Only 1 POST request(s) can be made to \"*\" every minute."
+ *      }
+ *    }
+ * 
+ * + * @author Adrian Cole, Steve Loughran + * + */ +public class OverLimitParser implements Function> { + + @Resource + private Logger logger = Logger.NULL; + private final Json json; + + @Inject + public OverLimitParser(Json json) { + this.json = checkNotNull(json, "json"); + } + + private static class Holder { + Map overLimit = ImmutableMap.of(); + } + + /** + * parses or returns an empty map. + */ + @Override + public Map apply(String in) { + try { + return json.fromJson(in, OverLimitParser.Holder.class).overLimit; + } catch (RuntimeException e) { + // an error was raised during parsing -which can include badly + // formatted fields. + logger.error("Failed to parse " + in + "", e); + return ImmutableMap.of(); + } + } +} \ No newline at end of file diff --git a/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandler.java b/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandler.java index 5afd04196c..6592c7d6b7 100644 --- a/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandler.java +++ b/apis/openstack-nova/src/main/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandler.java @@ -18,37 +18,72 @@ */ package org.jclouds.openstack.nova.v2_0.handlers; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Predicates.in; +import static com.google.common.base.Strings.emptyToNull; +import static com.google.common.collect.Maps.filterKeys; import static org.jclouds.http.HttpUtils.closeClientButKeepContentStream; +import java.util.Set; + +import javax.annotation.Resource; +import javax.inject.Inject; import javax.inject.Singleton; +import org.jclouds.date.DateCodecFactory; import org.jclouds.http.HttpCommand; import org.jclouds.http.HttpErrorHandler; import org.jclouds.http.HttpResponse; import org.jclouds.http.HttpResponseException; +import org.jclouds.http.functions.HeaderToRetryAfterException; +import org.jclouds.logging.Logger; +import org.jclouds.openstack.nova.v2_0.functions.OverLimitParser; import org.jclouds.rest.AuthorizationException; import org.jclouds.rest.InsufficientResourcesException; import org.jclouds.rest.ResourceNotFoundException; +import org.jclouds.rest.RetryAfterException; + +import com.google.common.base.Optional; +import com.google.common.base.Ticker; +import com.google.common.collect.ImmutableSet; /** * This will parse and set an appropriate exception on the command object. * - * @author Adrian Cole + * @author Adrian Cole, Steve Loughran * */ // TODO: is there error spec someplace? let's type errors, etc. @Singleton public class NovaErrorHandler implements HttpErrorHandler { + @Resource + protected Logger logger = Logger.NULL; + protected final HeaderToRetryAfterException retryAfterParser; + protected final OverLimitParser overLimitParser; + + protected NovaErrorHandler(HeaderToRetryAfterException retryAfterParser, OverLimitParser overLimitParser) { + this.retryAfterParser = checkNotNull(retryAfterParser, "retryAfterParser"); + this.overLimitParser = checkNotNull(overLimitParser, "overLimitParser"); + } + + /** + * in current format, retryAt has a value of {@code 2012-11-14T21:51:28UTC}, which is an ISO-8601 seconds (not milliseconds) format. + */ + @Inject + public NovaErrorHandler(DateCodecFactory factory, OverLimitParser overLimitParser) { + this(HeaderToRetryAfterException.create(Ticker.systemTicker(), factory.iso8601Seconds()), overLimitParser); + } + public void handleError(HttpCommand command, HttpResponse response) { // it is important to always read fully and close streams byte[] data = closeClientButKeepContentStream(response); - String message = data != null ? new String(data) : null; + String content = data != null ? emptyToNull(new String(data)) : null; - Exception exception = message != null ? new HttpResponseException(command, response, message) - : new HttpResponseException(command, response); - message = message != null ? message : String.format("%s -> %s", command.getCurrentRequest().getRequestLine(), - response.getStatusLine()); + Exception exception = content != null ? new HttpResponseException(command, response, content) + : new HttpResponseException(command, response); + String requestLine = command.getCurrentRequest().getRequestLine(); + String message = content != null ? content : String.format("%s -> %s", requestLine, response.getStatusLine()); switch (response.getStatusCode()) { case 400: if (message.indexOf("quota exceeded") != -1) @@ -68,10 +103,29 @@ public class NovaErrorHandler implements HttpErrorHandler { } break; case 413: - exception = new InsufficientResourcesException(message, exception); - break; + if (content == null) { + exception = new InsufficientResourcesException(message, exception); + break; + } + exception = parseAndBuildRetryException(content, message, exception); } command.setException(exception); } + /** + * Build an exception from the response. If it contains the JSON payload then + * that is parsed to create a {@link RetryAfterException}, otherwise a + * {@link InsufficientResourcesException} is returned + * + */ + private Exception parseAndBuildRetryException(String json, String message, Exception exception) { + Set retryFields = ImmutableSet.of("retryAfter", "retryAt"); + for (String value : filterKeys(overLimitParser.apply(json), in(retryFields)).values()) { + Optional retryException = retryAfterParser.tryCreateRetryAfterException(exception, value); + if (retryException.isPresent()) + return retryException.get(); + } + return new InsufficientResourcesException(message, exception); + } + } diff --git a/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/NovaErrorHandlerTest.java b/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/NovaErrorHandlerTest.java deleted file mode 100644 index 2691715488..0000000000 --- a/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/NovaErrorHandlerTest.java +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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.openstack.nova.v2_0; - -import static org.easymock.EasyMock.createMock; -import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.replay; -import static org.easymock.EasyMock.reportMatcher; -import static org.easymock.EasyMock.verify; - -import java.net.URI; - -import org.easymock.IArgumentMatcher; -import org.jclouds.http.HttpCommand; -import org.jclouds.http.HttpRequest; -import org.jclouds.http.HttpResponse; -import org.jclouds.openstack.nova.v2_0.handlers.NovaErrorHandler; -import org.jclouds.rest.AuthorizationException; -import org.jclouds.rest.InsufficientResourcesException; -import org.jclouds.rest.ResourceNotFoundException; -import org.testng.annotations.Test; - -import com.google.inject.Guice; - -/** - * - * @author Adrian Cole - */ -@Test(groups = { "unit" }) -public class NovaErrorHandlerTest { - - @Test - public void test401MakesAuthorizationException() { - assertCodeMakes("GET", URI.create("https://api.openstack.nova.com/foo"), 401, "", "Unauthorized", - AuthorizationException.class); - } - - @Test - public void test400MakesIllegalStateExceptionOnQuotaExceededOnNoFixedIps() { - // should wait until ips are associated w/the server - assertCodeMakes( - "POST", - URI.create("https://az-1.region-a.geo-1.compute.hpcloudsvc.com/v1.1/37936628937291/servers/71554/action"), - 400, - "HTTP/1.1 400 Bad Request", - "{\"badRequest\": {\"message\": \"instance |71554| has no fixed_ips. unable to associate floating ip\", \"code\": 400}}", - IllegalStateException.class); - } - - @Test - public void test400MakesIllegalStateExceptionOnAlreadyExists() { - assertCodeMakes( - "POST", - URI.create("https://az-1.region-a.geo-1.compute.hpcloudsvc.com/v1.1/37936628937291/servers"), - 400, - "HTTP/1.1 400 Bad Request", - "{\"badRequest\": {\"message\": \"Server with the name 'test' already exists\", \"code\": 400}}", - IllegalStateException.class); - } - - @Test - public void test400MakesInsufficientResourcesExceptionOnQuotaExceeded() { - assertCodeMakes( - "POST", - URI.create("https://az-1.region-a.geo-1.compute.hpcloudsvc.com/v1.1/37936628937291/os-floating-ips"), - 400, - "HTTP/1.1 400 Bad Request", - "{\"badRequest\": {\"message\": \"AddressLimitExceeded: Address quota exceeded. You cannot create any more addresses\", \"code\": 400}}", - InsufficientResourcesException.class); - } - - @Test - public void test413MakesInsufficientResourcesException() { - assertCodeMakes( - "POST", - URI.create("https://az-1.region-a.geo-1.compute.hpcloudsvc.com/v1.1/37936628937291/os-volumes"), - 413, - "HTTP/1.1 413 Request Entity Too Large", - "{\"badRequest\": {\"message\": \"Volume quota exceeded. You cannot create a volume of size 1G\", \"code\": 413, \"retryAfter\": 0}}", - InsufficientResourcesException.class); - } - - @Test - public void test404MakesResourceNotFoundException() { - assertCodeMakes("GET", URI.create("https://api.openstack.nova.com/foo"), 404, "", "Not Found", - ResourceNotFoundException.class); - } - - private void assertCodeMakes(String method, URI uri, int statusCode, String message, String content, - Class expected) { - assertCodeMakes(method, uri, statusCode, message, "text/json", content, expected); - } - - private void assertCodeMakes(String method, URI uri, int statusCode, String message, String contentType, - String content, Class expected) { - - NovaErrorHandler function = Guice.createInjector().getInstance(NovaErrorHandler.class); - - HttpCommand command = createMock(HttpCommand.class); - HttpRequest request = HttpRequest.builder().method(method).endpoint(uri).build(); - HttpResponse response = HttpResponse.builder().statusCode(statusCode).message(message).payload(content).build(); - response.getPayload().getContentMetadata().setContentType(contentType); - - expect(command.getCurrentRequest()).andReturn(request).atLeastOnce(); - command.setException(classEq(expected)); - - replay(command); - - function.handleError(command, response); - - verify(command); - } - - public static Exception classEq(final Class in) { - reportMatcher(new IArgumentMatcher() { - - @Override - public void appendTo(StringBuffer buffer) { - buffer.append("classEq("); - buffer.append(in); - buffer.append(")"); - } - - @Override - public boolean matches(Object arg) { - return arg.getClass() == in; - } - - }); - return null; - } - -} diff --git a/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandlerTest.java b/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandlerTest.java new file mode 100644 index 0000000000..08c8fe85c0 --- /dev/null +++ b/apis/openstack-nova/src/test/java/org/jclouds/openstack/nova/v2_0/handlers/NovaErrorHandlerTest.java @@ -0,0 +1,276 @@ +/** + * 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.openstack.nova.v2_0.handlers; + +import static org.testng.Assert.assertEquals; + +import java.util.concurrent.TimeUnit; + +import org.jclouds.date.DateCodec; +import org.jclouds.date.internal.DateServiceDateCodecFactory.DateServiceIso8601SecondsCodec; +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.functions.HeaderToRetryAfterException; +import org.jclouds.json.internal.GsonWrapper; +import org.jclouds.openstack.nova.v2_0.functions.OverLimitParser; +import org.jclouds.rest.AuthorizationException; +import org.jclouds.rest.InsufficientResourcesException; +import org.jclouds.rest.ResourceNotFoundException; +import org.jclouds.rest.RetryAfterException; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +import com.google.common.base.Ticker; +import com.google.gson.Gson; + +/** + * + * @author Adrian Cole, Steve Loughran + */ +@Test(groups = { "unit" }) +public class NovaErrorHandlerTest { + + private HttpCommand command; + + @BeforeTest + void setupCommand(){ + command = command(); + } + + @Test + public void test401MakesAuthorizationException() { + fn.handleError(command, HttpResponse.builder().statusCode(401).message("Unauthorized").build()); + + assertEquals(command.getException().getClass(), AuthorizationException.class); + assertEquals(command.getException().getMessage(), + "POST https://nova/v1.1/servers HTTP/1.1 -> HTTP/1.1 401 Unauthorized"); + } + + @Test + public void test404MakesResourceNotFoundException() { + fn.handleError(command, HttpResponse.builder().statusCode(404).message("Not Found").build()); + + assertEquals(command.getException().getClass(), ResourceNotFoundException.class); + assertEquals(command.getException().getMessage(), + "POST https://nova/v1.1/servers HTTP/1.1 -> HTTP/1.1 404 Not Found"); + } + + // should wait until ips are associated w/the server + HttpResponse noFixedIps = HttpResponse.builder().statusCode(400) + .message("HTTP/1.1 400 Bad Request") + .payload("{\"badRequest\": {\"message\": "+ + "\"instance |71554| has no fixed_ips. unable to associate floating ip\", \"code\": 400}}") + .build(); + + @Test + public void test400MakesIllegalStateExceptionOnQuotaExceededOnNoFixedIps() { + fn.handleError(command, noFixedIps); + + assertEquals(command.getException().getClass(), IllegalStateException.class); + assertEquals(command.getException().getMessage(), noFixedIps.getPayload().getRawContent()); + } + + HttpResponse alreadyExists = HttpResponse.builder().statusCode(400) + .message("HTTP/1.1 400 Bad Request") + .payload("{\"badRequest\": {\"message\": \"Server with the name 'test' already exists\", \"code\": 400}}") + .build(); + + @Test + public void test400MakesIllegalStateExceptionOnAlreadyExists() { + fn.handleError(command, alreadyExists); + + assertEquals(command.getException().getClass(), IllegalStateException.class); + assertEquals(command.getException().getMessage(), alreadyExists.getPayload().getRawContent()); + } + + HttpResponse quotaExceeded = HttpResponse.builder().statusCode(400) + .message("HTTP/1.1 400 Bad Request") + .payload("{\"badRequest\": {\"message\": \"AddressLimitExceeded: Address quota exceeded. " + + "You cannot create any more addresses\", \"code\": 400}}") + .build(); + + @Test + public void test400MakesInsufficientResourcesExceptionOnQuotaExceeded() { + fn.handleError(command, quotaExceeded); + + assertEquals(command.getException().getClass(), InsufficientResourcesException.class); + assertEquals(command.getException().getMessage(), quotaExceeded.getPayload().getRawContent()); + } + + HttpResponse tooLarge = HttpResponse.builder().statusCode(413) + .message("HTTP/1.1 413 Request Entity Too Large") + .payload("{\"badRequest\": {\"message\": \"Volume quota exceeded. You cannot create a volume of size 1G\", " + + "\"code\": 413, \"retryAfter\": 0}}") + .build(); + + @Test + public void test413MakesInsufficientResourcesException() { + fn.handleError(command, tooLarge); + + assertEquals(command.getException().getClass(), InsufficientResourcesException.class); + assertEquals(command.getException().getMessage(), tooLarge.getPayload().getRawContent()); + } + + /** + * Reponse received from Rackspace UK on November 14, 2012. + */ + HttpResponse retryAt = HttpResponse.builder().statusCode(413) + .message("HTTP/1.1 413 Request Entity Too Large") + .payload("{ 'overLimit' : { 'code' : 413," + + " 'message' : 'OverLimit Retry...', " + + " 'details' : 'Error Details...'," + + " 'retryAt' : '2012-11-14T21:51:28UTC' }}") + .build(); + + @Test + public void test413WithRetryAtExceptionParsesDelta() { + fn.handleError(command, retryAt); + + assertEquals(command.getException().getClass(), RetryAfterException.class); + assertEquals(command.getException().getMessage(), "retry in 3600 seconds"); + } + + /** + * Folsom response. This contains a delta in seconds to retry after, not a + * fixed time. + * + */ + HttpResponse retryAfter = HttpResponse.builder().statusCode(413) + .message("HTTP/1.1 413 Request Entity Too Large") + .payload("{ 'overLimit': { 'message': 'This request was rate-limited.', " + + " 'retryAfter': '54', " + + " 'details': 'Only 1 POST request(s) can be made to \\'*\\' every minute.'" + " }}") + .build(); + + @Test + public void test413WithRetryAfterExceptionFolsom() { + fn.handleError(command, retryAfter); + + assertEquals(command.getException().getClass(), RetryAfterException.class); + assertEquals(command.getException().getMessage(), "retry in 54 seconds"); + } + + /** + * Folsom response with a retryAt field inserted -at a different date. This + * can be used to verify that the retryAfter field is picked up first + */ + HttpResponse retryAfterTrumps = HttpResponse.builder().statusCode(413) + .message("HTTP/1.1 413 Request Entity Too Large") + .payload("{ 'overLimit': {" + + " 'message': 'This request was rate-limited.', " + + " 'retryAfter': '54', " + + " 'retryAt' : '2012-11-14T21:51:28UTC'," + + " 'details': 'Only 1 POST request(s) can be made to \\'*\\' every minute.' }}") + .build(); + + @Test + public void test413WithRetryAfterTrumpsRetryAt() { + fn.handleError(command, retryAfterTrumps); + + assertEquals(command.getException().getClass(), RetryAfterException.class); + assertEquals(command.getException().getMessage(), "retry in 54 seconds"); + } + + HttpResponse badRetryAt = HttpResponse.builder().statusCode(413) + .message("HTTP/1.1 413 Request Entity Too Large") + .payload("{ 'overLimit' : { 'code' : 413," + + " 'message' : 'OverLimit Retry...', " + + " 'details' : 'Error Details...'," + + " 'retryAt' : '2012-11-~~~:51:28UTC' }}") + .build(); + + @Test + public void test413WithBadRetryAtFormatFallsBack() { + fn.handleError(command, badRetryAt); + + assertEquals(command.getException().getClass(), InsufficientResourcesException.class); + assertEquals(command.getException().getMessage(), badRetryAt.getPayload().getRawContent()); + } + + + DateCodec iso8601Seconds = new DateServiceIso8601SecondsCodec(new SimpleDateFormatDateService()); + + Ticker y2k = new Ticker(){ + + @Override + public long read() { + return TimeUnit.MILLISECONDS.toNanos(iso8601Seconds.toDate("2012-11-14T20:51:28UTC").getTime()); + } + + }; + + NovaErrorHandler fn = new NovaErrorHandler(HeaderToRetryAfterException.create(y2k, iso8601Seconds), + new OverLimitParser(new GsonWrapper(new Gson()))); + + private HttpCommand command() { + return new HttpCommand() { + + private Exception exception; + + @Override + public int getRedirectCount() { + return 0; + } + + @Override + public int incrementRedirectCount() { + return 0; + } + + @Override + public boolean isReplayable() { + return false; + } + + @Override + public Exception getException() { + return exception; + } + + @Override + public int getFailureCount() { + return 0; + } + + @Override + public int incrementFailureCount() { + return 0; + } + + @Override + public void setException(Exception exception) { + this.exception = exception; + } + + @Override + public HttpRequest getCurrentRequest() { + return HttpRequest.builder().method("POST").endpoint("https://nova/v1.1/servers").build(); + } + + @Override + public void setCurrentRequest(HttpRequest request) { + + } + + }; + } + +}