Android OkHttp, обновленный токен обновления
Сценарий. Я использую OkHttp/Retrofit для доступа к веб-службе: одновременно отправляются несколько HTTP-запросов. В какой-то момент токен аутентификации истекает, и несколько запросов получат ответ 401.
Проблема. В моей первой реализации я использую перехватчик (здесь упрощенный), и каждый поток пытается обновить токен. Это приводит к беспорядку.
public class SignedRequestInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
// 1. sign this request
request = request.newBuilder()
.header(AUTH_HEADER_KEY, BEARER_HEADER_VALUE + token)
.build();
// 2. proceed with the request
Response response = chain.proceed(request);
// 3. check the response: have we got a 401?
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
// ... try to refresh the token
newToken = mAuthService.refreshAccessToken(..);
// sign the request with the new token and proceed
Request newRequest = request.newBuilder()
.removeHeader(AUTH_HEADER_KEY)
.addHeader(AUTH_HEADER_KEY, BEARER_HEADER_VALUE + newToken.getAccessToken())
.build();
// return the outcome of the newly signed request
response = chain.proceed(newRequest);
}
return response;
}
}
Желаемое решение: все потоки должны ждать обновления одного токена: первый запрос об ошибке запускает обновление, а вместе с другими запросами ждет новый токен.
Что такое хороший способ для этого? Могут ли помочь некоторые встроенные функции OkHttp (например, Authenticator)? Спасибо за любой намек.
Ответы
Ответ 1
Спасибо за ваши ответы - они привели меня к решению. Я закончил использование блокировки ConditionVariable
и AtomicBoolean. Вот как вы можете это сделать: прочитайте комментарии.
/**
* This class has two tasks:
* 1) sign requests with the auth token, when available
* 2) try to refresh a new token
*/
public class SignedRequestInterceptor implements Interceptor {
// these two static variables serve for the pattern to refresh a token
private final static ConditionVariable LOCK = new ConditionVariable(true);
private static final AtomicBoolean mIsRefreshing = new AtomicBoolean(false);
...
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Request request = chain.request();
// 1. sign this request
....
// 2. proceed with the request
Response response = chain.proceed(request);
// 3. check the response: have we got a 401?
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
if (!TextUtils.isEmpty(token)) {
/*
* Because we send out multiple HTTP requests in parallel, they might all list a 401 at the same time.
* Only one of them should refresh the token, because otherwise we'd refresh the same token multiple times
* and that is bad. Therefore we have these two static objects, a ConditionVariable and a boolean. The
* first thread that gets here closes the ConditionVariable and changes the boolean flag.
*/
if (mIsRefreshing.compareAndSet(false, true)) {
LOCK.close();
// we're the first here. let refresh this token.
// it looks like our token isn't valid anymore.
mAccountManager.invalidateAuthToken(AuthConsts.ACCOUNT_TYPE, token);
// do we have an access token to refresh?
String refreshToken = mAccountManager.getUserData(account, HorshaAuthenticator.KEY_REFRESH_TOKEN);
if (!TextUtils.isEmpty(refreshToken)) {
.... // refresh token
}
LOCK.open();
mIsRefreshing.set(false);
} else {
// Another thread is refreshing the token for us, let wait for it.
boolean conditionOpened = LOCK.block(REFRESH_WAIT_TIMEOUT);
// If the next check is false, it means that the timeout expired, that is - the refresh
// stuff has failed. The thread in charge of refreshing the token has taken care of
// redirecting the user to the login activity.
if (conditionOpened) {
// another thread has refreshed this for us! thanks!
....
// sign the request with the new token and proceed
// return the outcome of the newly signed request
response = chain.proceed(newRequest);
}
}
}
}
// check if still unauthorized (i.e. refresh failed)
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
... // clean your access token and prompt user for login again.
}
// returning the response to the original request
return response;
}
}
Ответ 2
Вы не должны использовать перехватчики или реализовывать логику повторения самостоятельно, так как это приводит к лабиринту рекурсивных проблем.
Вместо этого реализуем okhttp Authenticator
, который предоставляется специально для решения этой проблемы:
okHttpClient.setAuthenticator(...);
Ответ 3
У меня была та же проблема, и мне удалось ее решить, используя ReentrantLock.
import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import timber.log.Timber;
public class RefreshTokenInterceptor implements Interceptor {
private Lock lock = new ReentrantLock();
@Override
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
// first thread will acquire the lock and start the refresh token
if (lock.tryLock()) {
Timber.i("refresh token thread holds the lock");
try {
// this sync call will refresh the token and save it for
// later use (e.g. sharedPreferences)
authenticationService.refreshTokenSync();
Request newRequest = recreateRequestWithNewAccessToken(chain);
return chain.proceed(newRequest);
} catch (ServiceException exception) {
// depending on what you need to do you can logout the user at this
// point or throw an exception and handle it in your onFailure callback
return response;
} finally {
Timber.i("refresh token finished. release lock");
lock.unlock();
}
} else {
Timber.i("wait for token to be refreshed");
lock.lock(); // this will block the thread until the thread that is refreshing
// the token will call .unlock() method
lock.unlock();
Timber.i("token refreshed. retry request");
Request newRequest = recreateRequestWithNewAccessToken(chain);
return chain.proceed(newRequest);
}
} else {
return response;
}
}
private Request recreateRequestWithNewAccessToken(Chain chain) {
String freshAccessToken = sharedPreferences.getAccessToken();
Timber.d("[freshAccessToken] %s", freshAccessToken);
return chain.request().newBuilder()
.header("access_token", freshAccessToken)
.build();
}
}
Основное преимущество использования этого решения заключается в том, что вы можете написать unit test с помощью mockito и протестировать его. Вам нужно будет включить функцию инкубации Mockito для насмешек финальных классов (ответ от okhttp). Подробнее о здесь.
Тест выглядит примерно так:
@RunWith(MockitoJUnitRunner.class)
public class RefreshTokenInterceptorTest {
private static final String FRESH_ACCESS_TOKEN = "fresh_access_token";
@Mock
AuthenticationService authenticationService;
@Mock
RefreshTokenStorage refreshTokenStorage;
@Mock
Interceptor.Chain chain;
@BeforeClass
public static void setup() {
Timber.plant(new Timber.DebugTree() {
@Override
protected void log(int priority, String tag, String message, Throwable t) {
System.out.println(Thread.currentThread() + " " + message);
}
});
}
@Test
public void refreshTokenInterceptor_works_as_expected() throws IOException, InterruptedException {
Response unauthorizedResponse = createUnauthorizedResponse();
when(chain.proceed((Request) any())).thenReturn(unauthorizedResponse);
when(authenticationService.refreshTokenSync()).thenAnswer(new Answer<Boolean>() {
@Override
public Boolean answer(InvocationOnMock invocation) throws Throwable {
//refresh token takes some time
Thread.sleep(10);
return true;
}
});
when(refreshTokenStorage.getAccessToken()).thenReturn(FRESH_ACCESS_TOKEN);
Request fakeRequest = createFakeRequest();
when(chain.request()).thenReturn(fakeRequest);
final Interceptor interceptor = new RefreshTokenInterceptor(authenticationService, refreshTokenStorage);
Timber.d("5 requests try to refresh token at the same time");
final CountDownLatch countDownLatch5 = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
interceptor.intercept(chain);
countDownLatch5.countDown();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
}
countDownLatch5.await();
verify(authenticationService, times(1)).refreshTokenSync();
Timber.d("next time another 3 threads try to refresh the token at the same time");
final CountDownLatch countDownLatch3 = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
interceptor.intercept(chain);
countDownLatch3.countDown();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
}
countDownLatch3.await();
verify(authenticationService, times(2)).refreshTokenSync();
Timber.d("1 thread tries to refresh the token");
interceptor.intercept(chain);
verify(authenticationService, times(3)).refreshTokenSync();
}
private Response createUnauthorizedResponse() throws IOException {
Response response = mock(Response.class);
when(response.code()).thenReturn(401);
return response;
}
private Request createFakeRequest() {
Request request = mock(Request.class);
Request.Builder fakeBuilder = createFakeBuilder();
when(request.newBuilder()).thenReturn(fakeBuilder);
return request;
}
private Request.Builder createFakeBuilder() {
Request.Builder mockBuilder = mock(Request.Builder.class);
when(mockBuilder.header("access_token", FRESH_ACCESS_TOKEN)).thenReturn(mockBuilder);
return mockBuilder;
}
}
Ответ 4
Если вы не хотите, чтобы ваши потоки блокировались, а первый обновлял токен, вы можете использовать синхронизированный блок.
private final static Object lock = new Object();
private static long lastRefresh;
...
synchronized(lock){ // lock all thread untill token is refreshed
// only the first thread does the w refresh
if(System.currentTimeMillis()-lastRefresh>600000){
token = refreshToken();
lastRefresh=System.currentTimeMillis();
}
}
Здесь 600000 (10 минут) произвольно, это число должно быть большим, чтобы предотвратить многократный вызов обновления и меньше, чем время истечения маркера, чтобы вы вызывали обновление при истечении токена.
Ответ 5
Отредактировано для безопасности потоков
Havent посмотрел на OkHttp или модифицировал, но как насчет того, чтобы установить статический флаг, который установлен, как только токен сработает, и проверить этот флаг перед запросом нового токена?
private static AtomicBoolean requestingToken = new AtomicBoolean(false);
//.....
if (requestingToken.get() == false)
{
requestingToken.set(true);
//.... request a new token
}