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

Network: Retry requests that failed in flight #6567

Merged
merged 21 commits into from
Jan 12, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3360e84
NetworkTests: Add test confirming failed requests are not retried
kidroca Dec 2, 2021
3974014
API.requestErrorHandler - retry some requests
kidroca Dec 2, 2021
1371639
API extract reusable retry and shouldRetry functions
kidroca Dec 2, 2021
4029c72
Network: update post method docs
kidroca Dec 2, 2021
24a91ef
Fix API: don't allow more than one auth requests to run at the same time
kidroca Dec 2, 2021
7489bfb
Fix Session: reauthenticate pusher is still causing multiple authenti…
kidroca Dec 2, 2021
b3e3ba6
tests: update expired token test - request order is no longer reversed
kidroca Dec 2, 2021
badccf5
tests: reduce expired token test size
kidroca Dec 2, 2021
2a2c367
tests: Add API and Session tests regarding parallel calls
kidroca Dec 2, 2021
262469d
API: update docs
kidroca Dec 2, 2021
3045aca
API: better names for shouldRetry and retry
kidroca Dec 10, 2021
b1e2a39
Session: revert changes made to reauthenticatePusher
kidroca Dec 10, 2021
d3a9d1c
Merge branch 'main' into kidroca/retry-failed-requests
kidroca Jan 7, 2022
a2b45ec
Revert "Network: update post method docs"
kidroca Jan 7, 2022
6b5a5d1
Revert "Fix API: don't allow more than one auth requests to run at th…
kidroca Jan 7, 2022
99e57e6
Revert "tests: Add API and Session tests regarding parallel calls"
kidroca Jan 7, 2022
2fd5104
Revert "tests: reduce expired token test size"
kidroca Jan 7, 2022
c3a0e5c
Use correct function name
kidroca Jan 7, 2022
13397ea
Revert "API extract reusable retry and shouldRetry functions"
kidroca Jan 7, 2022
470eaaa
Revert "tests: update expired token test - request order is no longer…
kidroca Jan 7, 2022
294a8f1
Network - move the retry on network failure logic
kidroca Jan 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 52 additions & 44 deletions src/libs/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Network from './Network';
import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens';
import setSessionLoadingAndError from './actions/Session/setSessionLoadingAndError';

let isAuthenticating;
let pendingAuthTask;
let credentials;
let authToken;

Expand Down Expand Up @@ -62,6 +62,26 @@ function isAuthTokenRequired(command) {
], command);
}

/**
* Determines are we supposed to retry the given request
* @param {Object} request
* @returns {Boolean}
*/
function shouldRetry(request) {
return lodashGet(request, 'data.shouldRetry', false);
}

/**
* When the request should be retried add it back to the queue
* It will either get retried now, or later when we're back online
kidroca marked this conversation as resolved.
Show resolved Hide resolved
* @param {Object} request
*/
function retry(request) {
kidroca marked this conversation as resolved.
Show resolved Hide resolved
Network.post(request.command, request.data, request.type, request.shouldUseSecure)
.then(request.resolve)
.catch(request.reject);
}

/**
* Adds default values to our request data
*
Expand Down Expand Up @@ -92,35 +112,12 @@ Network.registerParameterEnhancer(addDefaultValuesToParameters);
* Function used to handle expired auth tokens. It re-authenticates with the API and
* then replays the original request
*
* @param {String} originalCommand
* @param {Object} [originalParameters]
* @param {String} [originalType]
* @returns {Promise}
* @param {Object} originalRequest
*/
function handleExpiredAuthToken(originalCommand, originalParameters, originalType) {
// When the authentication process is running, and more API requests will be requeued and they will
// be performed after authentication is done.
if (isAuthenticating) {
return Network.post(originalCommand, originalParameters, originalType);
}

// Prevent any more requests from being processed while authentication happens
Network.pauseRequestQueue();
isAuthenticating = true;

function handleExpiredAuthToken(originalRequest) {
// eslint-disable-next-line no-use-before-define
return reauthenticate(originalCommand)
.then(() => {
// Now that the API is authenticated, make the original request again with the new authToken
const params = addDefaultValuesToParameters(originalCommand, originalParameters);
return Network.post(originalCommand, params, originalType);
})
.catch(() => (

// If the request did not succeed because of a networking issue or the server did not respond requeue the
// original request.
Network.post(originalCommand, originalParameters, originalType)
));
reauthenticate(originalRequest.command)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB, just rubber ducking... so we don't need to return this promise anymore because we are handling the promise in retryRequest(). Makes sense and looks clean.

However, when we previously successfully called reauthenticate() we were calling addDefaultValuesToParameter() and passing in the original parameters (no authToken present there) and the comment seems to suggest that we are getting a new authToken to use (the one set locally inside reauthenticate())

authToken = response.authToken;

It does look like we will add those parameters here when making the request

? enhanceParameters(queuedRequest.command, requestData)

I'm guessing that this is fine because the request we are passing to this callback here

App/src/libs/Network.js

Lines 221 to 229 in 021ad5d

const finalParameters = _.isFunction(enhanceParameters)
? enhanceParameters(queuedRequest.command, requestData)
: requestData;
onRequest(queuedRequest, finalParameters);
HttpUtils.xhr(queuedRequest.command, finalParameters, queuedRequest.type, queuedRequest.shouldUseSecure)
.then(response => onResponse(queuedRequest, response))
.catch(error => onError(queuedRequest, error));
});

Should never have an authToken in it's requestData...?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR is the existing code here just pointless?

App/src/libs/API.js

Lines 112 to 117 in 021ad5d

return reauthenticate(originalCommand)
.then(() => {
// Now that the API is authenticated, make the original request again with the new authToken
const params = addDefaultValuesToParameters(originalCommand, originalParameters);
return Network.post(originalCommand, params, originalType);
})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should never have an authToken in it's requestData...?

That makes sense - only add (the latest) token for authenticated requests and don't expect it to be part of requestData

TL;DR is the existing code here just pointless?

Yeah, it unneeded but it also might be a subtle bug calling addDefaultValuesToParameters here. It would add authToken to original params which might get persisted, and might expire, but won't be possible to update:

App/src/libs/API.js

Lines 75 to 77 in 7af06bc

if (isAuthTokenRequired(command) && !parameters.authToken) {
finalParameters.authToken = authToken;
}

It might be best to update the enhancer to always add the latest token

    if (isAuthTokenRequired(command)) {
        finalParameters.authToken = authToken;
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. Maybe we can address this in a follow up issue if it becomes a problem. Not sure I am seeing the subtle bug yet but does sound like maybe persisted requests should not be saved with the authToken and we should always prefer the most recent one we have.

.finally(() => retry(originalRequest));
kidroca marked this conversation as resolved.
Show resolved Hide resolved
}

Network.registerRequestHandler((queuedRequest, finalParameters) => {
Expand Down Expand Up @@ -159,15 +156,12 @@ Network.registerResponseHandler((queuedRequest, response) => {
// There are some API requests that should not be retried when there is an auth failure like
// creating and deleting logins. In those cases, they should handle the original response instead
// of the new response created by handleExpiredAuthToken.
const shouldRetry = lodashGet(queuedRequest, 'data.shouldRetry');
if (!shouldRetry || unableToReauthenticate) {
if (!shouldRetry(queuedRequest) || unableToReauthenticate) {
kidroca marked this conversation as resolved.
Show resolved Hide resolved
queuedRequest.resolve(response);
return;
}

handleExpiredAuthToken(queuedRequest.command, queuedRequest.data, queuedRequest.type)
.then(queuedRequest.resolve)
.catch(queuedRequest.reject);
handleExpiredAuthToken(queuedRequest);
Copy link
Contributor

@roryabraham roryabraham Dec 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we pause the request queue right here rather than in Authenticate? Because we know on line 155 if we're going to try to reauthenticate, so maybe we should immediate pause the request queue so that another request doesn't begin processing before we attempt to reauthenticate?

Copy link
Contributor Author

@kidroca kidroca Dec 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to - it's impossible for another requests to start

Whether the call is now or "a few lines later" inside Authenticate it's still executed as sync code, which makes it impossible for other requests to start

The unit tests are covering this: "Multiple Authenticate calls should be resolved with the same value"
we start 5 requests at the same time - if pausing didn't work as expected it would have let another request to start

Putting the "pause" anywhere else but in Authenticate, only hides details that might be important and scatters the logic around

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting the "pause" anywhere else but in Authenticate, only hides details that might be important and scatters the logic around

more opinion than fact (in my opinion 😄)

Copy link
Contributor Author

@kidroca kidroca Jan 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more opinion than fact (in my opinion 😄)

So you both think we should pause while authenticating, call the pause outside of Authenticate but call the unpause from inside Authenticate?

The original code was pausing the Network queue in handleExpiredAuthToken clearly the intent was to prevent any (other) requests while authentication runs.
Besides running Authenticate would change the token so it's best to pause any requests, otherwise they will happen in the middle of authentication and get rejected, causing a new authenticate and then retry the actual call

Since authenticate and reauthenticate are exported, they can and are triggered from outside (pusher)

return;
}

Expand All @@ -191,6 +185,13 @@ Network.registerErrorHandler((queuedRequest, error) => {
// Set an error state and signify we are done loading
setSessionLoadingAndError(false, 'Cannot connect to server');

// When the request should be retried add it back to the queue
// It will either get retried now, or later when we're back online
if (shouldRetry(queuedRequest)) {
retry(queuedRequest);
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if wrong, but this here seems to be the main material change?

If we get a network error (like a fetch error) now we just toss the request into the ether instead of retrying it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If that's correct. I'm sort of confused why we are doing this here instead of when we call onError() here

App/src/libs/Network.js

Lines 226 to 228 in 021ad5d

HttpUtils.xhr(queuedRequest.command, finalParameters, queuedRequest.type, queuedRequest.shouldUseSecure)
.then(response => onResponse(queuedRequest, response))
.catch(error => onError(queuedRequest, error));

Could we just add the request to the array of requests to retry instead?

App/src/libs/Network.js

Lines 243 to 245 in 021ad5d

// We clear the request queue at the end by setting the queue to retryableRequests which will either have some
// requests we want to retry or an empty array
networkRequestQueue = requestsToProcessOnNextRun;

Seems easier to do that and then we won't need onError() to call retryRequest() which calls Network.post() etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we get a network error (like a fetch error) now we just toss the request into the ether instead of retrying it?

The opposite actually. When we get a network/fetch error we'll get right here where we retry if we're supposed to.

If that's correct. I'm sort of confused why we are doing this here instead of when we call onError() here

App/src/libs/Network.js

Lines 226 to 228 in 021ad5d

HttpUtils.xhr(queuedRequest.command, finalParameters, queuedRequest.type, queuedRequest.shouldUseSecure)
.then(response => onResponse(queuedRequest, response))
.catch(error => onError(queuedRequest, error));

It happens inside onError aka the ErrorHanlder because we

  • log about it
  • do "something" with the session
  • check and retry the request if we should
  • or reject to the original caller otherwise

Are you suggesting something like

HttpUtils.xhr(queuedRequest.command, finalParameters, queuedRequest.type, queuedRequest.shouldUseSecure) 
	     .then(response => onResponse(queuedRequest, response)) 
	     .catch(error => {
	        if (canRetryRequest(queuedRequest)) {
	           requestsToProcessOnNextRun.push(queuedRequest);
	        } else {
	          onError(queuedRequest, error);
	        }
	     }); 

If we shouldn't log or call setSessionLoadingAndError we could move the retry to Network. The only problem would be that we have a onError handler that's not handling all the errors

Besides the SuccessHandler also retries the request through Network.post(), but if it were to throw on expired token it would trigger the catch block and reschedule the request through the same logic


One thing to note in advance - the following is not possible:

	     .catch(error => {
                onError(queuedRequest, error);

	        if (canRetryRequest(queuedRequest)) {
	           requestsToProcessOnNextRun.push(queuedRequest);
	        }
	     }); 

onError would reject the underlying promise and it won't be possible to resolve or reject it anymore

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opposite actually. When we get a network/fetch error we'll get right here where we retry if we're supposed to.

Yes, sorry I am describing the current behavior (without your changes)

I see your point about losing the logs. But we could also:

  • Log that we are retrying because of a network error from within Network
  • Add the request back into the queue right away
  • Not call the onError() because we don't need to reject the promise or call setLoadingAndError()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a plan

Copy link
Contributor Author

@kidroca kidroca Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're already retrying requests on a few places inside API, wouldn't it be best to extract logic that can cover those as well (handleExpiredAuthToken) (And all of those are hooked back to the original requests' promise)

Didn't really get this, but maybe do not understand. We are not losing the original promise context if we put it back into the request queue with the resolve and reject attached. If the request gets a response it will be handled.

I'm saying that we already have a retry logic inside API (calling Network.post)
And we're discussing whether we should add another retry logic in Network (to spare us from calling Network.post, and to reuse canRetry)
Let's keep using Network.post to retry the request like we do in the Response Handler (expired token)
It would still aid refactoring, when we identify exactly how it should happen


On one side (Network) we

  • 🟢 can reuse canRetry
  • ❔we spare putting the request through post again (and chaining to the original promise)
  • 🔴 but we lose the ability to log without without introducing circular dep

On the other

  • 🟢 we can log
  • 🟡 we duplicate a few one liners (lodash.get data.shouldRetry)
  • ❔ we need to call Network.post and chain to the original promise

❔ - is it really good or bad?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a cors error can happen, or an error resulting from calling an url that no longer exists
these are developer errors and would result in a deploy blocker anyway

Are these the only possibilities we should expect to see? And if so and these should be deploy blocking events why would the proposed solution be to retry them? Should we maybe at least queue a log to alert in such cases in addition to retrying over and over?

The requests we retry here would eventually succeed

Sorry, I'm not quite seeing where the eventual success happens.

Let's keep using Network.post to retry the request like we do in the Response Handler (expired token)

As I mentioned already, I don't see a compelling reason to do it this way so it's not worth having this many back and forth exchanges to resolve.

Regardless, it seems more important to figure out how to handle the infinite retries concern.

I'm not sure if there's a way to wrap that concern up quickly. But without a proposal to do so I'm not sure it's a great idea to retry these requests.

Copy link
Contributor Author

@kidroca kidroca Jan 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these the only possibilities we should expect to see? And if so and these should be deploy blocking events why would the proposed solution be to retry them? Should we maybe at least queue a log to alert in such cases in addition to retrying over and over?

I'm not suggesting to retry those, but the error itself doesn't carry information to distinct them - fetch would just call catch with an unspecific Failed to fetch error. Supposedly this is by design to prevent exploits

  • edit: In the end I kinda am in favor of retrying everything

We can't distinct what caused an error. A network error can be caused by:

  • CORS
  • DNS errors
  • TLS negotiation failure
  • others

The most likely reason to be in the catch handler is that we're either offline or we've experienced a fluke and can recover by retrying the request

To your point for prevent infinite loop of requests we could only retry the request if

  1. it failed
  2. we check our current offline state in the error handler. If we're offline that's probably the reason the request failed - we can execute logic to retry it

Let's keep using Network.post to retry the request like we do in the Response Handler (expired token)

As I mentioned already, I don't see a compelling reason to do it this way so it's not worth having this many back and forth exchanges to resolve.

I've edited my last reply with pros / cons, but if that's not making sense just tell me what you need done:

  • have the retry happen in Network.js
  • don't log anything or log to console.debug
  • push the request directly to the queue

And if so and these should be deploy blocking events why would the proposed solution be to retry them?

If the reason for a request to fail is purely network related and not the actual payload we should retry it - same reason we retry it after re-authentication or why we post it after it has been persisted in storage be it a minute or a year
Because we "promise eventual delivery"

Let's say there's a CORS error and posting chat messages stops working - there's nothing you can do on the front end to fix the issue. Instead of commiting to data loss and discarding the requests we can

  1. inform the users that their actions aren't reaching target (but will be sent as soon as the problem is resolved)
  2. keep retrying the requests
  3. if the user decides to quit - any "important" requests were persisted and will get retried

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason to retry the request, besides everything else said
When we fail to reach the API for any reason - retry the request (if it's retriable)

Keep the logic simple we can extend it if it becomes a problem

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tell me what you need done

Sure, sorry for being indirect - that new plan sounds good for now

  • have the retry happen in Network.js
  • don't log anything
  • push the request directly to the queue

It would be good to log when the request happens so maybe next we can solve the circular dependency issues in a new PR.


// Reject the queued request with an API offline error so that the original caller can handle it.
queuedRequest.reject(new Error(CONST.ERROR.API_OFFLINE));
});
Expand All @@ -205,7 +206,7 @@ Network.registerErrorHandler((queuedRequest, error) => {
* @param {String} [parameters.twoFactorAuthCode]
* @param {String} [parameters.email]
* @param {String} [parameters.authToken]
* @returns {Promise}
* @returns {Promise<{jsonCode: Number}>}
kidroca marked this conversation as resolved.
Show resolved Hide resolved
*/
function Authenticate(parameters) {
const commandName = 'Authenticate';
Expand All @@ -217,7 +218,15 @@ function Authenticate(parameters) {
'partnerUserSecret',
], parameters, commandName);

return Network.post(commandName, {
// Don't run more than one Authentication requests at the same time
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind explaining (briefly) the motivation behind moving this logic into Authenticate() instead of handleExpiredAuthToken()? Didn't quite figure it out from looking at the PR.

Could we theoretically leave the pendingAuthTask idea and put this logic back into handleExpiredAuthToken() where it was and achieve the "retry requests that fail in flight" objective?

Feels like isAuthenticating was swapped for pendingAuthTask. Is there a clear reason to prefer one over the other?

Copy link
Contributor Author

@kidroca kidroca Jan 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind explaining (briefly) the motivation behind moving this logic into Authenticate() instead of handleExpiredAuthToken()? Didn't quite figure it out from looking at the PR.

I've actually made a comment about it here:

The original code was pausing the Network queue in handleExpiredAuthToken clearly the intent was to pause (other) requests while authentication runs.
Besides running Authenticate would change the token so it's best to pause any requests, otherwise they will happen in the middle of authentication and get rejected, causing a new authenticate and then retry the actual call


Could we theoretically leave the pendingAuthTask idea and put this logic back into handleExpiredAuthToken() where it was and achieve the "retry requests that fail in flight" objective?

Sure, would you like me to open a separate PR about it?

Feels like isAuthenticating was swapped for pendingAuthTask. Is there a clear reason to prefer one over the other?

Capturing a Promise inside pendingAuthTask allow us to hook other callers to the same Authentication call rather than starting a new call.
If Authentication is already in progress when you call Authenticate you should hook to it, rather than starting a new call and potentially invalidating the result of the one that's already running

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, maybe we can create a new issue for this as well? I'm not quite seeing the path for how this can happen or why we should be concerned about it - but guessing you would be able to document it and we can address it separately if it's concerning?

if (pendingAuthTask) {
return pendingAuthTask;
}

// Make any other requests wait while authentication happens
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? If we are "pausing" the request queue what requests are being made?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean? If we are "pausing" the request queue what requests are being made?

Pausing the queue doesn't prevent you from making actions that trigger a Network.post
I'm saying anything pending or added to the network queue has to wait until we're authenticated.

Network.pauseRequestQueue();

pendingAuthTask = Network.post(commandName, {
// When authenticating for the first time, we pass useExpensifyLogin as true so we check
// for credentials for the expensify partnerID to let users Authenticate with their expensify user
// and password.
Expand Down Expand Up @@ -264,7 +273,15 @@ function Authenticate(parameters) {
}
}
return response;
})
.finally(() => {
// The authentication process is finished so the network can be unpaused to continue
// processing requests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is copied from elsewhere but think it would be good to take the opportunity and explain

  • why we are resetting the pendingAuthTask
  • why we unpause the queue here
  • why we are doing this regardless of success or failure of authentication

pendingAuthTask = null;
Network.unpauseRequestQueue();
});

return pendingAuthTask;
}

/**
Expand Down Expand Up @@ -292,18 +309,9 @@ function reauthenticate(command = '') {
// new authToken
updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
authToken = response.authToken;

// The authentication process is finished so the network can be unpaused to continue
// processing requests
isAuthenticating = false;
Network.unpauseRequestQueue();
})

.catch((error) => {
// If authentication fails, then the network can be unpaused
Network.unpauseRequestQueue();
isAuthenticating = false;

// When a fetch() fails and the "API is offline" error is thrown we won't log the user out. Most likely they
// have a spotty connection and will need to try to reauthenticate when they come back online. We will
// re-throw this error so it can be handled by callers of reauthenticate().
Expand Down
6 changes: 3 additions & 3 deletions src/libs/Network.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,9 @@ function canProcessRequestImmediately(request) {
*
* @param {String} command
* @param {*} [data]
* @param {String} [type]
* @param {Boolean} shouldUseSecure - Whether we should use the secure API
* @returns {Promise}
* @param {String} [type='post']
* @param {Boolean} [shouldUseSecure=false] - Whether we should use the secure API
* @returns {Promise<{ jsonCode: Number }>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure that ppl would be open to hearing your thoughts on why these extra docs are useful or we should do it. But most people are not adding them consistently so I think they should be removed.

I'd recommend:

  1. Bringing it up in the Slack channel
  2. Proposing a change to the style guide
  3. Fixing all the JSDocs so they are consistent

*/
function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
return new Promise((resolve, reject) => {
Expand Down
17 changes: 13 additions & 4 deletions src/libs/actions/Session/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -424,20 +424,29 @@ function validateEmail(accountID, validateCode, password) {
});
}

// It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to
// reconnect each time when we only need to reconnect once. This way, if an authToken is expired and we try to
// We only need to reconnect once. If an authToken is expired, and we try to
// subscribe to a bunch of channels at once we will only reauthenticate and force reconnect Pusher once.
const reauthenticatePusher = _.throttle(() => {
let isReconnectingPusher = false;
function reauthenticatePusher() {
if (isReconnectingPusher) {
return;
}

isReconnectingPusher = true;
Log.info('[Pusher] Re-authenticating and then reconnecting');

API.reauthenticate('Push_Authenticate')
.then(Pusher.reconnect)
.catch(() => {
console.debug(
'[PusherConnectionManager]',
'Unable to re-authenticate Pusher because we are offline.',
);
})
.finally(() => {
isReconnectingPusher = false;
});
}, 5000, {trailing: false});
}
kidroca marked this conversation as resolved.
Show resolved Hide resolved

/**
* @param {String} socketID
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/APITest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import _ from 'underscore';
import * as API from '../../src/libs/API';
import * as Network from '../../src/libs/Network';
import HttpUtils from '../../src/libs/HttpUtils';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import CONFIG from '../../src/CONFIG';

Network.setIsReady(true);

test('Authenticate should not make parallel auth requests', () => {
// We're setting up a basic case where all requests succeed
const xhr = jest.spyOn(HttpUtils, 'xhr').mockResolvedValue({jsonCode: 200});

// Given multiple auth calls happening at the same time
_.each(_.range(5), () => API.Authenticate({
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
partnerUserID: 'testUserId',
partnerUserSecret: 'testUserSecret',
}));

// Then a single auth request should be made
return waitForPromisesToResolve()
.then(() => {
expect(xhr).toHaveBeenCalledTimes(1);
});
});

test('Multiple Authenticate calls should be resolved with the same value', () => {
// A mock where only the first xhr responds with 200 and all the rest 999
const mockResponse = {jsonCode: 200};
jest.spyOn(HttpUtils, 'xhr')
.mockResolvedValueOnce(mockResponse)
.mockResolvedValue({jsonCode: 999});

// Given multiple auth calls happening at the same time
const tasks = _.map(_.range(5), () => API.Authenticate({
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
partnerUserID: 'testUserId',
partnerUserSecret: 'testUserSecret',
}));

// Then they all should be resolved from the first response
return Promise.all(tasks)
.then((results) => {
expect(_.size(results)).toEqual(5);
_.each(results, response => expect(response).toBe(mockResponse));
});
});
80 changes: 48 additions & 32 deletions tests/unit/NetworkTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,22 +132,8 @@ test('consecutive API calls eventually succeed when authToken is expired', () =>
const TEST_CHAT_LIST = [1, 2, 3];

let chatList;
Onyx.connect({
key: 'test_chatList',
callback: val => chatList = val,
});

let account;
Onyx.connect({
key: 'test_account',
callback: val => account = val,
});

let personalDetailsList;
Onyx.connect({
key: 'test_personalDetailsList',
callback: val => personalDetailsList = val,
});
kidroca marked this conversation as resolved.
Show resolved Hide resolved

// When we sign in
return TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN)
Expand Down Expand Up @@ -175,36 +161,34 @@ test('consecutive API calls eventually succeed when authToken is expired', () =>
authToken: 'qwerty12345',
}))

// Get&returnValueList=personalDetailsList
.mockImplementationOnce(() => Promise.resolve({
jsonCode: 200,
personalDetailsList: TEST_PERSONAL_DETAILS,
}))
.mockImplementation((command, data) => {
const response = {jsonCode: 200};

// Get&returnValueList=account
.mockImplementationOnce(() => Promise.resolve({
jsonCode: 200,
account: TEST_ACCOUNT_DATA,
}))
kidroca marked this conversation as resolved.
Show resolved Hide resolved
// Get&returnValueList=chatList
// Get&returnValueList=personalDetailsList
// Get&returnValueList=account
switch (data.returnValueList) {
case 'chatList': response.chatList = TEST_CHAT_LIST; break;
case 'personalDetailsList': response.personalDetailsList = TEST_PERSONAL_DETAILS; break;
case 'account': response.account = TEST_ACCOUNT_DATA; break;
default: throw new Error('Unexpected XHR call');
}

// Get&returnValueList=chatList
.mockImplementationOnce(() => Promise.resolve({
jsonCode: 200,
chatList: TEST_CHAT_LIST,
}));
return Promise.resolve(response);
});

// And then make 3 API requests in quick succession with an expired authToken and handle the response
API.Get({returnValueList: 'chatList'})
.then((response) => {
Onyx.merge('test_chatList', response.chatList);
chatList = response.chatList;
});
API.Get({returnValueList: 'personalDetailsList'})
.then((response) => {
Onyx.merge('test_personalDetailsList', response.personalDetailsList);
personalDetailsList = response.personalDetailsList;
});
API.Get({returnValueList: 'account'})
.then((response) => {
Onyx.merge('test_account', response.account);
account = response.account;
});

return waitForPromisesToResolve();
Expand Down Expand Up @@ -271,3 +255,35 @@ test('retry network request if auth and credentials are not read from Onyx yet',
expect(spyHttpUtilsXhr).toHaveBeenCalled();
});
});

test('retry network request if connection is lost while request is running', () => {
// Given a xhr mock that will fail as if network connection dropped
const xhr = jest.spyOn(HttpUtils, 'xhr')
.mockImplementationOnce(() => {
Onyx.merge(ONYXKEYS.NETWORK, {isOffline: true});
return Promise.reject(new Error('Network request failed'));
})
.mockResolvedValue({jsonCode: 200, fromRetriedResult: true});

// Given a regular "retriable" request (that is bound to fail)
const promise = Network.post('Get');

return waitForPromisesToResolve()
.then(() => {
// When network connection is recovered
Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
return waitForPromisesToResolve();
})
.then(() => {
// Advance the network request queue by 1 second so that it can realize it's back online
jest.advanceTimersByTime(1000);
return waitForPromisesToResolve();
})
.then(() => {
// Then the request should be attempted again
expect(xhr).toHaveBeenCalledTimes(2);

// And the promise should be resolved with the 2nd call that succeeded
return expect(promise).resolves.toEqual({jsonCode: 200, fromRetriedResult: true});
});
});
Loading