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
Fixes source token expiration by token refresh #5198
Conversation
enum TokenState { | ||
"NONE", | ||
"VALID", | ||
"INVALID", | ||
} |
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.
tangent: enums are controversial in TS for some reason.
https://blog.logrocket.com/why-typescript-enums-suck/
Internally, I think we prefer string literals:
type TokenState = "none" | "valid" | "invalid"
} | ||
|
||
calculateTokenExpiry(): bigint { | ||
const now = process.hrtime.bigint(); |
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.
Personally, I think using hrtime
with its bigint
silliness is unnecessarily precise, especially if the time range we care about is in MINUTES not MICROSECONDS.
I'd suggest we stick to Date.now()
and forget about big ints.
if (this.tokenState === TokenState.NONE) { | ||
this.tokenState = TokenState.INVALID; | ||
return undefined; | ||
} else if (this.tokenState === TokenState.INVALID) { |
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 found that token states as described here bit confusing. tokenState == INVALID
makes me think that token is invalid, but token doesn't actually exist at this point.
What about another name that closely maps to the state of the scraper, like tokenFetchState
instead of tokenState
and FETCHING
instead of INVALID
?
return this.promise; | ||
} | ||
|
||
checkTokenExpired(): boolean { |
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.
nit* I usually name my boolean function with "is" like isTokenExpired
|
||
checkTokenExpired(): boolean { | ||
if (this.expiration === undefined) { | ||
throw new Error(); |
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.
This error is user visible - we should try our best to describe the situation and also call out what users should do when they see the issue.
@@ -210,9 +210,11 @@ export class Fabricator { | |||
if (apiFunction.httpsTrigger) { | |||
apiFunction.httpsTrigger.securityLevel = "SECURE_ALWAYS"; | |||
} | |||
apiFunction.sourceToken = await scraper.tokenPromise(); | |||
// apiFunction.sourceToken = await scraper.tokenPromise(); |
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.
nit - stray comment?
return Promise.resolve(undefined); | ||
isTokenExpired(): boolean { | ||
if (this.expiry === undefined) { | ||
throw new FirebaseError("failed to check expiry: no token exists"); |
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.
This error message makes sense to you and me but won't to almost all users trying to deploy their function.
I think this is a good example of a user facing error message - describe the situation in coarsely and suggest next steps:
firebase-tools/src/deploy/functions/prepareFunctionsUpload.ts
Lines 36 to 40 in 2ebefe2
throw new FirebaseError( | |
"Cloud Runtime Config is currently experiencing issues, " + | |
"which is preventing your functions from being deployed. " + | |
"Please wait a few minutes and then try to deploy your functions again." + | |
"\nRun `firebase deploy --except functions` if you want to continue deploying the rest of your project." |
In our case, hitting this if statement should never happen and would be considered a bug. Maybe we can suggest that they file a issue.
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.
ah gotcha. working on a more descriptive message.
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.
throw new FirebaseError(
"Your deployment is checking the expiration of a source token that has not yet been polled. " +
"Hitting this case should never happen and should be considered a bug. " +
"Please file an issue at https://github.com/firebase/firebase-tools/issues " +
"and try deploying your functions again."
);
What about something like this?
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## master #5198 +/- ##
=========================================
Coverage ? 56.26%
=========================================
Files ? 308
Lines ? 20814
Branches ? 4225
=========================================
Hits ? 11710
Misses ? 8092
Partials ? 1012
Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here. ☔ View full report at Codecov. |
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.
Lgtm
this.promise = new Promise((resolve) => (this.resolve = resolve)); | ||
return undefined; | ||
} | ||
return this.promise; |
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.
Should we throw an error here instead of silently succeeding? Can see argument for either.
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.
By throwing an error, do you mean throwing an error and then catching it in an enclosing try { await getToken(); ... } catch (expiredErr) {...}
block, and then retrying?
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 mean that in theory we should never reach this codepath - fetchState can only be in one of three states, and we have if condition for all of them. So we could throw (which could happen if we misunderstood this code for somee reason) or just return the promise (which might leave the CLI in an undefined state). I have slight preference for the former.
await expect(scraper.getToken()).to.eventually.equal("magic token #2"); | ||
}); | ||
|
||
it("concurrent requests for source token", async () => { |
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.
Nice test!!
return Promise.resolve(undefined); | ||
isTokenExpired(): boolean { | ||
if (this.expiry === undefined) { | ||
throw new FirebaseError( |
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.
Can we update this to throw with code: 2? We track this in Google Analytics for internal assertion errors.
const timeout = (duration: number): Promise<void> => { | ||
return new Promise<void>((resolve) => setTimeout(resolve, duration)); | ||
}; | ||
await timeout(50); |
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.
In the future, try to instrument code without sleeping in your test. You can inject a clock for example.
Developers have reported a variety of issues while attempting large function deployments (see #4266). Specifically, developers were encountering an
Invalid source token
error message after successfully deploying a subset of the functions, indicating that at least part of the issue stemmed from the source token re-use hack (used to expedite deployments by re-using the same container for each function).On particularly large deployments (~100+), the process can take >30 minutes. The source token acquired after the first function deployment expires at 30 minutes, resulting in the failure of subsequent function deployments that included the expired source token.
This change tracks the expiration of acquired source tokens, and after about 25 minutes, discards the old token and instructs the next function deployment to request a new source token. The remaining functions wait until the new source token is acquired before starting deployment. A 5 minute buffer is provided to basically guarantee that no subsequent function deployments will include the expired token. After about 25 minutes, the second token also expires and the next function requests a new token, etc., etc.