Deprecated Actions II: Seamlessly using durable functions with the Power Platform

In the last blog we created a durable function and custom connector and used them in a Power Automate Cloud Flow. In this blog, we’re going to update the Azure function to simplify its use within Power Automate - allowing us to use Power Automate’s normal output and error handling.

Scenario

Previously we created a durable function that outputs a list of connectors and the actions within each connector. We had to manually check the runtimeStatus and format the output using a ‘Condition’ and ‘Parse JSON’ step in our cloud flow. Furthermore internal URLs for the azure function were leaked. Let’s fix that by updating the Azure Function and custom connector to improve the experience when using within Power Automate, Logic Apps or Canvas Apps.

The missing piece of the jigsaw

After a long time searching the internet, the key nugget of information was held in the forum post Signalling a failure condition to Power Automate. The durable functions extention employs the Async Request/Reply Pattern, so we must return a status code 202 ‘Accepted’ with ‘Location’ and ‘Retry-After’ headers.

The above post explains that Power Automate expects a custom status URL to be returned in the 202 ‘Accepted’ initial response from the durable function. Hence, we cannot use the commonly documented response that we previously used. e.g. return starter.CreateCheckStatusResponse(req, instanceId);.

Altering the ‘Accepted Response’

The first step is to replace the starter.CreateCheckStatusResponse in the orchestrator function, and return a custom response built with a new internal method BuildAcceptedResponse. This method creates an AcceptResult where the location points to a new ScrapeConnectorStatus function (see further below). It has a parameter of the unique instanceId of this durable function invocation. No body is necessary.

public static async Task<IActionResult> ScrapeConnectors(
  [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req,
  [DurableClient] IDurableOrchestrationClient starter,
  ILogger log)
{
  string requestBody = new StreamReader(req.Body).ReadToEnd();
  var request = JsonConvert.DeserializeObject<ScrapeConnectorsRequest>(requestBody);

  log.LogInformation($"{nameof(ScrapeConnectors)} triggered at {DateTime.UtcNow:O}. " +
                     $"SelectedConnectors=${String.Join(",", request.SelectedConnectors)}");

  // Start the orchestrator with this request
  string instanceId = await starter.StartNewAsync("ScrapeConnectorsOrchestration", request);

  log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

  // Return a custom '202 Accepted' response
  return BuildAcceptedResponse(req, instanceId);
}

private static IActionResult BuildAcceptedResponse(HttpRequest req, string instanceId)
{
  // Inform the client how long to wait in seconds before checking the status
  req.HttpContext.Response.Headers.Add("retry-after", "20");

  // Inform the client where to check again
  var location = string.Format("{0}://{1}/api/ScrapeConnectorsStatus/instance/{2}", req.Scheme, req.Host, instanceId);
  return new AcceptedResult(location, null);
}

Adding a custom status function

We must now add our custom status function ScrapeConnectorStatus. This function gets the status of the durable function instance and returns one of the following status codes:

Instance status Return Status Comments
Pending 202 (Accepted) Informs the client to try again in 20 seconds
Complete 200 (OK) Returns the ‘output’ property to the client, which contains the successful output
Failed 400 (BadRequest) There was an error whilst running the durable function. Returns the error message in the ‘output’ property to the client
n/a 404 (NotFound) The provided instanceId could not be found
/// Http Triggered Function which acts as a wrapper to get the status of a running Durable orchestration instance.
/// We're using Anonymous Authorisation Level for demonstration purposes. You should use a more secure approach.
[FunctionName(nameof(ScrapeConnectorsStatus))]
public static async Task<IActionResult> ScrapeConnectorsStatus(
  [HttpTrigger(AuthorizationLevel.Anonymous, methods: "get", Route = "ScrapeConnectorsStatus/instance/{instanceId}")] HttpRequest req,
  [DurableClient] IDurableOrchestrationClient orchestrationClient,
  string instanceId)
{
  // Get the built-in status of the orchestration instance. This status is managed by the Durable Functions Extension.
  var status = await orchestrationClient.GetStatusAsync(instanceId);
  if (status != null)
  {
    if (status.RuntimeStatus == OrchestrationRuntimeStatus.Running || status.RuntimeStatus == OrchestrationRuntimeStatus.Pending)
    {
      return BuildAcceptedResponse(req, instanceId);
    }
    else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
    {
      return new OkObjectResult(status.Output);
    }
    else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
    {
      return new BadRequestObjectResult(status.Output);
    }
    throw new Exception($"Unexpected RuntimeStatus: {status.RuntimeStatus}");
  }

  // If status is null, then instance has not been found. Create and return an Http Response with status NotFound (404).
  return new NotFoundObjectResult($"InstanceId {instanceId} is not found.");
}

Thanks to this excellent blog for pointers: Example of a custom status response.

Testing locally

If we test our updated function locally we can see it has a much simpler output. It now contains just the ‘Location’ and ‘Retry-After’ headers. The location points to our new ScrapeConnectorStatus function.

Testing locally

Successful result

If we perform a GET on the status URL after the durable function has finished successfully processing, a 200 (OK) status code will be returned with only the ‘output’ property as the content.

Local Success

Unsuccessful result

We’re now able to handle the situation where a connector isn’t found because our status function can return a 400 ‘BadRequest’ error.

Let’s test this by adding a small amount of error handling into the main orchestrator function. We’ll raise an exception, and thus output an error message if a requested connector cannot be found.

if (request.SelectedConnectors != null)
{
  // Filter connectors found to just the selectedConnectors required
  connectors = connectors.Where(c => request.SelectedConnectors.Contains(c.UniqueName)).ToList();

  // Throw an exception if not all connectors are found, we could be more helpful with the error message here!!
  if (connectors.Count < request.SelectedConnectors.Count())
  {
    throw new Exception("Not all connectors found");
  }
}

Testing locally with a bogus connector, we can see that a 202 ‘Accepted’ is still returned, but after the durable function has completed it’s processing the output is now a 404 ‘BadRequest’.

Local failure

Custom Connector Update

Critically, we must update our custom connector so that it knows about the new ScrapeConnectorStatus function. We do this by adding a new action to the connector as shown below:

Update Connector

Testing in Power Automate (success)

Now we can simplify the cloud flow (created in the previous blog) so that we just use the one action in our custom connector. We can see that upon successful completion of the durable function we have a nicely formed output. We don’t have to check the durable function runtimeStatus or access the output property, resulting in a simpler, more efficient cloud flow.

Cloud Flow Success

Testing in Power Automate (failure)

Additionally, because we’re returning an 404 ‘BadRequest’ upon failure of the durable function, Power Automate can detect this and we can use all the normal behaviours to detect if the action has failed, as shown below:

Clound Flow Failure

Summary

  • We’ve improved our Azure function to have a dedicated status message that Power Automate Cloud now understands allowing us to
    • Detect if the durable function completes successfully or fails.
    • Output a clean output to Power Automate.

In the next blog post we’ll make creating the custom connector simpler by automatically generating an OpenAPI definition.

Source Code

You can get the source code and solutions used on my github in the blog2 branch. Import the ‘DeprecatedConnectors’ solution before the ‘DeprecatedConnectorsFlow’ solution due to a known limitation that custom connectors must be installed first.

References