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 (например, Аутентификатор)? Спасибо за любой намек.

5 ответов


вы не должны использовать перехватчики или реализовать логику повтора самостоятельно, так как это ведет к лабиринту рекурсивных вопросов.

вместо реализации okhttp в Authenticator, которая предоставлена специально для решения этой проблемы:

okHttpClient.setAuthenticator(...);

Спасибо за ваши ответы - они привели меня к решению. В итоге я использовал 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's 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's 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;
    }
}

у меня была такая же проблема и мне удалось решить с помощью 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();
    }
}

основным преимуществом использования этого решения является то, что вы можете написать модульный тест с помощью 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;
    }

}

Если вы не хотите, чтобы ваши потоки bock в то время как первый обновляет токен, вы можете использовать синхронизированный блок.

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 мин) произвольно это число должно быть большим enouth, чтобы предотвратить вызов обновления muliple и меньше, чем время истечения срока действия маркера, так что вы вызываете обновление, когда токен истекает.


отредактировано для безопасности потоков

Havent посмотрел на OkHttp или retrofit, но как насчет статического флага, который устанавливается, как только токен терпит неудачу, и проверяет этот флаг, прежде чем запрашивать новый токен?

private static AtomicBoolean requestingToken = new AtomicBoolean(false);

//..... 
if (requestingToken.get() == false)
 {
    requestingToken.set(true);
    //.... request a new token
 }