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
 }