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

[return-await] Recommendations on return-await #1378

Closed
mightyiam opened this issue Dec 24, 2019 · 15 comments
Closed

[return-await] Recommendations on return-await #1378

mightyiam opened this issue Dec 24, 2019 · 15 comments
Labels
package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin question Questions! (i.e. not a bug / enhancment / documentation)

Comments

@mightyiam
Copy link
Contributor

mightyiam commented Dec 24, 2019

Thank you for this super helpful project!

I am the main maintainer of https://github.com/standard/eslint-config-standard-with-typescript but I'm not a computer science person and sometimes struggle understanding linting rules.

One such example where I struggle is with the return-await rule.

eslint-config-standard-with-typescript aims to provide a stricter set of rules than plain standard using this project's parser and plugin.

Is there an obvious decision regarding return-await — whether it is generally recommended in any form, please? I do see that it is not included in the exported recommended config. I would appreciate all the input on this.

For reference, we have a discussion on no-return-await and ESLint had one as well.

@mightyiam mightyiam added package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for maintainers to take a look labels Dec 24, 2019
@bradzacher bradzacher added question Questions! (i.e. not a bug / enhancment / documentation) and removed triage Waiting for maintainers to take a look labels Dec 25, 2019
@bradzacher
Copy link
Member

bradzacher commented Dec 25, 2019

The bulk of the arguments in standard/standard#1442 do not apply, because we are using typescript. Type checking very easily catches all of these "refactoring traps". Better still, if you use explicit-function-return-types, then you have a very clear signal that you've refactored incorrectly.

There is a lot of debate around as to whether or not you should await a returned promise. There are probably some (negligible) perf benefits to not using return await (due to less scope suspensions). But some people prefer the stack trace one way or the other (FWIW, I don't agree with it, I think you shouldn't have the frame in there, as it's a red herring). Some people just prefer the style because it makes refactoring easier.

It's ultimately down to personal preference. What do you like to do? Do you like the stack trace? Do you prefer the style of not having the extra await? Etc

PERSONALLY:

I think that it's better to not await a returned promise, because I don't think it's right to have the useless await. But I think you should always await the promise if the return is inside a try/catch, so that the error handling actually applies.

My personal recommendation would be to use our return-await rule with the in-try-catch option.

@G-Rath
Copy link
Contributor

G-Rath commented Jan 10, 2020

use our require-await rule with the in-try-catch option.

@bradzacher could you confirm for me if you meant return-await or require-await?

@bradzacher
Copy link
Member

Good catch, sorry. Meant return-await (fixed it)

@mightyiam
Copy link
Contributor Author

Thank you, @bradzacher. Keep up the excellent work.

jhnns added a commit to peerigon/eslint-config-peerigon that referenced this issue Feb 11, 2020
We actually want a rule that enforces to *always use return await*.
Reasoning: Putting try/catch around a return without await is a footgun.

try {
    return somethingAsync();
} catch (error) { <-- will never be caught
}

Further discussions:
- eslint/eslint#12246
- mightyiam/eslint-config-love#206
- typescript-eslint/typescript-eslint#1378
@LinusU
Copy link
Contributor

LinusU commented Feb 26, 2020

Sorry to drag up an old thread 🙈

@bradzacher would you mind quickly elaborating on why you think the extra stack frame is a "red herring"?

One thing that I've had a few times are variations of the following problem: I get an ENOENT with a stack that just goes to me trying to some high level stuff, like initialising a library/api client. I have no idea which inner part of it actually caused the error, since that gets lost when the Promise is returned.

Had the code-base been using return await I would have seen loadConfigFromFile quite high in the stack trace, and then I would have known that it's trying to load an rc-file (e.g; or similar).

My experience is that the stack traces provides much more usable information when having return-await set to always, and I've yet to come across a situation where I wish it didn't show the extra frames.

It should be noted that we've only recently started embracing async/await all the way, but one of the key argument for doing the refactoring is that we've had a lot of problems with errors without stack traces, since our code-base is a bit dated and used a lot of .then(foo).

The parts that we have converted to async await has much better stack traces, and I really like that the extra frames are in.

Sorry, this got a bit longer than I intended 😄, would love to hear your thoughts, I'm always open to seeing the other side!

@CherryDT
Copy link

CherryDT commented Feb 28, 2020

(FWIW, I don't agree with it, I think you shouldn't have the frame in there, as it's a red herring)

@bradzacher Why is it a red herring? Example:

// Assume that User.findByOrFail is a database method that queries by a field on the
// users table and throws a ResourceNotFoundError if it can't be found, otherwise
// returns the first match.

async function getUser (request) {
  if (request.loginMethod === 'jwt') {
    if (!request.jwt || !request.jwt.valid) throw new Error('Invalid JWT')
    return await User.findByOrFail('id', request.jwt.userId)
  } else if (request.loginMethod === 'apiKey') {
    return await User.findByOrFail('apiKey', request.apiKey)
  } else if (request.loginMethod === 'serviceToken' && request.isTrustedSource) {
    // COPY-PASTE ERROR HERE: should be request.serviceToken and not request.apiKey
    return await User.findByOrFail('serviceToken', request.apiKey)
  } else {
    throw new Error('Unknown login method')
  }
}

My example would throw a ResourceNotFoundError in findOrFail if getUser was called with loginMethod: 'serviceToken' due to a copy-paste error. The stack trace would look like this:

ResourceNotFoundError: No matching record found
    at Model.findByOrFail (.../Model.js:59:13)
    at async LoginService.getUser (.../LoginService.js:31:18) <<<<< here is the bug!
    at async RequestMediator.handleInternalRequest (.../RequestMediator.js:102:35)

Without the await the stack frame with the actual bug would be omitted and it would be unclear which of the three findByOrFail invocations inside getUser led to the exception (and that getUser was involved at all can be known only by checking what is at line 102 in handleInternalRequest):

ResourceNotFoundError: No matching record found
    at Model.findByOrFail (.../Model.js:59:13)
    at async RequestMediator.handleInternalRequest (.../RequestMediator.js:102:35)

Not very helpful.

Yes I know I'm missing type definitions here, but I can't see how they would fix it. This example is from a JS case but I think it should apply here too.

@mightyiam
Copy link
Contributor Author

Is this a missing stack trace frames issue before it is a linting issue? Should the stack trace contain the missing frame regardless of await?

@LinusU
Copy link
Contributor

LinusU commented Mar 2, 2020

Should the stack trace contain the missing frame regardless of await?

I don't think that a conforming engine could add the frame unless there is an await there. Even if it could, I don't think it should since the function is only in the stack if you are awaiting...

@CherryDT
Copy link

CherryDT commented Mar 2, 2020

Hm, interestingly in Chrome devtools it is there. I guess it keeps a reference of the stack at the time of function invocation, which is actually a good idea I believe. After all, the actual invocation is inside the other function, regardless of whether it waits for completion or not.

@bradzacher
Copy link
Member

bradzacher commented Mar 2, 2020

In an example like yours, where there are multiple calls to exactly the same async function, then sure there can be some use to that extra frame.

Though chances are you should be able to tell based on the next frame which line was called. I don't know your codebase, but it's unlikely you pass the arguments down 5-10 layers of function calls, all with no awaits, and no logs, and then await the promise at the top level. Chances are you've got code that looks something like:

async function handleRequest(requestFromApi) {
  log(request);
  // ...
  await getUser(request);
  // ...
}

And in that case, it's not entirely important that you have that extra stack frame, because the important part is the request object, and what's in it. You'd care more about the value of the property in the request object and why that threw an error, and less about the exact frame.

or in most code cases, something more like:

async function doSomethingA(apiKey) {
  // ...
  await getUser('apiKey', apiKey);
  // ...
}

async function doSomethingB(serviceToken) {
  // ...
  await getUser('serviceToken', serviceToken);
  // ...
} 

In this case you don't care about that stack frame, because it's trivial to figure it out, and it probably doesn't help to know it.


The other problem you've got is that the extra await can seriously impact performance due to the suspension introduced by the await.
A quick and dirty benchmark shows this impact:

async function returnAwait() { return await Promise.resolve() }
async function noReturnAwait() { return Promise.resolve() }

async function main() {
  console.time('returnAwait')
  for (let i = 0; i < 100000; i+=1) {
    await returnAwait();
  }
  console.timeEnd('returnAwait');


  console.time('noReturnAwait')
  for (let i = 0; i < 100000; i+=1) {
    await noReturnAwait();
  }
  console.timeEnd('noReturnAwait');
}

main();
platformtimes
chrome 82.0.4074.0
returnAwait: 1192.033203125ms
noReturnAwait: 445.592041015625ms
chrome 80.0.3987.122
returnAwait: 930.93798828125ms
noReturnAwait: 456.47900390625ms
node 12.14
returnAwait: 1301.600ms
noReturnAwait: 1158.415ms
node 10.17
returnAwait: 2280.213ms
noReturnAwait: 1454.469ms
node 8.10
returnAwait: 671.070ms
noReturnAwait: 397.231ms

You can imagine if you have multiple return await in your stack, that this performance impact would additively amass.

async function returnAwait(arg) {
  if (arg === 0) {
    return await Promise.resolve()
  }
  arg -= 1;
  return await returnAwait(arg);
}
async function noReturnAwait(arg) {
  if (arg === 0) {
    return await Promise.resolve()
  }
  arg -= 1;
  return noReturnAwait(arg);
}

async function main() {
  console.time('returnAwait')
  for (let i = 0; i < 100000; i+=1) {
    await returnAwait(5);
  }
  console.timeEnd('returnAwait');


  console.time('noReturnAwait')
  for (let i = 0; i < 100000; i+=1) {
    await noReturnAwait(5);
  }
  console.timeEnd('noReturnAwait');
}

main();
platformtimes
chrome 82.0.4074.0
returnAwait: 8650.486083984375ms
noReturnAwait: 2882.72900390625ms
chrome 80.0.3987.122
returnAwait: 5387.321044921875ms
noReturnAwait: 2335.196044921875ms
node 12.14
returnAwait: 4523.902ms
noReturnAwait: 3589.545ms
node 10.17
returnAwait: 7772.676ms
noReturnAwait: 4526.320ms
node 8.10
returnAwait: 2036.175ms
noReturnAwait: 1080.972ms

Ultimately, I guess the question is, "how much is that extra stack frame worth to you?"

Would you rather take the perf impact of a return await to give you the added debuggability? Or would you rather structure your code in such a way that this extra stack frame isn't required (i.e. eliminate multiple calls to the same function where possible my splitting code into multiple functions, etc.

There's no "right" answer here, I don't think there's a true "hard and fast rule" which should say "always always do this".
However I do think that always return await is wrong, though I concede that in certain cases, it has its uses.
As with all linting - there are always exceptions, which is why the eslint-disable class of comments exist, so you can knowingly work around a lint rule, when you have a good reason.

My recommendation is still to use our return-await rule with the in-try-catch option, and know that you should still stop and think if you might want a return await in a specific case.

@CherryDT
Copy link

CherryDT commented Mar 2, 2020

That's interesting, I did my own test before and the result was that it does not cause another tick delay. (In actual processing time it added 12 microseconds per iteration in my tests.)
I'd be curious to know what happens here exactly, since it contradicts my earlier findings here (checked whether it waits for another tick or not) and here (checked how much the actual additional time in the current tick is)

Also, I see that when returning 123 instead of undefined in the resolved promise, the time for the return await doesn't change while the time for the non-awaiting return increases.

@bradzacher
Copy link
Member

I see that when returning 123 instead of undefined in the resolved promise, the time for the return await doesn't change while the time for the non-awaiting return increases.

I don't see that same impact. I get the same average runtimes regardless of what the promise resolves to.

does not cause another tick delay

It does not, but it does cause a microtask to be queued.

https://tc39.es/ecma262/#await

6.2.3.1 Await
...
\1. Let completion be Await(value).
...
\9. Perform ! PerformPromiseThen(promise, onFulfilled, onRejected).

https://tc39.es/ecma262/#sec-performpromisethen

25.6.5.4.1 PerformPromiseThen
...
\9. Else if promise.[[PromiseState]] is fulfilled, then
a. Let value be promise.[[PromiseResult]].
b. Perform EnqueueJob("PromiseJobs", PromiseReactionJob, « fulfillReaction, value »).

And the spec does not explicitly declare when and how the queues are to be processed.

https://tc39.es/ecma262/#sec-jobs-and-job-queues

8.4 Jobs and Job Queues
...
This specification does not define the order in which multiple Job Queues are serviced. An ECMAScript implementation may interweave the FIFO evaluation of the PendingJob records of a Job Queue with the evaluation of the PendingJob records of one or more other Job Queues.

It only dictates that

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty.

There are potentially separate queues for promises and timeouts, so in your examples, it's perfectly reasonable for the engine to enqueue and dequeue the promise result immediately, before dequeueing the setImmediate job, so it looks like it's processed in parallel.


The thing I'm getting at is that promises and how they are handled is sometimes straightforward, and sometimes not.

With all the enqueueing happening, it's reasonable to assume that your application might enqueue a long running promise resolution between the return await and the parent await, which means that you won't have an immediate execution as you've observed, and it instead delays the execution of your task by maybe seconds.

OTOH, if you don't use return await, your execution order is guaranteed. There is no enqueueing beyond the parent await.

Also every await comes with a cost, as you can observe from the spec - constructing and tearing down async contexts takes time and memory.
This cost is mostly negligible, but if you return await everywhere, and have a lot of async processing, this can all add up, and cause slow down + memory bloat across your application.
It could be the difference between hitting and dropping frames, or delaying your first meaningful paint and missing metrics.


It's a tradeoff. Potential better devx for potential impact to the end user.
If your team is okay with that tradeoff, then go for it!

But the more research I do into this, the firmer I become in my recommendation of using in-try-catch.

@LinusU
Copy link
Contributor

LinusU commented Mar 2, 2020

The other problem you've got is that the extra await can seriously impact performance due to the suspension introduced by the await.

I don't think that calling it a "serious impact" is fair, in an actual event looped single threaded application yielding to the runtime more often could actually increase throughput. Even so, your benchmark only shows a few micro-seconds of overhead of calling await.

e.g. benchmarking Math.round(x) vs x | 0 gives smilar results for me, but it seems like making a general recommendation to use x | 0 instead of Math.round because it could be "the difference between hitting and dropping frames" wouldn't be a good takeaway from that. I mean, any straw could be the one that breaks the camels back.

I'd definitely take that super-small (micro-seconds) overhead to aid in debugability.

In this case you don't care about that stack frame, because it's trivial to figure it out, and it probably doesn't help to know it.

Even though though you sometimes, maybe even in many cases, can figure out what the missing frames are, I don't see what negative it would do to just have them there from the beginning.

It's a tradeoff. Potential better devx for potential impact to the end user.

To me, this sounds very lot like premature optimisation. I cannot possible see an app where extra return await would make a meaningful impact. In all other cases I always prefer debugability, correctness and developer experience over micro-optimisations.

@bradzacher
Copy link
Member

but it seems like making a general recommendation to use x | 0 instead of Math.round because it could be "the difference between hitting and dropping frames" wouldn't be a good takeaway from that

If your application does make copious use of rounding, then I would recommend it. Though I would recommend instead just using a babel transform to optimise it away to keep things clean and clear for developers.

It's important to evaluate your codebase to look for common patterns, and figure out if it's good or bad.

I mean, any straw could be the one that breaks the camels back.

Sure, but IMO this straw is unnecessary weight, and shouldn't be introduced in the first place


This is something we clearly aren't going to agree upon, and that's okay. Linting is configurable because it is all subjective, and opinionated.

You're free to use it in your codebase if you think it improves your devx. My recommendation will remain as mentioned.

@LinusU
Copy link
Contributor

LinusU commented Mar 2, 2020

This is something we clearly aren't going to agree upon, and that's okay

Sound good 👍 ☺️

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Apr 20, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin question Questions! (i.e. not a bug / enhancment / documentation)
Projects
None yet
Development

No branches or pull requests

5 participants