Proposal: Chaining middleware using Promises
See original GitHub issueThis 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.
Receiver
s call App.processEvent()
instead of emitting
Receivers are no longer EventEmitter
s. 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:
- One global middleware chain
- 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()
(andawait 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 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
- 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.
- If you used post processing, move that code from inside the callback to after the
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:
- Created 4 years ago
- Reactions:5
- Comments:17 (17 by maintainers)
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 likekoa-compose
where logic afterawait next()
will happen going up the middleware stackapp.processEvent
which returnsPromise<void>
resolving after all middleware is done (completed or errored).Things still needing discussion: (My commentary as subpoints)
App.processEvent
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.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?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.