-
Notifications
You must be signed in to change notification settings - Fork 407
Proactively refresh access tokens before expiration #9
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
Conversation
Change-Id: Id8cc988ca5ba13a19287e203464c80171d53a64b
Change-Id: I3520e34d2d70deb9ae4dfca95239f61d9395cec4
src/firebase-app.ts
Outdated
| }); | ||
| } | ||
|
|
||
| // Establish a timeout to proactively refresh the token five minutes before it expires. In |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you feel about the logic for when to refresh the token? We could do something more complex, but the tokens should have a high expiration time (way above five minutes) in almost all situations. The reason I chose six minutes as the cutoff point is to handle the situation where the expiration time is in five minutes and one second. I didn't want that to end up refreshing the token with only a second left, since then we would not have enough time to actually refresh the token. We could do something more complex (e.g. if expiration is less than six minutes but more than two minutes away, refresh one minute before expiration and if expiration is less than two minutes away, refresh immediately), but I don't think that really gains us anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong opinion about this. This seems fine as is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about not refresh at all if the expiry time is less than 6 minutes? If the backend server (erroneously) sends us tokens with a short ttl, the implemented logic will essentially force the SDK into a refresh loop.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree we should avoid a possible refresh loop. I do think we should handle a short expiry though since I honestly cannot say for certain when or why a short expiry would occur. I took a new approach which fixes this problem and the missing retry problem Bassam called out. Now, the SDK will attempt to refresh every minute starting at 5 minutes before expiry. So, in the standard case, we will just need one refresh at 5 minutes before expiry. In rare cases, the first refresh will fail and we will need to try a second retry at 4 minutes. In even rarer cases, we may get a short lived token (say that only last 2.5 minutes), and it will retry at 2 minutes before expiry. This should prevent endless refresh loops and add some resiliency to intermittent failures. I also added some tests for these scenarios.
Please take another look and let me know if you think this is a good compromise.
Change-Id: Idae8aaacd0b9f86980a978b8bb6a4c35695f8d6b
src/firebase-app.ts
Outdated
| // instance has not already been deleted. | ||
| if (!this.isDeleted_) { | ||
| this.tokenRefreshTimeout_ = setTimeout(() => { | ||
| this.getToken(/* forceRefresh */ true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While server side code is more stable than client side code, in the likelihood of getToken(true) failing for some recoverable error (retrial would fix it), the proactive token refresh would be broken here. The proactive token refresh would not restart until the developer or some service tries to get a token after expiration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
src/firebase-app.ts
Outdated
| }); | ||
| } | ||
|
|
||
| // Establish a timeout to proactively refresh the token five minutes before it expires. In |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't have a strong opinion about this. This seems fine as is.
src/firebase-app.ts
Outdated
| if (!this.isDeleted_) { | ||
| this.tokenRefreshTimeout_ = setTimeout(() => { | ||
| this.getToken(/* forceRefresh */ true); | ||
| clearTimeout(this.tokenRefreshTimeout_); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If force token refresh is called externally and it succeeds, the timeout should be cleared then, too. Otherwise you will have 2 timers running.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great catch! I fixed this and also added a test for it.
hiranya911
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just had one minor suggestion. I'll defer to the bojeil-google for other JS-specific feedback,
src/firebase-app.ts
Outdated
| }); | ||
| } | ||
|
|
||
| // Establish a timeout to proactively refresh the token five minutes before it expires. In |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about not refresh at all if the expiry time is less than 6 minutes? If the backend server (erroneously) sends us tokens with a short ttl, the implemented logic will essentially force the SDK into a refresh loop.
|
Thanks to you both for the reviews on this proactive refresh PR. Assigning this back to myself so I can take another crack at it. Note that I don't plan to ship this with tomorrow's |
Change-Id: Ie8027c789138f0a9924e52056ed85042f69c9969
test/unit/firebase-app.spec.ts
Outdated
| }); | ||
| }); | ||
|
|
||
| // TODO(jwenger): I simply cannot get this test to pass although it should. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am stumped as to why this test won't pass... I assume it has something to do with mocking the timers, but the previous test works fine. I think the code is correct and it should pass.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does the test fail exactly?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The setTimeout() only ever fires once, even though it gets set up multiple times. So, the expect(getTokenStub.callCount).to.equal(2); check fails.
test/unit/firebase-app.spec.ts
Outdated
| this.clock.tick(ONE_MINUTE_IN_MILLISECONDS); | ||
|
|
||
| // Ensure the token was attempted to be proactively refreshed two times. | ||
| expect(getTokenStub.callCount).to.equal(2); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is not calling more than once for the following reason:
getToken(true) called manually and succeeds,
timeout set to 5 mins before timeout.
set clock to 5mins before expiration.
getToken(true) will be called underneath
clearTimeout(this.tokenRefreshTimeout_) will clear any pending timeout
this.cachedTokenPromise_ = Promise.resolve(this.credential_.getAccessToken()) will fail and the catch part will run
catch part will do nothing
Proactive refresh will not run again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The thing is that I'm stubbing mockApp.INTERNAL.getToken() to always return a rejected promise. I'm not stubbing credential.getAccessToken(). So we should never get to the point where we clear the timeout since that code has been stubbed. Also, we do indeed want to clear the timeout whenever getToken() is called since we will only re-set the timeout if that fails. And the catch() on getAccessToken() re-throws the error so that should be okay.
I think it is probably better to mock the credential's getAccessToken() method instead of getToken() itself, so I'll play around a bit more with that. This is all still really puzzling though...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. I suppose, we can revisit this and stub credential.getAccessToken instead. Anyway, if you tested this and it works in real life, we can proceed and revisit after. Maybe just add a note on where the test is failing and why you think this is happening for the future. Thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I was able to get this to work once I moved the logic for caching getAccessToken() failures to a different spot. I think the new logic is technically more correct in the sense that it fixes the test scenario, although I don't think that is a very real-life situation. Nonetheless, it still works the same for all other situations so I think it's probably the right place for it. And now all the tests pass! Please take another look and let me know what you think.
test/unit/firebase-app.spec.ts
Outdated
| this.clock.tick(ONE_MINUTE_IN_MILLISECONDS); | ||
|
|
||
| // Ensure the token was attempted to be proactively refreshed two times. | ||
| expect(getTokenStub.callCount).to.equal(2); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. I suppose, we can revisit this and stub credential.getAccessToken instead. Anyway, if you tested this and it works in real life, we can proceed and revisit after. Maybe just add a note on where the test is failing and why you think this is happening for the future. Thanks.
Change-Id: I6b323d5f0574d841940f8d63bbd6cd4755fd35a9
|
LGTM. |
|
LGTM |
|
Just to follow up here, I did some long running tests over the past few hours and things appear to be behaving as expected and desired! In the normal case, we refresh the token every 55 minutes. In the case of a short-lived token, we refresh at the top of the next minute. And in the case when we cannot successfully refresh the token, we retry five times before giving up. So we are good to ship this tomorrow! |
|
Cool, thanks for the update! |
Description
The SDK will now proactively refresh Google OAuth2 access tokens before they expire. In the common cases where the expiration time is greater than six minutes away, we will refresh it five minutes before it expires. In the rare cases where the expiration time is less than or equal to six minutes away, we will refresh it immediately.
One somewhat interesting result of this change is that, by introducing a
setTimeout()call, the Node.js process will stay alive until the timeout either runs or the app is deleted. This broke our tests (in the sense that they hung after they reported a success) because we were not properly deleting used apps to clean up resources. It is possible this could have a similar "hanging" impact on end-users scripts if they do the same.Code sample
N/A
cc/ @inlined - FYI.