How to chain Azure Functions

And leverage Durable Functions to reduce the burden when managing long-living serverless workloads

Ricardo Mendes
CI&T

--

Photo by Larisa Birta on Unsplash

Microservices architectures have been widely used by enterprise organizations to split their applications into smaller parts. Experience shows that, along with benefits, microservices also bring challenges for software development teams. Integrating related parts is among the most common issues and there’s no silver bullet to tackle the problem. Microservices Choreography vs Orchestration: The Benefits of Choreography brings reasonable thoughts on this matter.

Detached events and functions (choreography) fit concise workloads quite well but may bring unwanted complexity to the overall solution when it comes to workflow-like processing. This is why I believe there’s either room for orchestration. More than this, I believe we can fine-tune microservice architectures by finding a balance between both flavors.

The present blog post addresses technical facets of microservices orchestration under the Microsoft Azure umbrella: Azure Functions are event-driven computing resources that allow applications to scale on demand; Durable Functions is an extension that brings built-in support for function chaining and configuration as code. Best practices for API development are covered as well.

Workflow-like data processing

I’ve recently faced a microservices orchestration challenge when working on a data pipeline comprising multiple transformations and few storage buckets between them.

In this context, I’m calling workloads the sequential data transformations that happen before data is persisted into a given storage bucket. Let me use a diagram to illustrate what I mean:

Sample workload powered by Azure Functions
Image 1. Sample workload powered by Azure Functions

In summary,

  • workloads are atomic and the functions must run in a specific order — e.g., 1, 2, and 7 — because they rely on data that is enriched by their predecessors;
  • there are optional execution paths, represented by the dashed arrows;
  • processed data is persisted only at the end of each workload;
  • running a sequence of transformations may take a while, causing the triggering HTTP request (pink arrow at left) to timeout and the client to lose relevant execution outcome.

The pipeline itself comprises a handful of workloads that communicate to each other through events (pink arrow at right). This is what I mean by finding a balance: workloads leverage orchestration while the pipeline leverages choreography.

Although Azure Functions support microservice architectures and provide horizontal scalability, orchestrating them in such a chain may result in a complex and error-prone process. Developers might need to handle events, business logic, and errors for every single function. Moreover, the deployment strategy may become trickier depending on the number of Function Apps required to fulfill the requirements.

Durable Functions

There is also good news, though: Durable Functions, an extension of Azure Functions, let us seamlessly run workflows in a serverless compute environment.

Behind the scenes, the extension manages state, checkpoints, and restarts for you, allowing you to focus on your business logic.

— Durable Functions docs

Image 2 presents the same workload from Image 1, using Durable Functions instead of standard Azure Functions. It may look cumbersome at first glance but, with regard to coding and deployment, it’s actually not.

Sample workload powered by Azure Durable Functions
Image 2. Sample workload powered by Azure Durable Functions

Direct benefits of using Durable Functions are listed below:

  1. the extension handles low-level function-to-function communications for you, so the code you write becomes simpler;
  2. notice the 2-ways arrows connecting the Orchestrator to other functions — they mean those functions (called Activities in the scope of Durable Functions) can return values to the Orchestrator. Such values can be assigned to variables and then passed as parameters for the following functions or used for decision making on what to run next;
  3. the Orchestrator does workload coordination, which means Activity functions have only their specific business logic, making it easier to comply with the SOLID / Single Responsibility Principle;
  4. workload-level error handling, e.g., rollbacking changes, might be done in a single place: the Orchestrator code;
  5. the Durable Functions extension natively addresses the problem of coordinating the state of long-running operations with external clients;
  6. all related functions are deployed together within a single Function App.

In the coming sections, I discuss implementation details that hopefully will help you thrive when working with the Function chaining and Async HTTP APIs application patterns. I’m using Python in the examples, but the concepts are agnostic enough to be applied to any language supported by Durable Functions.

The sample Funds Transfer App

I’m going to use a dummy Funds Transfer App intended to help you follow me from now on. Comprising 4 business steps, each implemented as an Azure Function, the sample app is simpler than the above examples but covers all the relevant matters mentioned so far. The business steps are: get the funds transfer data, validate input, validate internal accounts, and handle funds transfer.

The Azure Functions Core Tools can be used to create the functions in your development environment:

Snippet 1. Commands to create the Orchestrator, Activities, and HTTP starter functions

You will see that the above commands created 6 functions from 3 different templates (--template argument):

  • Orchestrator (1 function): responsible for coordinating the activity functions;
  • Activities (4 functions): where actions such as making a database call or performing a computation happen;
  • HTTP starter (1 function): a trigger that starts the orchestration and returns an HTTP response containing URIs that the client can use to monitor and manage the new orchestration.

The next diagram shows the interactions among them.

Image 3. Azure Functions that compose the sample Funds Transfer App

For now, bear in mind get-funds-transfer-data returns a value to the orchestrator. Additionally, get-funds-transfer-data, validate-input, and handle-funds-transfer are mandatory and must run in this order, while validate-internal-account is optional can execute between validate-input and handle-funds-transfer under certain conditions.

Function implementation details

This section describes the template-based generated code and the main changes applied to it to have a working version of the sample application. The source code is available on GitHub.

start-from-http

Snippet 2. Azure Durable Function passing the HTTP request body to the Orchestrator

The main goals of this function are to trigger orchestrations when receiving HTTP requests and return URIs used by external clients to manage the long-running operations.

DurableOrchestrationClient.start_new() is responsible for triggering the orchestration. Its first argument, orchestration_function_name, is taken from the request URI through the functionName route param (line 4). Having the Orchestrator name as a route param enables a single HTTP starter function to trigger distinct orchestration processes in the case of real apps that have more than one orchestrator in place. We will see more on route params in the A user-friendly API/Custom URI section below.

The code is pretty much the same as the Durable Functions HTTP starter template except for line 5. Please note req.get_json(), the last parameter of the client.start_new method call; I’m using it to pass the request body to the Orchestrator — more on this in the transfer-funds function below.

I’ve also changed its configuration file (function.json) a little bit. One of the changes is in the bindings.methods value, now accepting only POST requests.

get-funds-transfer-data

Snippet 3. Azure Durable Function returning a value to the Orchestrator

Here you can see that an Activity behaves like a regular function (not an Azure Function) when returning values to the Orchestrator — i.e., you don’t need to care about returning an HTTP response enclosing the result. Please keep in mind Activities always need to return a value; in other words, they cannot return null.

The binding.name configuration was changed to body in function.json to match the function argument’s name (I did similar changes for other functions as well).

validate-input

Snippet 4. Azure Durable Function raising exceptions to be handled by the Orchestrator

This Activity is intended to demonstrate exception handling with Durable Functions. Exceptions raised by the Activities are handled by the Orchestrator in the sample app, as you can see in the A user-friendly API/Succinct error messages section below.

validate-internal-account

I didn’t add any extra logic to this Activity code. It’s used only to show how the Orchestrator can programmatically decide on activating optional execution paths (more details in the transfer-funds function below). Theoretically, if the source and target accounts belong to the same bank, we could perform extra validations, such as checking whether the provided accounts are valid and active.

handle-funds-transfer

In the final step of the worload, money should be moved from the source to the target account if all previous validations succeed. The sample code consists of a simple sleep statement faking the time required to perform such a transaction.

transfer-funds

And finally, the Orchestrator:

Snippet 5. Chaining Azure Functions with a Durable Functions Orchestrator

There is important stuff to be highlighted in the above piece of code:

  1. the function receives a context object as a parameter, which, as its name says, is used to access the overall orchestration context;
  2. line 3 shows how to get the body of the HTTP request that triggered the orchestration: context.getInput() it was set in the start-from-http function described above;
  3. lines 5, 8, 12, and 15 show how to call Activity functions, providing them the appropriate parameters;
  4. line 11 shows how to programmatically add a new function to the chain based on a given condition;
  5. lines 2, 6, 9, 13, 16, and 18 bring a suggestion on how to build a successful execution output through simple status strings that let it clear for users all the steps that have been performed — more on this in the A user-friendly API/Successful execution output section below.

Well, with the foundational code in place, it’s time to go further and discuss how Durable Functions fit user-friendly APIs development.

A user-friendly API

Even the most powerful frameworks require us, developers, common sense to improve user-friendliness on top of them. This section discusses features and small adjustments that turn a Durable Functions-based application easier to use from the client's perspective.

Custom URI

When we generate an HTTP starter function through the Durable Functions HTTP starter template, its standard URI is {SCHEME}://{HOST}:{PORT}/api/orchestrators/{functionName}. This URI can be modified to hide implementation details.

As an example, I’ve changed bindings.route from orchestrators/{functionName} to simply {functionName} in start-from-http/function.json. It shrinks the URI, and users can trigger the sample application by posting a request to {SCHEME}://{HOST}:{PORT}/api/transfer-funds.

Asynchronous endpoints

Please refer back to the start-from-http code (Snippet 1) and notice its last line: return client.create_check_status_response(req, instance_id). This helper method, provided by the Durable Functions extension, creates an HTTP response that brings orchestration-related information for clients, allowing them to manage the new orchestration through further requests to the API.

That said, start-from-http will immediately return a 202 Accepted response if DurableOrchestrationClient.start_new() can start an orchestration. The response body is something like:

{
"id": "__REDACTED__",
"statusQueryGetUri": "{HOST}/runtime/webhooks/.../{id}/...",
"sendEventPostUri": "{HOST}/runtime/.../{id}/raiseEvent/...",
"terminatePostUri": "{HOST}/runtime/.../{id}/terminate/...",
"rewindPostUri": "{HOST}/runtime/.../{id}/rewind/...",
"purgeHistoryDeleteUri": "{HOST}/runtime/webhooks/.../{id}/..."
}

It contains, for example, the statusQueryGetUri, which allows the client to poll the orchestrator from time to time to get the results of a long-running operation. This behavior unblocks the clients and prevents them from facing timeouts when waiting for the tasks to complete.

Successful execution output

The statusQueryGetUri will return 202 Accepted while the tasks have not been completed. You can check additional details in the runtimeStatus field of the response body, which values comprise Pending, Running, Completed, and Failed (more on failures in the next section).

On successful execution, the status changes to 200 OK, and we can share details with the client through the output response field. In the sample app, I added status strings that result in the following output:

{
"name": "transfer-funds",
"instanceId": "__REDACTED__",
"runtimeStatus": "Completed",
"input": "__REDACTED__",
"customStatus": null,
"output": [
"Get funds transfer data: DONE",
"Validate input: DONE",
"Handle funds transfer: DONE"
],

"createdTime": "2021-04-18T09:39:02Z",
"lastUpdatedTime": "2021-04-18T09:39:15Z"
}

The Durable Functions extension automatically sets the output array of the response body with the result of the Orchestrator function.

Succinct error messages

The status changes to 500 Internal Server Error when something goes wrong. In this case, the extension presents the failed Orchestrator and Activity function names, the exception type and message, and a stack trace in the output response field — everything formatted as a single string that may require valuable time from the client to understand what didn’t work.

My suggestion to improve this behavior is to handle Activity exceptions at the Orchestrator level, parsing error messages to extract the meaningful parts from a client perspective. Then, fulfill the orchestration context with such info and throw a new exception. The below snippet shows how to do it. Please pay special attention to line 24, where the context’s customStatus property is set.

Snippet 6. Handling exceptions at the Durable Functions Orchestrator level

This code results in the following response body:

{
"name": "transfer-funds",
"instanceId": "__REDACTED__",
"runtimeStatus": "Failed",
"input": "__REDACTED__",
"customStatus": "The funds transfer data is mandatory",
"output": "Orchestrator function 'transfer-funds' failed: ['Get funds transfer data: DONE', \"'validate-input' raised Exception >> The funds transfer data is mandatory'\"]",
"createdTime": "2021-04-18T10:23:57Z",
"lastUpdatedTime": "2021-04-18T10:24:04Z"
}

The customStatus field is used to present the original exception message, which should be the most important information for the client. Furthermore, the output field presents some orchestration context, which might be useful for first-level support.

In closing

Thanks for reading so far! Now it’s time to validate the work described in the blog post.

You can publish the function (func azure functionapp publish <YOUR-FUNCTION-NAME>) and use Postman or equivalent tool to make requests. The destination URI will be displayed at the end of the publishing logs — replace {functionName} with transfer-funds and you will see it in action.

Please use the below content as a reference request body:

{
"metadata": {
"requestId": "6d79dbfb",
"requestType": "fundsTransfer"
},
"data": {
"sourceAccount": {
"id": "123456",
"bankId": "001"
},
"targetAccount": {
"id": "456789",
"bankId": "002"
}
}
}

It will result in successful execution. Applying slight changes to it will activate the validation errors and optional execution path:

  • remove data, data.sourceAccount, or data.targerAccount to see the validation errors;
  • change data.targetAccount.bankId to 001 to include the validate-internal-account step.

That’s it, and I hope it helps! :)

--

--