Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: Authentication flow #14

Closed
aikoven opened this issue Dec 23, 2015 · 46 comments
Closed

Question: Authentication flow #14

aikoven opened this issue Dec 23, 2015 · 46 comments

Comments

@aikoven
Copy link
Contributor

aikoven commented Dec 23, 2015

I'm trying to implement user authentication with following requirements:

  • Sign-in can be triggered by user action
  • Auth token must be refreshed after some delay upon successful sign-in
  • If there is token stored in localStorage on app start then we must refresh it immediately

Here's my attempt:

function* authentication() {
  let refreshDelay = null;

  let token = JSON.parse(localStorage.getItem('authToken'));

  if (token) 
    refreshDelay = call(delay, 0);  // instant refresh

  while (true) {
    const {action} = yield race({
      action: take([SIGN_IN, SIGN_OUT]),
      delay: refreshDelay || call(waitForever)
    });
    refreshDelay = null;

    if (action && action.type === SIGN_OUT) {
      localStorage.removeItem('authToken');
      continue;
    }

    try {
      token = yield action == null ? auth.refreshToken(token) : auth.signIn();
      localStorage.setItem('authToken', JSON.stringify(token));
      yield put(authSuccess(token));
      refreshDelay = call(delay, token.expires_in);
    } catch (e) {
      localStorage.removeItem('authToken');
      yield put(authFailure(e));
    }
  }
}

This code works well, but I wonder if there is more elegant solution for handling refresh. Currently it's too difficult to track refreshDelay effect. I thought about extracting refresh to separate saga that waits for AUTH_SUCCESS action and then sets up a delay, but in this case I won't be able to cancel scheduled refresh if e.g. user signs out.

@yelouafi
Copy link
Member

yelouafi commented Dec 24, 2015

The following is my opinionated approach

First, I dont recommend calling service directly within sagas, it'd be better to use declarative calls. It makes possible testing all the operational logic inside the generator as explained in the declarative effects section

So I'll first refactor localStorage calls into some isolated service

// Side effects Services
function getAuthToken() {
  return JSON.parse(localStorage.getItem('authToken'))
}

function setAuthToken(token) {
  localStorage.setItem('authToken', JSON.stringify(token))
}

function removeAuthToken() {
  localStorage.removeItem('authToken')
}

Another remark is regarding action watching flow. It'd be easier if you exploit the Structured programming benefits offered by generators. I'll illustrate with a simple example, suppose our flow is just this simple sequence

SIGN_IN -> AUTHORIZE -> SIGN_OUT

Instead of doing something like

while(true) {
  const action = take([SIGN_IN, SIGN_OUT])

  if(action.type === SIGN_IN)
     const token = yield call(authorize)
     yield call(setAuthToken) // save to local storage
     yield put(authSuccess, token)
  else
     yield call(removeAuthToken)
     yield put(signout)
  }
}

You can exploit the fact that SIGN_IN and SIGN_OUT fire always in sequence and never concurrently and offload the flow control burden (where are we in the program right now ?) to the underlying generator runtime (I simplify to illustrate the concept, I ll introduce concurrency next)

function* authFlowSaga() {
  while(true) {
    // first expect a SIGN_IN
    const {credentials} = yield take(SIGN_IN)
    const token = yield call(authorize, credentials)

    // followed by a SIGN_OUT
    yield take(SIGN_OUT)
    yield call(signout)
  }
}

// reusable subroutines. Avoid duplicating code inside the main Saga
function* authorize(credentialsOrToken) {
  // call the remote authorization service
  const token = yield call(authService, credentialsOrToken)
  yield call(setAuthToken, token) // save to local storage
  yield put(authSuccess, token) // notify the store
  return token
}

function* signout(error) {
  yield call(removeAuthToken) // remove the token from localStorage
  yield put( actions.signout(error)  ) // notify th store
}

Before introducing concurrency, let's first introduce the refresh cycles, when the token expires w'll send again a request to the server to get a new token. so our sequence will become now

SIGN_IN -> AUTHORIZE -> REFRESH* -> SIGN_OUT

REFRESH* means many refreshes i.e. a loop in the generator

We're still forgetting concurrency to keep things simple and progress step by step

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)

    // refresh authorization tokens on expiration
    while(true) {
      yield wait(token.expires_in)
      token = call(authorize, token)
    }

    yield take(SIGN_OUT)
    yield call(signout)
  }
}

But we've an issue there, the refresh loop executes forever, because there is no breaking condition. If the user signed out between 2 refreshes we've to break the loop. So the breaking condition is the SIGN_OUT action. Also the SIGN_OUT action is concurrent to the next expiration delay, So we have to introduce a race between the 2 events

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)

    let userSignedOut
    while( !userSignedOut ) {
      const {expired} = yield race({
        expired : wait(token.expires_in),
        signout : take(SIGN_OUT)
      })

      // token expired first
      if(expired)
        token = yield call(authorize, token)
      // user signed out before token expiration
      else {
        userSignedOut = true // breaks the loop and wait for SIGN_IN again
        yield call(signout)
      }
    }
  }
}

But there are 2 othe issues, first the authorize saga may fail if the remote server responded with an error (e.g. invalid credentials, network error ...). And second, there is another concurrency issue, what if the user signed out in the middle of a refresh request/response cycle ? We'd have to cancel the ongoing authorization operation.

So first, we've to refactor our authorize saga

function* authorize(credentialsOrToken) {
  const {response} = yield race({
    response: call(authService, credentialsOrToken), 
    signout : take(SIGN_OUT)
  })

  // server responded (with Success) before user signed out
  if(response && response.token) {
    yield call(setAuthToken, response.token) // save to local storage
    yield put(authSuccess, response.token)
    return response.token
  } 
  // user signed out before server response OR server responded first but with error
  else {
    yield call(signout, response ? response.error : 'User signed out')
    return null
  }
}

Now if a remote authorization fails, we signout the user and return null as token. So we've also to refactor our main Saga to take into account the failure (null return value)

function* authFlowSaga() {
  while(true) {
    const {credentials} = yield take(SIGN_IN)
    let token = yield call(authorize, credentials)
    // authorization failed, wait the next signing
    if(!token) 
        continue

    let userSignedOut
    while( !userSignedOut ) {
      const {expired} = yield race({
        expired : wait(token.expires_in),
        signout : take(SIGN_OUT)
      })

      // token expired first
      if(expired) {
        token = yield call(authorize, token)
        // authorization failed, either by the server or the user signout
        if(!token) {
          userSignedOut = true // breaks the loop
          yield call(signout)
        }
      } 
      // user signed out before token expiration
      else {
        userSignedOut = true // breaks the loop
        yield call(signout)
      }
    }
  }
}

Now, we can think of the left requirement. What if there is a token already in local storage ? We'll simply skip the take(SIGN_IN) step and refresh immerdiately

function* authFlowSaga() {

  let token = yield call(getAuthToken) // retreive from local storage
  token.expires_in = 0

  while(true) {
    if(!token) {
      let {credentials} = yield take(SIGN_IN)
      token = yield call(authorize, credentials)
    }

   // ... rest pf code unchanged
}

So IMO we should follow as much as we can the following

  • isolate side effects functions (api calls, dom storage, ...) into separate services. This includes JSON.parse or stringify, or also wrapping api call results into {result} or {errors}
  • implement your flow step by step, starting by the simplest assumptions, then progressively introduce more requirements (concurrency, failure). The code will emerge naturally from this iterative process.
  • follow the Structured Programming approach. An if test makes only sens if we're waiting for concurrent effects (using race) or conditional results (success or error)
  • Your code flow should reflect closely the corresponding flow of events. If you know 2 events will fire in sequence (e.g. SIGN_IN then SIGN_OUT) then write 2 consecutive takes (take(SIGN_IN) then take(SIGN_OUT)). Use race only if there are concurrent events.

As I said, the main benefit of using Generators is that it allows to leverage the power of Structured Programming and routine/subroutine approach. do you think that humans could write such complex programs using only goto jumps ?

@aikoven
Copy link
Contributor Author

aikoven commented Dec 24, 2015

Wow, thank you for detailed answer, it was extremely helpful!
What I missed in my attempt were subroutines as generators with return statement.

One thing that still bothers me a bit is that we need to take SIGN_OUT in two places: authorize subroutine and main authFlowSaga. One idea is to race between authorize subroutine and take(SIGN_OUT), but I'm not sure if I can cancel the subroutine so that it won't continue execution upon sign-out. Still maybe that's overcomplicating, after all we can introduce another sign-out subroutine to remove duplication.

@youknowriad
Copy link

@yelouafi what an answer 😄 this could make a good blog (medium) post I think to show the strength of Sagas

@aikoven
Copy link
Contributor Author

aikoven commented Dec 24, 2015

Further development strengthened my concern:
If my flow takes more async steps (e.g. fetching user info) then I have to introduce even more races with take(SIGN_OUT). But if I could cancel forked subroutine then it would become much simpler:

function* authorize() {
  let token;
  while (true) {
    token = yield call(authService, token)
    yield call(setAuthToken, token)
    yield put(authSuccess, token)
    yield call(delay, token.expires_in);
  }
}

function* authFlowSaga() {
  while (true) {
    yield take(SIGN_IN);
    const authLoopTask = yield fork(authorize);
    yield take(SIGN_OUT);
    authLoopTask.cancel();
  }
}

@slorber
Copy link
Contributor

slorber commented Dec 24, 2015

@yelouafi that's a nice example :)

You should put it in a readme maybe and create an example with a fake auth service that failes 20% of the time :)

I think you do not handle request failures and bad credentials cases on initial authentication.

@yelouafi
Copy link
Member

I think you do not handle request failures and bad credentials cases on initial authentication.

Yup. Fixed, thanks

@yelouafi
Copy link
Member

But if I could cancel forked subroutine then it would become much simpler:

cancellation is on my todo list. And what you said makes quite sens. But We've to consider this: We can't allow arbitrary task cancellation, something ala linux kill process.

Consider for example this simple subtask

function* fetchPosts() {
  yield put( startFetchRequest() )
  const posts = yield call(fetchApi, '/posts')
  yield put( fethRequestSuccess(posts) )
}

What happens if you cancel this task while it's still waiting for the api call to resolve ? Imagine if your reducer sets some isFetching flag to true when he receives the first startFetchRequest action. Aborting the task brutally could let your store in an inconsistent state. This is somewhat similar to DB transactions : every startFetchRequest must have a corresponding fetchRequestSuccess/failure action.

So task cancellation, if implemented, should give a chance to the cancelled task to do its cleanup to let the store in a consistent state.

The best I can think of actually is similar to Threads behavior on Java. If a task is cancelled, we throw a special exception on it to give it chance to handle its cancellation in a proper way

function* fetchPosts() {
  try {
     yield put( startFetchRequest() )
     const posts = yield call(fetchApi, '/posts')
     yield put( fethRequestSuccess(posts) )
  } catch(error) {
     if(error instanceof SagaCancellationError)
        yield put( fetchRequestFailure(error) )
  }
}

Cancellation may be useful, but we need clear semantics for it. I m interested in any ideas.

@aikoven
Copy link
Contributor Author

aikoven commented Dec 24, 2015

Yes, throwing special error was what I thought about. I can work on PR with
this feature.

@yelouafi
Copy link
Member

yelouafi commented Dec 24, 2015

@aikoven you may take a looke at task-cancel branch its incomplete (use a generic error) but it seems to work (Need more tests though).

@yelouafi
Copy link
Member

@aikoven Of course you are welcome to provide a PR with this feature

@aikoven
Copy link
Contributor Author

aikoven commented Dec 25, 2015

I checked task-cancel branch and:

  1. Should we probably stop routine execution after it has been cancelled? For now if user catches thrown error and does not rethrow it he will end up with inconsistent state. This was a mistake, of course we must continue execution to run any cleanup. Still the docs should emphasise that routine must return after handling cancelation exception.
  2. I think if routine has running subroutine then we should cancel it as well.

@aikoven
Copy link
Contributor Author

aikoven commented Dec 25, 2015

Submitted PR #17

@aikoven
Copy link
Contributor Author

aikoven commented Dec 25, 2015

Complete flow with cancelation:

function* authorize(refresh) {
  try {
    const token = yield call(auth.authorize, refresh);
    yield call(auth.storeToken, token);
    yield put(authorizeSuccess(token));
    return token;
  } catch (e) {
    yield call(auth.storeToken, null);
    yield put(authorizeFailure(e));
    return null;
  }
}

function* authorizeLoop(token) {
  try {
    while (true) {
      const refresh = token != null;
      token = yield call(authorize, refresh);
      if (token == null)
        return;

      yield call(delay, token.expires_in);
    }
  } catch (e) {
    if (e instanceof InterruptedError)
      return;

    throw e;
  }
}

function* authentication() {
  const storedToken = yield call(auth.getStoredToken);

  while (true) {
    if (!storedToken)
      yield take(SIGN_IN);

    const authLoopTask = yield fork(authorizeLoop, storedToken);

    const {signOutAction} = yield race({
      signOutAction: take(SIGN_OUT),
      authLoop: join(authLoopTask)
    });

    if (signOutAction) {
      authLoopTask.cancel(new InterruptedError(SIGN_OUT));
      yield call(auth.storeToken, null);
    }
  }
}

@dts
Copy link

dts commented Dec 30, 2015

I am loving where this all is heading! For me, constantly "racing" the SIGN_OUT take is problematic for the simple reason that it is shortsighted - perhaps there will be some other action should cancel this in the future, and then you have to add an item to each of the races. Having forked "processes" be generally cancellable is the only way to go long-term IMO.

I see the advantages of using the exception system for this: it does the 'correct' thing and immediately stops what is going on, and allows you to handle the cancellation in any way you see fit. However, it does seem like an abuse of the exception system - someone "canceling" a behavior is not exceptional behavior at all, and I shy away from using exceptions for non-exceptional behavior for semantic reasons.

The obvious (though admittedly horrible) alternative is a more "opt-in" system where you check against some state variable whether or not the current thread has been canceled (this.isCanceled() or whatever). Are there any other thoughts on the mechanics for this cancellation?

@yelouafi
Copy link
Member

The obvious (though admittedly horrible) alternative is a more "opt-in" system where you check against some state variable whether or not the current thread has been canceled (this.isCanceled() or whatever). Are there any other thoughts on the mechanics for this cancellation?

AFAIK The behavior of this inside a generator is unspecified. It may point to the global object (window).

@dts
Copy link

dts commented Dec 30, 2015

Oh, I am sure that it currently is, and that it probably should stay that way. Adding another important keyword to the mix is likely to do more harm than good with this framework! The this.isCanceled() syntax was honestly intended to be a straw-man in order to elicit a better one 😄

@aikoven
Copy link
Contributor Author

aikoven commented Dec 30, 2015

@dts Could you please add more detail on your point against exceptions?
It feels ok to me to use exceptions for canceling. As I see it, from subroutine's point of view cancellation is an exceptional behavior. It is some external event that makes it interrupt, just like KeyboardInterrupt in Python or even OutOfMemoryError in Java.

@dts
Copy link

dts commented Dec 30, 2015

We're arguing about a very small point here, so I would like to say that the exception mechanism is the best I've seen - I am merely trying to point out what I see as a weakness that we may be able to improve.

With that out of the way, I would argue that any event that is triggered by a user action is inherently "non-exceptional". KeyboardInterrupt is related to a SIGTERM, so I'd put it in the "borderline" category, and OutOfMemoryError is not a result of user action at all. That's the high-level view that I see, anyway.

As for the mechanics, the default behavior of the exception mechanism is to drop everything semi-immediately, cleanup must be explicit with a try/catch/finally syntax. If there are asynchronous tasks that need to be performed in order to safely cancel something, that's additionally complicated (I assume it's possible, though).

@johnsoftek
Copy link

There may be cases where cancellation would be useful, but I don't think it is needed here. Sign-in and sign-out are triggered by real world events.

Consider a UI containing Sign In and Sign Out buttons. Only one of these buttons would be enabled at any time. Sure, refresh could be in progress when sign_out occurs, but each refresh attempt could be time limited, so the delay to sign out would be un-noticeable.

As @yelouafi said, "progressively introduce more requirements". I think this is a step too far.

@aikoven
Copy link
Contributor Author

aikoven commented Jan 5, 2016

With automatic cancellation of race competitors (see #17) it became even more concise:

function* authorize(refresh) {
  try {
    const token = yield call(auth.authorize, refresh);
    yield call(auth.storeToken, token);
    yield put(authorizeSuccess(token));
    return token;
  } catch (e) {
    yield call(auth.storeToken, null);
    yield put(authorizeFailure(e));
    return null;
  }
}

function* authorizeLoop(token) {
  while (true) {
    const refresh = token != null;
    token = yield call(authorize, refresh);
    if (token == null)
      return;

    yield call(delay, token.expires_in);
  }
}

function* authentication() {
  const storedToken = yield call(auth.getStoredToken);

  while (true) {
    if (!storedToken)
      yield take(SIGN_IN);

    const {signOutAction} = yield race({
      signOutAction: take(SIGN_OUT),
      authLoop: call(authorizeLoop, storedToken)
    });

    if (signOutAction) {
      yield call(auth.storeToken, null);
    }
  }
}

@Marinolinderhof
Copy link

@aikoven nice! i do have a follow up question.
About this line const token = yield call(auth.authorize, refresh);

Could you elaborate how this function works?

cause in my flow it's just a service which uses fetch and returns the token. But my problem is that i need the token something like: token: {id: '1', token:'1234-abcd-1234-abcd', expires: 6000} or the credentials, username and password, if refresh is false.

but then i need access to my store and because auth.authorize is just some dumb javascript service it hasn't access to the store. I could pass in the credentials or the 'old' token but then i think i break the and i quote "So I'll first refactor localStorage calls into some isolated service" rule.

So if you could elaborate how that part works i would be grateful, sorry for the beginners question.

@aikoven
Copy link
Contributor Author

aikoven commented Jul 25, 2016

@Marinolinderhof In my project I used Google auth library that stored token somewhere in memory and there was no need to keep it in Redux store.

I guess the best solution for your case is to use select effect: https://redux-saga.github.io/redux-saga/docs/api/index.html#selectselector-args

@tobyl
Copy link

tobyl commented Aug 25, 2016

@yelouafi this was amazing, it really helped me understand how saga might be used for async login requests. One question though - I'm a little confused about some of the put commands.

if(response && response.token) {
    yield call(setAuthToken, response.token) // save to local storage
    yield put(authSuccess, response.token)
    return response.token
  } 

In this block, it makes sense that a function is called to save the token in localStorage, but what is the put doing? Is this the reverse of take(ACTION_NAME) and calling a function from the actions file? The saga below seems to be doing a similar thing, except called in a different way - is there a reason for this?

 function* signout(error) {
  yield call(removeAuthToken) // remove the token from localStorage
  yield put( actions.signout(error)  ) // notify th store
}

Thank you in advance!

@jamesblight
Copy link

put dispatches an action to the redux store. It's the same as store.dispatch(action.signout(error))

@tobyl
Copy link

tobyl commented Aug 26, 2016

thanks for the response - I actually meant these two specific references:

yield put(authSuccess, response.token)
yield put( actions.signout(error) )

The format of the rest of the code suggests that authSuccess is a helper function like setAuthToken, but if it's an action, what is the reason for storing the token in state?

Thanks.

@thangchung
Copy link

@yelouafi @aikoven It is really saving my time a lot. Love your works 👍

@benjaminreid
Copy link

@aikoven This is a really nice example. I'm struggling to see how you combine other API requests with this though.

For example, refresh token has expired, a request to an authorized resource is about to be made. How would you delay that request until the refresh token is done in here?

Unless every other saga that hits the api has to check the expiry of the auth token?

🤔

@aikoven
Copy link
Contributor Author

aikoven commented Feb 23, 2017

@nouveller A common path is to start refreshing token before it expires, so that your current requests (and token refresh request as well) have time to finish.

@benjaminreid
Copy link

benjaminreid commented Feb 23, 2017

@aikoven That makes sense, keep an eye on your expiry time then trigger a refresh 5 minutes before or something.

I suppose the only edge case to that may be the app goes into the background, token expires, app goes into the foreground and then you've got to do a refresh before any other api call.

When I was using redux-thunk for this, all the api calls were going through one piece of middleware, which was queueing up requests that were attempting to be called while the refresh token had expired/was refreshing. I'd then run through the queue of requests after the refresh had been completed. It was fairly solid but confusing to read and not very portable.

Unless there's an obvious solution to the edge case I mentioned above, maybe there's a nice way to wrap each saga so that it can wait until the expiry of the token is far enough in the future it won't interrupt a refresh.

Thanks for your thoughts though either way.

@aikoven
Copy link
Contributor Author

aikoven commented Feb 23, 2017

I'd go with a generic generator for all API calls that first gets the token from Redux store using yield select(...), checks the token and if it needs refresh, waits for a TOKEN_REFRESHED action before making a request.

@benjaminreid
Copy link

@aikoven Sounds perfect, thanks for your input 👍

@lukeggchapman
Copy link

@nouveller that's what I'm worried about too. Having a delay on token.expires_in then the thread goes to sleep as the app is not focused, when you re-open it the delay is still running but is going to fire late. I haven't ran tests to see if this is the case yet. @aikoven is on the money with having the generic data saga and having it check the token expiry each time, I might need to abandon the authorizeLoop for this approach.

@benjaminreid
Copy link

benjaminreid commented Feb 25, 2017 via email

@shengnian
Copy link

that's nice, i do have a follow up question, see the below code:

function* authentication() {
  const storedToken = yield call(auth.getStoredToken);

  while (true) {
    if (!storedToken)
      yield take(SIGN_IN);

    const {signOutAction} = yield race({
      signOutAction: take(SIGN_OUT),
      authLoop: call(authorizeLoop, storedToken)
    });

    if (signOutAction) {
      storedToken = null;  <-  Is it necessary to set it null now?
      yield call(auth.storeToken, null);
    }
  }
}

@Andarist
Copy link
Member

Yes, otherwise ur loop will skip take sign in

@shengnian
Copy link

thanks, if not set the null, the sign out action will be failure, refresh token is still valid.

@msageryd
Copy link

I throttle most of my api calls and put them through a specialized throttle function. I'm not queuing the calls (other than throttle-wise), because I don't care if I miss a call due to Internet outage or invalid token. These "missed" calls will be handled by another saga upon "INTERNET_ON" or "RECEIVED_TOKEN".

I hope it helps someone. Please chime in if you think there is some wrong thinking in this.

//A special saga helper that acts exactly like effects.throttle, but only
//calls the throttled function if there is an Internet connection and access token is valid
export function* throttleIfOnlineAndTokenIsValid(ms, pattern, task, ...args) {
  const throttleChannel = yield actionChannel(pattern, buffers.sliding(1));

  while (true) {
    const action = yield take(throttleChannel);
    const isOnline = yield call(NetInfo.isConnected.fetch);
    const isAccessTokenValid = yield select(selectCheckAccessToken);
    if (isOnline && isAccessTokenValid) yield fork(task, ...args, action);
    yield call(delay, ms);
  }
}

export function* throttleIfOnline(ms, pattern, task, ...args) {
  const throttleChannel = yield actionChannel(pattern, buffers.sliding(1));

  while (true) {
    const action = yield take(throttleChannel);
    const isOnline = yield call(NetInfo.isConnected.fetch);
    if (isOnline) yield fork(task, ...args, action);
    yield call(delay, ms);
  }
}

@alvelig
Copy link

alvelig commented Sep 24, 2017

Hi, I made an auth flow library (token based), which consists of 2 libs:

https://github.com/alvelig/redux-saga-auth

https://github.com/alvelig/redux-saga-api-call-routines

It makes use of redux-saga-routines for compatibility with redux-form. Any comments are appreciated.

@marcelaraujo
Copy link

@aikoven Could you post the complete auth flow somewhere? It will be great.

@aikoven
Copy link
Contributor Author

aikoven commented Oct 12, 2017

@marcelaraujo It's there already: #14 (comment)

@marcelaraujo
Copy link

@aikoven It was incomplete because you didn't handle the credentials on yield take(SIGN_IN);

@zcmgyu
Copy link

zcmgyu commented Dec 29, 2017

@aikoven @yelouafi
SIGN_IN -> AUTHORIZE -> REFRESH* -> SIGN_OUT

How could I retry to refresh access_token when api throws 401? Might you give me some hint?

@UchihaVeha
Copy link

@zcmgyu
Create wrapper saga function on fetch

export default function* apiCaller({
  headers = {},
  ...rest
}) {
  const authorizeHeader = yield call(getAuthorizationHeader);
  const props = {
    ...rest,
    headers: {
      ...headers,
      ...authorizeHeader
    }
  };
  const response = yield call(request, props);
  if (response) {
    if (
      response.statusCode === 401 &&
      response.message !== 'refreshToken is expired'
    ) {
      const isRefreshed = yield call(tryRefreshTokens);
      if (isRefreshed) {
        return yield call(apiCaller, props);
      }
    }
    return response;
  }
  return {};
}

Usage:

function* loadTodos({ onDate }) {
  const { payload } = yield call(apiCaller, api.getTodos({ onDate }));
  if (payload) {
    yield put(payload )
    );
  }
}

@nachoargentina
Copy link

@zcmgyu
Create wrapper saga function on fetch

export default function* apiCaller({
  headers = {},
  ...rest
}) {
  const authorizeHeader = yield call(getAuthorizationHeader);
  const props = {
    ...rest,
    headers: {
      ...headers,
      ...authorizeHeader
    }
  };
  const response = yield call(request, props);
  if (response) {
    if (
      response.statusCode === 401 &&
      response.message !== 'refreshToken is expired'
    ) {
      const isRefreshed = yield call(tryRefreshTokens);
      if (isRefreshed) {
        return yield call(apiCaller, props);
      }
    }
    return response;
  }
  return {};
}

Usage:

function* loadTodos({ onDate }) {
  const { payload } = yield call(apiCaller, api.getTodos({ onDate }));
  if (payload) {
    yield put(payload )
    );
  }
}

Hi! sorry to revive the thread again. I have been trying your suggested approach to refresh a token if an API call is made that comes back with 401. I created wrapper saga function on fetch and it's all working fine except for one thing.
Quoting the example, let's say that htis function to get all todos runs on the click of a button:

function* loadTodos({ onDate }) {
  const { payload } = yield call(apiCaller, api.getTodos({ onDate }));
  if (payload) {
    yield put(payload )
    );
  }
}

This will go through the API caller, make the call, and if it fails, it will refresh the token and make the call again with the new token. Awesome!
Now, this is the problem I'm facing. To make it simple, let's say someone clicks the button 10 times in a row. This will trigger 10 API calls.
My expectation would be that the first API call will fail and it will request a new token.
In the meantime, the other calls will start to fail too. Since a new token is being requested, I just want to make it so that they wait for the token refreshed action to be dispatch, and when that happen, retry the calls with the new token.
All in all, I'm eventually expecting all 10 API calls to be made successfully.

Here's the code I came up with:

export default function* ApiCaller(method, url, params = null, body = null, avoidHeader) {
    const props = {
        method,
        url,
        params,
        body,
        avoidHeader
    };
    if (localStorage.getItem('isRefreshingToken')) {
        const { success } = yield race({
            success: take(AUTH_ACTIONS.userTokenRefreshSuccess().type),
            fail: take(AUTH_ACTIONS.userTokenRefreshFailure().type)
        });
        if (success) {
            localStorage.setItem('isRefreshingToken', '');
            console.log('token refreshed, retrying');
            return yield call(ApiCaller, props.method, props.url, props.params, props.body, props.avoidHeader);
        } else {
            console.log('token refresh failed, logging out user');
            yield put(AUTH_ACTIONS.LOGOUT_REQUEST);
        }
    } else {
        const response = yield call(callApi, props);
        const { success } = response;
        const { error } = response;
        const errorData = error && error.response && error.response.data;
        if (errorData) {
            if (
                errorData.httpErrorType === 401
                && errorData.type === 'unauthorized'
            ) {
                if(!localStorage.getItem('isRefreshingToken')) {
                    localStorage.setItem('isRefreshingToken', true);
                    yield put(AUTH_ACTIONS.userTokenRefreshRequest());

                    const { success } = yield race({
                        success: take(AUTH_ACTIONS.userTokenRefreshSuccess().type),
                        fail: take(AUTH_ACTIONS.userTokenRefreshFailure().type)
                    });

                    if (success) {
                        localStorage.setItem('isRefreshingToken', '');
                        return yield call(ApiCaller, props.method, props.url, props.params, props.body, props.avoidHeader);
                    } else {
                        console.log('token refresh failed, logging out user');
                        yield put(AUTH_ACTIONS.LOGOUT_REQUEST);
                    }
                } else {
                    const { success } = yield race({
                        success: take(AUTH_ACTIONS.userTokenRefreshSuccess().type),
                        fail: take(AUTH_ACTIONS.userTokenRefreshFailure().type)
                    });
                    if (success) {
                        console.log('token refreshed, retrying');
                        return yield call(ApiCaller, props.method, props.url, props.params, props.body, props.avoidHeader);
                    } else {
                        console.log('token refresh failed, logging out user');
                        yield put(AUTH_ACTIONS.LOGOUT_REQUEST);
                    }
                }
            }
        }
        return success;
    }

I hope it's not too bad.
The problem here is that the moment a second API call get's made and it goes through const response = yield call(callApi, props);, it's like the response get's overwitten and you are always listening ONLY to the last API call. So, after 10 clicks, you would have made 10 API calls that failed, 1 refresh token call and then only 1 of the calls (the last one) will be made with the new token!

Any suggestions on how I may address that?
Thanks in advanced for your time guys!

@msageryd
Copy link

msageryd commented Sep 25, 2018

  • Edit: I see now that you want to implement "request new token" into this. You could do this in my function by dispatching a token-request action when token is missing. I handle token refreshing in a separate saga, so I don't need to handle this in the functions below.

Sorry, I don't have time to dive into your code. Instead I'll post my take on this. The below code serves me well in my app.

The function takeEveryIfOnlineAndTokenIsValidis almost the same as the takeEvery function in redux-saga. My version only starts the task (via fork) if I have an Internet connection and a valid token.

If a task won't be forked due to no network or no token, this task will be retried automatically due to the extended pattern.

I had problem with NetInfo.isConnected.fetch (remarked), so I implemented a network listener which dispatches actions when Internet comes and goes. This way I can select network status from my store instead of relying on the shaky isConnected.fetch function.

function extendPattern(pattern, actions) {
  if (!Array.isArray(pattern)) pattern = [pattern];
  if (!Array.isArray(actions)) actions = [actions];

  actions.forEach((action) => {
    if (!pattern.includes(action)) pattern.push(action);
  });
  return pattern;
}

export function* takeEveryIfOnlineAndTokenIsValid(pattern, task, ...args) {
  //Add two triggers to the pattern in order to auto-resume if the action gets discarded due to network or token problems
  pattern = extendPattern(pattern, [AUTH_GET_USER_TOKEN_SUCCESS, NETWORK_INTERNET_ON]);

  while (true) {
    const action = yield take(pattern);
    const isOnline = yield select(selectNetworkStatus);
    //    const isOnline = yield call(NetInfo.isConnected.fetch);
    const isAccessTokenValid = yield select(selectCheckAccessToken);

    if (isOnline && isAccessTokenValid) {
      yield fork(task, ...args, action);
    } else {
      console.log(
        `Action ${action.type} discarded due to no network or invalid token (isOnline: ${isOnline}, isAccessTokenValid: ${isAccessTokenValid})`
      );

      if (DeviceInfo.isEmulator()) {
        //The iOS simulator does not cope well with irregular Internet connections.
        //We need to manually probe for internet if the simulator thinks that we are offline
        if (!isOnline) yield put(networkSimulatorProbeAction());
      }
    }
  }
}

@msageryd
Copy link

Here is a throttle-like version of the above

export function* throttleIfOnlineAndTokenIsValid(ms, pattern, task, ...args) {
  pattern = extendPattern(pattern, [AUTH_GET_USER_TOKEN_SUCCESS, NETWORK_INTERNET_ON]);
  const throttleChannel = yield actionChannel(pattern, buffers.sliding(1));

  while (true) {
    const action = yield take(throttleChannel);
    const isOnline = yield select(selectNetworkStatus);
    const isAccessTokenValid = yield select(selectCheckAccessToken);
    if (isOnline && isAccessTokenValid) {
      yield fork(task, ...args, action);
    } else {
      if (DeviceInfo.isEmulator()) {
        //The iOS simulator does not cope well with irregular Internet connections.
        //We need to manually probe for internet if the simulator thinks that we are offline
        if (!isOnline) yield put(networkSimulatorProbeAction());
      }
    }
    yield call(delay, ms);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests