question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Proposal: Chaining middleware using Promises

See original GitHub issue

This proposal is meant to address the following use cases:

  • Building synchronous Receiver implementations (those which need to know when an incoming event is fully processed).
  • Testing listener and middleware functions without an arbitrary delay
  • Capturing/handling errors that occur and were previously unhandled #248, and possibly #239.

The changes

Adjust ack() and next() to return Promises

All listeners (with the exception of those listening using app.event()) and all middleware will become async functions. The consequence of using a normal function where an async one was expected would be either to interpret it the same way as Promise.resolve(). Listeners and middleware’s returned promise should resolve when processing the event is complete. If it rejects, the middleware preceding it in the chain may catch, but if it doesn’t catch the middleware should also reject, and so on. Rejections that bubble to the first middleware are handled through the global error handler.

ack() returns Promise<void>. The promise resolves when the receiver is done acknowledging the event (typically when the HTTP response is done being written). By allowing the promise to reject, listeners can handle errors that occur during acknowledgement (like trying to send the HTTP response). Currently, receivers are expected to handle these sorts of errors, but they typically have no ability to do anything intelligent.

Code for work that should be performed after the incoming event is acknowledged should be performed after calling await ack() in the listener or middleware function. The consequence of forgetting the await keyword will likely be invisible. The returned promise would be unhandled and might reject, but that kind of error is very rare.

next() returns Promise<void>. The promise resolves when the next middleware in the chain’s returned promise resolves. This builds a chain through middleware where early middleware gain a signal for when processing has completed in all later middleware. That signal can then be passed onto the receiver. It also means middleware use await next() and follow with more code to post-process the event (such as logging middleware). This replaces the usage of next() where a callback function is passed in.

The global error handler can return a Promise

If a rejection bubbles through the first middleware, the global error handler is triggered. This is not new. But instead of only returning void it can optionally a Promise. If the returned promise rejects, that rejection is exposed to the receiver as described below. The default global error handler will log the error and then reject, which means by default all unhandled errors in listeners and middleware make their way back to the receiver.

Receivers call App.processEvent() instead of emitting

Receivers are no longer EventEmitters. Instead, they are provided with an App instance upon initialization. When the receiver has an event for the app to process, it calls the App.processEvent() method, which returns Promise<void>. The promise resolves when all middleware and listeners have finished processing the event. The promise rejects when the global error handler’s returned promise rejects.

There’s no resolved value for the returned promise. The receiver is expected to remember the value passed to the ack() function (which the receiver created) and associate that value to the returned promise if it chooses to delay the HTTP response until all processing is complete. This allows us to built synchronous receivers that only respond once all processing is complete.

Dispatch algorithm changes

Events are dispatched through 2 separate phases:

  1. One global middleware chain
  2. Many (n=number of listeners attached) listener middleware chains.

Between these two phases, a special middleware manages dispatching to all the listener middleware chains in parallel. This will not change. However, the return values of the individual listener middleware chains will start to become meaningful, and need to be bubbled up through the global middleware chain. This special middleware between the phases will aggregate the returned promises and wait for them all to complete (a la Promise.all()). If any promise(s) reject, then the middleware will return a rejected promise (whose error is a wrapper of all the resolved promises) to bubble through the global middleware chain. Conversely, if all of them succeed, the middleware will return a resolved promise to bubble through the global middleware chain.

Middleware which choose not to handle an event should return a resolved promise without ever calling next().

Align say() and respond() to return Promises

Now that listeners and middleware are expected to be async functions, it follows that all the utility functions given to them which perform asynchronous work should also return Promises. This aligns with ack() and next() by similarly allowing listeners to handle errors when calling say() or when calling respond().

The consequence of forgetting the await keyword will likely be invisible. The returned promise would be unhandled and might reject, but that kind of error is very rare.

Disadvantages

This is a breaking change. The scope of the backwards incompatibility is pretty limited, and here are some thoughts about migration:

  • Make all your listeners and middleware async functions.
    • Even if you fail to do this, Bolt will warn you, so it shouldn’t cause too many issues.
  • Adjust your code to await ack() (and await next()/say()/respond()).
    • If you fail to do this, your app will likely continue working except for the extremely rare occurrence when finishing an HTTP response has an error, and then you’ll know you should adjust due to an UnhandledPromiseRejection error.
  • If you wrote a middleware, adjust your code to await next() instead of calling it directly
    • If you used post processing, move that code from inside the callback to after the await next().
    • If you fail to do this, your app will likely continue working until a later middleware or listener fails, and then you’ll know you should adjust due to an UnhandledPromiseRejection error.
    • Estimating by the issues created so far, the number of apps using middleware today is very small.

The performance might suffer. The change in the dispatch algorithm requires all the listener middleware chain returned promises to resolve. Most should resolve rather quickly (since the first middleware in most of the chains will filter out the event and immediately return). However, if some middleware needs to process the event asynchronously before it can decide to call next() or not, it could slow down the event from being fully processed by another much quicker listener middleware chain. Once again, we haven’t heard from many users who use listener middleware, especially for asynchronous tasks, so we think this impact is relatively small. We should measure the performance of our example code (and new examples) to understand whether or not this has a significant impact.

There will be less compatibility with Hubot and Slapp apps who wish to migrate and continue to use middleware they wrote. A design similar to this one was considered before releasing Bolt v1. It was deliberately rejected in an effort to maximize compatibility with Hubot and Slapp. However, at this point there’s no indication that many Hubot or Slapp developers are actively migrating their code, and no indication that this specific part of migration is a problem worth holding back on this design’s advantages for.

Other benefits

The old way could be implemented in terms of the new way (but not the other way around). This could be a useful way to assist developers in migrating. In fact, this could pave the way for how “routers” within Bolt are composed together to make a larger application. The special middleware between the two phases can be thought of as a router that you get by default, and could be instantiated separately in other files. They could all then be composed back into the main app.

Credit to @selfcontained, @seratch, @barlock, and @tteltrab. I’ve simply synthesized their ideas into a proposal.

Requirements (place an x in each of the [ ])

  • I’ve read and understood the Contributing guidelines and have done my best effort to follow them.
  • I’ve read and agree to the Code of Conduct.
  • I’ve searched for any related issues and avoided creating a duplicate issue.

Issue Analytics

  • State:closed
  • Created 4 years ago
  • Reactions:5
  • Comments:17 (17 by maintainers)

github_iconTop GitHub Comments

3reactions
barlockcommented, Jan 3, 2020

What’s next for making this proposal a reality? It would be great to start working on it.

To summarize the points of agreement I’m seeing:

  • next, ack, say, respond will all switch to promise based. (Did I miss any?)
  • next will act like koa-compose where logic after await next() will happen going up the middleware stack
  • Processing algorithm: Global middleware down => All listeners in parallel down then up => Global Middleware up. Errors anywhere are sent to the global error handler.
  • Receivers remove their event emitter and instead call app.processEvent which returns Promise<void> resolving after all middleware is done (completed or errored).
  • FaaS support will not be a priority for this proposal

Things still needing discussion: (My commentary as subpoints)

  • Function signatures for listener middleware
    • Receivers will break, technically a major semver update, do we communicate that to users with a change in listener middleware signature as well?
    • I think this would make the js community more cohesive (common signatures across projects) and make code-reuse easier (koa-compose vs handwritten). I don’t feel strongly about this.
  • Function signature for receivers calling App.processEvent
    • Still feels wrong to me that the promise never rejects. Let me try another scenario to try to persuade. In the event of an error before an ack (fetching secrets, state, etc.), letting the receiver respond immediately with a non-200 error code will inform users that something wen’t wrong with their interaction faster (Yey better ux) rather than spinning and timing out. Also, Slack’s servers don’t need to hold onto those connections as long. I would think receivers would only do this type of action rather than handle the error. The error would still also propagate to the global handler in parallel. I feel medium strong about this.
    • Seems like a good opportunity to clear a TODO and have processEvent handle signature verification rather than the receiver.

Did I miss anything? If there are parts that are good to go already, what are the steps to getting code changes? next branch to make prs into?

2reactions
aoberoicommented, Mar 27, 2020

We’ve gone ahead and landed the implementations we’ve converged on in the @slack/bolt@next branch! Closing this issue because I believe the discussion is over.

Read more comments on GitHub >

github_iconTop Results From Across the Web

chaining redux-actions and redux-promise-middleware
The problem with redux-thunk is that you have to transfer more and more data as parameters to the actions, and in the end,...
Read more >
The elegance of asynchronous middleware chaining in Koa.js
Middlewares can intercept the request-response cycle and modify the response. One key point is that middlewares can be chained.
Read more >
Promises chaining - The Modern JavaScript Tutorial
When a handler returns a value, it becomes the result of that promise, so the next .then is called with it. A classic...
Read more >
Manually Invoking Express.js Middleware Functions
So, if I want to invoke Express.js middleware functions manually, it makes sense to try and wrap their execution in a Promise chain,...
Read more >
Unit Testing Express Middleware - SlideShare
MIDDLEWARE Use Promise as Link Between Middleware and Endpoints var Q = require('q. Pull Middleware Into Tests TEST Use Promises with Mocha var ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found