Implementing asynchronous endpoints in REST with callbacks

(click on the image to view it in full resolution)

This is an extension of the asynchronous endpoint pattern where the consumer can register a callback URL, which is invoked when the task is completed. This only works if the consumer has the option to expose an externally hittable REST endpoint. The advantage is that the consumer does not have to keep polling the service to see if the task has completed.

Once the task completes, the service invokes the callback URL. A common approach is to send just a task id to the callback, and the consumer then invokes a call to the service to fetch the full payload. A less common approach (if sensitive info is not included in the payload) is to send the entire result-set to the callback endpoint.

On the service side, the implementation is done by reacting to the task completed event by invoking the registered callback for the completed task. There are several ways to do this. A simple approach is for the task worker itself to invoke the callback. The disadvantage there is that it’s complex to now support retries if the callback endpoint is down. With AWS, a common approach is to update the data in a DynamoDB table and for a Lambda to be triggered off that, and the Lambda would then invoke the callback. A similar approach for Azure is to have a Function triggered off Table storage or Cosmos DB, which would invoke the callback. Both AWS and Azure have mechanisms to add retry to the function/lambda, to handle scenarios where the callback URI is down.

Implementing asynchronous endpoints in REST

When you have an API endpoint that takes too long to run, it can result in consumers facing time-outs or blocking scenarios. Now while consumers can use background workers to make these long running calls, it’s far more convenient if the service is implemented to support an asynchronous operation. A very common approach is to queue up the work on the service side and to return a task-id to the consumer. The consumer can then periodically poll the API with the task-id which returns a response indicating whether the task has been completed or not. Once the task completes, the consumer then makes a call to retrieve the completed task response. In some implementations, these two operations are combined, so it’s the same endpoint that both returns the pending status as well as the completed result.

On the service side, the implementation is typically done using a queue. The first call generates and persists a task-id which is returned, and the task is queued up in a queue (such as AWS SQS or Azure Queues). You can then trigger off a worker via AWS Lambdas or Azure Functions which performs the task and then marks the task as completed.

Adding a lambda target with EventBridge

Unlike Kafka or Kinesis, you don’t really have a concept of a persisted topic or stream to which multiple consumers can consume from. Instead you need to set rules/targets, so it’s more like a concious push than a pull. Arguably, this does add a little bit of coupling in the sense that the producing team is aware of their consumers – but in many scenarios, that may actually be preferred.

Continuing with the example from the previous post, you’d need to add a new rule with the following event pattern.

{
  "source": ["demo-event"]
}

If you are wondering where that came from, here’s how you constructed your event entries. So the pattern above is basically saying, any time an event is pushed to the bus with that source, fire the rule.

PutEventsRequestEntry requestEntry1 = new PutEventsRequestEntry
{
    Source = "demo-event",
    EventBusName = "dev-event-bus",
    DetailType = "mock.client",
    Detail = payload1
};

You can now add a target to the rule. To test it out, the simplest approach is to set a cloudwatch log group as the target. Now you can just use the AWS console to confirm that your events are being received by the bus and that the rule is firing. Once you do that, you can test out the lambda target by creating a very simple lambda in C#.

public class Function
{        
    public async Task FunctionHandler(
        CloudWatchEvent<object> input, 
        ILambdaContext context)
    {
        context.Logger.LogLine($"Lambda: {input.Source}");
    }
}

The main thing is to note the signature for the FunctionHandler method. The events are going to be of type CloudWatchEvent<object>. And the one line of code just logs it to cloudwatch.

Amazon EventBridge

Amazon EventBridge is not an apples-to-apples comparable to Kafka or Kinesis, but it does enable event driven software architectures and is a serverless offering, so if you are heavily invested in AWS, it is very easy to move forward with it. It’s trivial to create an event bus (Amazon calls them custom event buses, but it’s basically just a named bus), and to produce events to it. For each event bus, you can have one or more rules associated with it. The rules dictate what happens when a certain event is fired – and it’s very basic pattern matching against the event schema. Each rule can have one or more targets – you can use lambdas or cloudwatch log groups as targets among others. Recently they added support for API targets – so basically you can point to any REST endpoint.

Producing Events

If your dev stack is .NET Core, you can use the AWSSDK.EventBridge NuGet package (official from AWS). Here’s some sample code that shows how to produce events to a specific bus. You can produce multiple events with a single call, but the bus receives them as individual events. So the example code below produces two separate events – meaning the targets receive them separately.

class Program
{
    static async Task Main()
    {
        string payload1 = JsonConvert.SerializeObject
            (
                new
                {
                    Firstname = "Joe",
                    Lastname = "Spodzter",
                    Email = "joe.s.100@yahoo.com"
                }
            );

        string payload2 = JsonConvert.SerializeObject
            (
                new
                {
                    Firstname = "Mary",
                    Lastname = "Spodzter",
                    Email = "joe.s.100@yahoo.com"
                }
            );

        PutEventsRequestEntry requestEntry1 = new PutEventsRequestEntry
        {
            Source = "demo-event",
            EventBusName = "dev-event-bus",
            DetailType = "mock.client",
            Detail = payload1
        };

        PutEventsRequestEntry requestEntry2 = new PutEventsRequestEntry
        {
            Source = "demo-event",
            EventBusName = "dev-event-bus",
            DetailType = "mock.client",
            Detail = payload2
        };

        using var client = new AmazonEventBridgeClient(RegionEndpoint.USEast2);

        var response = await client.PutEventsAsync
            (
                new PutEventsRequest()
                {
                    Entries =
                    {
                        requestEntry1, requestEntry2
                    }
                }
            );

        Console.WriteLine(response.HttpStatusCode);
    }
}