JCLOUDS-589: Reauthenticate on Keystone HTTP 401

This commit ports the Keystone 2.0 fix from JCLOUDS-178 to Keystone
1.1.
This commit is contained in:
Shri Javadekar 2014-06-09 15:10:49 -07:00 committed by Andrew Gaul
parent 5641f675da
commit c417ddef67
5 changed files with 204 additions and 17 deletions

View File

@ -65,10 +65,10 @@ public class RetryOnRenewExpectTest extends BaseCloudServersRestClientExpectTest
assert clientWhenImageExists.deleteImage(11);
}
@Test(expectedExceptions = AuthorizationException.class)
public void testDoesNotReauthenticateOnFatal401() {
public void testReauthenticateOn401ForFailedCommand() {
String requestUrl = "https://lon.servers.api.rackspacecloud.com/v1.0/10001786/images/11?now=1257695648897";
HttpRequest deleteImage = HttpRequest.builder().method("DELETE")
.endpoint("https://lon.servers.api.rackspacecloud.com/v1.0/10001786/images/11?now=1257695648897")
.endpoint(requestUrl)
.addHeader("X-Auth-Token", authToken).build();
HttpResponse unauthResponse = HttpResponse
@ -78,8 +78,31 @@ public class RetryOnRenewExpectTest extends BaseCloudServersRestClientExpectTest
.payload("[{\"unauthorized\":{\"message\":\"Fatal unauthorized.\",\"code\":401}}]")
.build();
CloudServersClient client = orderedRequestsSendResponses(initialAuth, responseWithAuth, deleteImage,
unauthResponse);
// second auth uses same creds as initial one
HttpRequest redoAuth = initialAuth;
String authToken2 = "12345678-9012-47c0-9770-2c5097da25fc";
HttpResponse responseWithUrls2 = responseWithAuth.toBuilder()
.payload(responseWithAuth.getPayload().getRawContent().toString()
.replace(authToken, authToken2)).build();
HttpRequest deleteImage2 = HttpRequest
.builder().method("DELETE")
.endpoint(requestUrl).addHeader("X-Auth-Token", authToken2).build();
HttpResponse imageDeleted = HttpResponse.builder().statusCode(204)
.message("HTTP/1.1 204 No Content").build();
// The sequence of events simulated here is as follows:
// 1. First auth succeeds.
// 2. The token returned in #1 is used in the deleteImage command.
// 3. The deleteImage command fails with a 401 error.
// 4. This should result in a new auth request which succeeds.
// 5. The new token is used in the next deleteImage command.
// 6. Succeed that command.
CloudServersClient client = orderedRequestsSendResponses(initialAuth,
responseWithAuth, deleteImage, unauthResponse, redoAuth,
responseWithUrls2, deleteImage2, imageDeleted);
client.deleteImage(11);
}

View File

@ -19,8 +19,12 @@ package org.jclouds.openstack.handlers;
import static org.jclouds.http.HttpUtils.closeClientButKeepContentStream;
import static org.jclouds.http.HttpUtils.releasePayload;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.inject.Named;
import org.jclouds.Constants;
import org.jclouds.domain.Credentials;
import org.jclouds.http.HttpCommand;
import org.jclouds.http.HttpResponse;
@ -29,8 +33,12 @@ import org.jclouds.logging.Logger;
import org.jclouds.openstack.domain.AuthenticationResponse;
import org.jclouds.openstack.reference.AuthHeaders;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.Multimap;
import com.google.common.util.concurrent.Uninterruptibles;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@ -42,8 +50,23 @@ public class RetryOnRenew implements HttpRetryHandler {
@Resource
protected Logger logger = Logger.NULL;
@VisibleForTesting
@Inject(optional = true)
@Named(Constants.PROPERTY_MAX_RETRIES)
static int NUM_RETRIES = 5;
private final LoadingCache<Credentials, AuthenticationResponse> authenticationResponseCache;
/*
* The reason retries need to be tracked is that it is possible that a token
* can be expired at any time. The reason we track by request is that only
* some requests might return a 401 (such as temporary URLs). However
* consistent failures of the magnitude this code tracks should indicate a
* problem.
*/
private static final Cache<HttpCommand, Integer> retryCountMap = CacheBuilder
.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build();
@Inject
protected RetryOnRenew(LoadingCache<Credentials, AuthenticationResponse> authenticationResponseCache) {
this.authenticationResponseCache = authenticationResponseCache;
@ -61,16 +84,34 @@ public class RetryOnRenew implements HttpRetryHandler {
&& headers.containsKey(AuthHeaders.AUTH_KEY) && !headers.containsKey(AuthHeaders.AUTH_TOKEN)) {
retry = false;
} else {
byte[] content = closeClientButKeepContentStream(response);
if (content != null && new String(content).contains("lease renew")) {
logger.debug("invalidating authentication token");
closeClientButKeepContentStream(response);
// This is not an authentication request returning 401
// Check if we already had seen this request
Integer count = retryCountMap.getIfPresent(command);
if (count == null) {
// First time this non-authentication request failed
logger.debug("invalidating authentication token - first time for %s", command);
retryCountMap.put(command, 1);
authenticationResponseCache.invalidateAll();
retry = true;
} else {
retry = false;
// This request has failed before
if (count + 1 >= NUM_RETRIES) {
logger.debug("too many 401s - giving up after: %s for %s", count, command);
retry = false;
} else {
// Retry just in case
logger.debug("invalidating authentication token - retry %s for %s", count, command);
retryCountMap.put(command, count + 1);
// Wait between retries
authenticationResponseCache.invalidateAll();
Uninterruptibles.sleepUninterruptibly(5, TimeUnit.SECONDS);
retry = true;
}
}
}
break;
break;
}
return retry;

View File

@ -19,6 +19,8 @@ package org.jclouds.openstack.keystone.v1_1.handlers;
import static org.jclouds.http.HttpUtils.closeClientButKeepContentStream;
import static org.jclouds.http.HttpUtils.releasePayload;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import javax.inject.Named;
@ -32,8 +34,12 @@ import org.jclouds.logging.Logger;
import org.jclouds.openstack.keystone.v1_1.domain.Auth;
import org.jclouds.openstack.reference.AuthHeaders;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.Multimap;
import com.google.common.util.concurrent.Uninterruptibles;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@ -42,9 +48,10 @@ import com.google.inject.Singleton;
*/
@Singleton
public class RetryOnRenew implements HttpRetryHandler {
@VisibleForTesting
@Inject(optional = true)
@Named(Constants.PROPERTY_MAX_RETRIES)
private int retryCountLimit = 5;
static int NUM_RETRIES = 5;
@Resource
protected Logger logger = Logger.NULL;
@ -60,6 +67,16 @@ public class RetryOnRenew implements HttpRetryHandler {
this.backoffHandler = backoffHandler;
}
/*
* The reason retries need to be tracked is that it is possible that a token
* can be expired at any time. The reason we track by request is that only
* some requests might return a 401 (such as temporary URLs). However
* consistent failures of the magnitude this code tracks should indicate a
* problem.
*/
private static final Cache<HttpCommand, Integer> retryCountMap = CacheBuilder
.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build();
@Override
public boolean shouldRetryRequest(HttpCommand command, HttpResponse response) {
boolean retry = false; // default
@ -72,13 +89,31 @@ public class RetryOnRenew implements HttpRetryHandler {
&& headers.containsKey(AuthHeaders.AUTH_KEY) && !headers.containsKey(AuthHeaders.AUTH_TOKEN)) {
retry = false;
} else {
byte[] content = closeClientButKeepContentStream(response);
if (content != null && new String(content).contains("lease renew")) {
logger.debug("invalidating authentication token");
closeClientButKeepContentStream(response);
// This is not an authentication request returning 401
// Check if we already had seen this request
Integer count = retryCountMap.getIfPresent(command);
if (count == null) {
// First time this non-authentication request failed
logger.debug("invalidating authentication token - first time for %s", command);
retryCountMap.put(command, 1);
authenticationResponseCache.invalidateAll();
retry = true;
} else {
retry = false;
// This request has failed before
if (count + 1 >= NUM_RETRIES) {
logger.debug("too many 401s - giving up after: %s for %s", count, command);
retry = false;
} else {
// Retry just in case
logger.debug("invalidating authentication token - retry %s for %s", count, command);
retryCountMap.put(command, count + 1);
// Wait between retries
authenticationResponseCache.invalidateAll();
Uninterruptibles.sleepUninterruptibly(5, TimeUnit.SECONDS);
retry = true;
}
}
}
break;

View File

@ -21,6 +21,7 @@ import static org.easymock.EasyMock.expect;
import static org.easymock.EasyMock.expectLastCall;
import static org.easymock.EasyMock.replay;
import static org.easymock.EasyMock.verify;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import org.jclouds.domain.Credentials;
@ -51,7 +52,8 @@ public class RetryOnRenewTest {
cache.invalidateAll();
expectLastCall();
expect(response.getPayload()).andReturn(Payloads.newStringPayload("token expired, please renew")).anyTimes();
expect(response.getPayload()).andReturn(Payloads.newStringPayload(""))
.anyTimes();
expect(response.getStatusCode()).andReturn(401).atLeastOnce();
replay(command);
@ -66,4 +68,53 @@ public class RetryOnRenewTest {
verify(response);
verify(cache);
}
/**
* We have three types of authentication failures: a) When the session
* (token) expires b) When you hit a URL you don't have access to (because of
* permissions) c) When you attempt to authenticate to the service (with bad
* credentials)
*
* In case c), which is detectable, we do not retry, as usually this means
* your credentials are broken. Case a) and b) cannot be distinguished easily
* at this point. Different providers will request token re-authentication in
* different ways (but usually preceded or by an authentication failure). To
* attempt to distinguish between case a) and b) this code tracks failures
* for specific calls. Multiple failures for the same call almost certainly
* indicates a permissions issue. A success results in a successful
* re-authentication.
*/
@Test
public void test401ShouldRetry4Times() {
HttpCommand command = createMock(HttpCommand.class);
HttpRequest request = createMock(HttpRequest.class);
HttpResponse response = createMock(HttpResponse.class);
@SuppressWarnings("unchecked")
LoadingCache<Credentials, AuthenticationResponse> cache = createMock(LoadingCache.class);
expect(command.getCurrentRequest()).andReturn(request).anyTimes();
expect(request.getHeaders()).andStubReturn(null);
cache.invalidateAll();
expectLastCall().anyTimes();
expect(response.getPayload()).andReturn(Payloads.newStringPayload(""))
.anyTimes();
expect(response.getStatusCode()).andReturn(401).anyTimes();
replay(command, request, response, cache);
RetryOnRenew retry = new RetryOnRenew(cache);
for (int n = 0; n < RetryOnRenew.NUM_RETRIES - 1; n++) {
assertTrue(retry.shouldRetryRequest(command, response),
"Expected retry to succeed");
}
assertFalse(retry.shouldRetryRequest(command, response),
"Expected retry to fail on attempt 5");
verify(command, response, cache);
}
}

View File

@ -21,6 +21,7 @@ import static org.easymock.EasyMock.expect;
import static org.easymock.EasyMock.expectLastCall;
import static org.easymock.EasyMock.replay;
import static org.easymock.EasyMock.verify;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import org.jclouds.domain.Credentials;
@ -30,6 +31,7 @@ import org.jclouds.http.HttpResponse;
import org.jclouds.http.handlers.BackoffLimitedRetryHandler;
import org.jclouds.io.Payloads;
import org.jclouds.openstack.keystone.v1_1.domain.Auth;
import org.jclouds.openstack.keystone.v1_1.handlers.RetryOnRenew;
import org.testng.annotations.Test;
import com.google.common.cache.LoadingCache;
@ -71,6 +73,41 @@ public class RetryOnRenewTest {
verify(backoffHandler);
}
@Test
public void test401ShouldRetry4Times() {
HttpCommand command = createMock(HttpCommand.class);
HttpRequest request = createMock(HttpRequest.class);
HttpResponse response = createMock(HttpResponse.class);
@SuppressWarnings("unchecked")
LoadingCache<Credentials, Auth> cache = createMock(LoadingCache.class);
BackoffLimitedRetryHandler backoffHandler = createMock(BackoffLimitedRetryHandler.class);
expect(command.getCurrentRequest()).andReturn(request).anyTimes();
expect(request.getHeaders()).andStubReturn(null);
cache.invalidateAll();
expectLastCall().anyTimes();
expect(response.getPayload()).andReturn(Payloads.newStringPayload(""))
.anyTimes();
expect(response.getStatusCode()).andReturn(401).anyTimes();
replay(command, request, response, cache);
RetryOnRenew retry = new RetryOnRenew(cache, backoffHandler);
for (int i = 0; i < RetryOnRenew.NUM_RETRIES - 1; ++i) {
assertTrue(retry.shouldRetryRequest(command, response),
"Expected retry to succeed");
}
assertFalse(retry.shouldRetryRequest(command, response),
"Expected retry to fail on attempt " + RetryOnRenew.NUM_RETRIES);
verify(command, response, cache);
}
@Test
public void test408ShouldRetry() {
HttpCommand command = createMock(HttpCommand.class);