Posted in:

One of the really nice new features released last year in Durable Functions v2 is support for "Durable HTTP APIs".

This feature simplifies calling HTTP APIs from your orchestrator functions. As you may know, in an orchestrator function, you're not allowed to perform any non-deterministic operations, so to call a HTTP API, you'd need to call an activity function, and make the HTTP request in there. The Durable HTTP feature removes the need to create an additional activity function.

Here's an example of an orchestrator that simply makes a GET request to another site, using IDurableOrchestrationContext.CallHttpAsync without needing an activity function. The returned result object gives us access to the status code, body and headers of the HTTP response.

[FunctionName(nameof(DurableHttpOrchestrator))]
public static async Task<List<string>> DurableHttpOrchestrator(
        [OrchestrationTrigger] IDurableOrchestrationContext context, 
        ILogger log)
{
    var result = await context.CallHttpAsync(HttpMethod.Get, 
        new System.Uri("https://mysite.com/"));            
    log.LogInformation($"Completed: {result.StatusCode} {result.Content}");
}

This works really well, but what if the endpoint you are making takes a long time to return? In my tests, Durable Functions gives up waiting and throws an exception after about 90 seconds.

Fortunately, there is another really cool capability built in to making durable HTTP calls, and that is the support for async operation tracking with automatic 202 polling.

With this approach, if the API you're calling is a long-running operation, it simply returns a 202 Accepted status code, with a Location header indicating where to go to poll for progress. You can also return a Retry-After header which specifies in seconds how long to wait before polling.

When Durable Functions makes a CallHttpAsync request and gets a 202 in response, it starts polling the location in the Location header. If the operation has not yet completed, it should again receive a 202 response with the Location and optional Retry-After headers. Once the operation has completed, the polling endpoint can simply return any other status code.

All of this behaviour is completely transparent in the orchestrator code. You simply call CallHttpAsync and it will keep polling until it gets a response other than 202.

To try this out you can also use Azure Functions to implement an API that uses the 202 pattern. Obviously you wouldn't actually need to do this if your API was in the same function app as the orchestrator, but here's how to implement the pattern anyway.

The first function we need is the one that starts the long-running operation. This is called BeginOperation, and it simply takes a query string parameter called duration which is how long in seconds the overall operation will take.

I then simply construct the polling location, which assumes another endpoint on the same function app called HttpFuncProgress, and I also am explicitly adding the Retry-After header with a value of 5 seconds. In theory this is optional, but I found when testing with the local runtime that I got a NullReferenceException inside the Durable Functions extension if I missed this out. It's possibly a bug, as from the look of the code, it's supposed to fall back to a default polling interval.

[FunctionName(nameof(BeginOperation))]
public static IActionResult BeginOperation(
    [HttpTrigger(AuthorizationLevel.Function, "post", "get", Route = null)] 
    HttpRequest req, ILogger log)
{
    // work out when this long-running operation will complete
    string duration = req.Query["duration"];
    if (!Int32.TryParse(duration, out int durationSeconds)) durationSeconds = 30;
    var expire = DateTime.UtcNow.AddSeconds(durationSeconds).ToString("o");

    // construct the Uri for polling
    var location = $"{req.Scheme}://{req.Host}/api/HttpFuncProgress?expire={HttpUtility.UrlEncode(expire)}";
    log.LogInformation($"Begun operation, due to end in {durationSeconds}s, poll at {location}");

    // optional hint for how long in seconds to wait before polling again
    req.HttpContext.Response.Headers.Add("Retry-After", "5");

    // return a 202 accepted
    return new AcceptedResult(location, "Begun operation");
}

The other function we need is the one that reports progress. For this demo I have simply included the operation expiry time in the polling Uri, so that it can work out whether the long-running operation has finished or not. Obviously a real implementation would likely check in a database of some sort to see whether the operation has completed.

If the operation hasn't completed we need to return the 202 response, and again we include the Location and Retry-After headers. If the operation has completed, we return a 200 OK with OkObjectResult which can include a payload indicating the output of the operation.

[FunctionName(nameof(HttpFuncProgress))]
public static IActionResult HttpFuncProgress(
    [HttpTrigger(AuthorizationLevel.Function, "get", Route = null)] 
    HttpRequest req, ILogger log)
{
    // determine how much longer this operation requires
    string expire = req.Query["expire"];
    if (!DateTime.TryParse(expire, out DateTime expireAt))
        return new BadRequestResult();

    // return a 200 if it's finished
    if (DateTime.UtcNow > expireAt)
        return new OkObjectResult($"finished! (it's after {expireAt})");

    // we need to provide the polling Uri again, which is simply the Uri
    // on which we were called
    var location = $"{req.Scheme}://{req.Host}{req.Path}{req.QueryString}";
    var remaining = (int) (expireAt - DateTime.UtcNow).TotalSeconds;
    log.LogInformation($"{remaining} seconds to go, poll at {location}");

    // return the 202 to indicate that the caller should keep polling
    var res = new AcceptedResult(location, $"{remaining} seconds to go");
    req.HttpContext.Response.Headers.Add("Retry-After", "5");
    return res;
}

As you can see, it's relatively straightforward to implement an API that supports the polling pattern, so this is a great way to keep your orchestrations very simple even while calling potentially long-running operations.

Another nice feature of the Durable HTTP API is that you can use the Managed Identity of your Function App to automatically retrieve a bearer token to use in the requests. There's a nice example of that in this sample that calls an ARM endpoint to restart a VM in Azure.

Want to learn more about how easy it is to get up and running with Durable Functions? Be sure to check out my Pluralsight course Azure Durable Functions Fundamentals.