[The API Patterns] Multiplexing Notifications. Asynchronous Event Processing
With this post, I’m continuing publishing the v2 of my book dedicated to APIs. If you like this book, please rate it on GitHub, Amazon, or Goodreads and
Chapter 22. Multiplexing Notifications. Asynchronous Event Processing
One of the vexing restrictions of almost every technology mentioned in the previous chapter is the limited size of messages. With client push notifications the situation is the most problematic: Google Firebase Messaging at the moment this chapter is being written allowed no more than 4000 bytes of payload. In backend development, the restrictions are also notable; let's say, Amazon SQS limits the size of messages to 256 KiB. While developing webhook-based integrations, you risk hitting the maximum body size allowed by the partner's webserver (for example, in nginx the default value is 1MB). This leads us to the necessity of making two technical decisions regarding the notification formats:
Whether a message contains all data needed to process it or just notifies some state change has happened
If we choose the latter, whether a single notification contains data on a single change, or it might bear several such events.
On the example of our coffee API:
// Option #1: the message
// contains all the order data
POST /partner/webhook
Host: partners.host
{
"event_id",
"occurred_at",
"order": {
"id",
"status",
"recipe_id",
"volume",
// Other data fields
…
}
}
// Option #2: the message body
// contains only the notification
// of the status change
POST /partner/webhook
Host: partners.host
{
"event_id",
// Message type: a notification
// about a new order
"event_type": "new_order",
"occurred_at",
// Data sufficient to
// retrieve the full state,
// in our case, the order identifier
"order_id"
}
// To process the event, the partner
// must request some endpoint
// on the API vendor's side,
// possibly asynchronously
GET /v1/orders/{id}
→
{ /* full data regarding
the order */ }
// Option #3: the API vendor
// notifies partners that
// several orders await their
// reaction
POST /partner/webhook
Host: partners.host
{
// The system state revision
// and/or a cursor to retrieve
// the orders might be provided
"occurred_at",
"pending_order_count":
<the number of pending orders>
}
// In response to such a call,
// partners should retrieve the list
// of ongoing orders
GET /v1/orders/pending
→
{
"orders",
"cursor"
}
Which option to select depends on the subject area (and on the allowed message sizes in particular) and on the procedure of handling messages by partners. In our case, every order must be processed independently and the number of messages during the order life cycle is low, so our natural choice would be either option #1 (if order data cannot contain unpredictably large fields) or #2. Option #3 is viable if:
The API generates a lot of notifications for a single logical entity
Partners are interested in fresh state changes only
Or events must be processed sequentially, and no parallelism is allowed.
NB: the approach #3 (and partly #2) naturally leads us to the scheme that is typical for client-server integration: the push message itself contains almost no data and is only a trigger for ahead-of-time polling.
The technique of sending only essential data in the notification has one important disadvantage, apart from more complicated data flows and increased request rate. With option #1 implemented (i.e., the message contains all the data), we might assume that returning a success response by the subscriber is equivalent to successfully processing the state change by the partner (although it's not guaranteed if the partner uses asynchronous techniques). With options #2 and #3, this is certainly not the case: the partner must carry out additional actions (starting from retrieving the actual order state) to fully process the message. This implies that two separate statuses might be needed: “message received” and “message processed.” Ideally, the latter should follow the logic of the API work cycle, i.e., the partner should carry out some follow-up action upon processing the event, and this action might be treated as the “message processed” signal. In our coffee example, we can expect that the partner will either accept or reject an order after receiving the “new order” message. Then the full message processing flow will look like this:
// The API vendor
// notifies the partner that
// several orders await their
// reaction
POST /partner/webhook
Host: partners.host
{
"occurred_at",
"pending_order_count":
<the number of pending orders>
}
// In response, the partner
// retrieves the list of
// pending orders
GET /v1/orders/pending
→
{
"orders",
"cursor"
}
// After the orders are processed,
// the partners notify about this
// by calling the specific API
// endpoint
POST /v1/orders/bulk-status-change
{
"status_changes": [{
"order_id",
"new_status": "accepted",
// Other relevant information
// e.g. the preparation time
// estimates
…
}, {
"order_id",
"new_status": "rejected",
"reason"
}, …]
}
If there is no genuine follow-up call expected during our API work cycle, we can introduce an endpoint to explicitly mark notifications as processed. This step is not mandatory as we can always stipulate that it is the partner's responsibility to process notifications and we do not expect any confirmations. However, we will lose an important monitoring tool if we do so, as we can no longer track what's happening on the partner's side, i.e., whether the partner is able to process notifications on time. This, in turn, will make it harder to develop the degradation and emergency shutdown mechanisms we talked about in the previous chapter.
This is Chapter 22 of “The API” book being written by Sergey Konstantinov. I also have a book on the history of beer and historical beer styles, a Telegram channel on interesting classical music recordings, a travel photo blog on Unsplash, and a website with ranking fantasy & science fiction novels based on awards they received.