Scenario: I am using OkHttp / Retrofit to access a web service: multiple HTTP requests are sent out at the same time. At some point the auth token expires, and multiple requests will get a 401 response.
Issue: In my first implementation I use an interceptor (here simplified) and each thread tries to refresh the token. This leads to a mess.
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; } }
Desired solution: All threads should wait for one single token refresh: the first failing request triggers the refresh, and together with the other requests waits for the new token.
What is a good way to proceed about this? Can some built-in features of OkHttp (like the Authenticator) be of help? Thank you for any hint.
A refresh token allows an application to obtain a new access token without prompting the user. You can create a refresh token by making a POST request to the following endpoint: https://api.twitter.com/2/oauth2/token You will need to add in the Content-Type of application/x-www-form-urlencoded via a header.
A refresh token is a special token that is used to obtain additional access tokens. This allows you to have short-lived access tokens without having to collect credentials every time one expires.
I had the same problem and I managed to solve it using a 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(); } }
The main advantage of using this solution is that you can write an unit test using mockito and test it. You will have to enable Mockito Incubating feature for mocking final classes (response from okhttp). Read more about here. The test looks something like this:
@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; } }
You should not use interceptors or implement the retry logic yourself as this leads to a maze of recursive issues.
Instead implement the okhttp's Authenticator
which is provided specifically to solve this problem:
okHttpClient.setAuthenticator(...);
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With