Getting a result from a Durable Function Orchestrator using Isolated Worker / DurableTaskClient
Durable Functions are designed to be fire-and-forget. You start an orchestration, you get an instance ID back, and the client polls a status endpoint until it's done. That's the pattern the tooling nudges you towards, and for long-running work it's the right one.
Sometimes you want the answer there and then.
I recently had a case where an HTTP trigger needed to kick off an orchestration and return the result to the caller in the same request. The orchestration was quick, a handful of activity calls fanning out and back, so making the caller poll felt like overkill.
You can do this with the isolated worker model and DurableTaskClient.
The code
public class GetOrderHttpTrigger
{
[Function("GetOrder")]
public async Task<HttpResponseData> RunAsync
(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "order/{orderId}")]
HttpRequestData req,
string orderId,
[DurableClient] DurableTaskClient durableTaskClient
)
{
var instanceId = await durableTaskClient
.ScheduleNewOrchestrationInstanceAsync("GetOrderOrchestrator", orderId);
// the `true` is important here
// it sets getInputsAndOutputs, which fetches the
// orchestration instance's inputs and outputs
var orchestrationMetadata = await durableTaskClient
.WaitForInstanceCompletionAsync(instanceId, true);
var order = orchestrationMetadata.ReadOutputAs<Order>();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(order);
return response;
}
}
The bit that catches people out
WaitForInstanceCompletionAsync has an overload taking a getInputsAndOutputs boolean, and it defaults to false.
Leave it off and everything looks like it works. The orchestration runs, the method returns, you get an OrchestrationMetadata object back with a RuntimeStatus of Completed. Then ReadOutputAs<Order>() hands you null and you spend twenty minutes wondering why your orchestrator isn't returning anything.
It is returning something. You didn't ask for it.
The default makes sense on its own terms. Orchestration payloads live as serialised blobs, and fetching them on every status check would be wasteful when most callers only want to know whether the thing has finished. It bites when you're after the result.
GetInstanceAsync behaves the same way, if you're checking on an orchestration you started earlier.
Don't do this for long-running work
The obvious caveat: this holds the HTTP request open for the full duration of the orchestration.
Consumption plan functions have a hard request timeout of 230 seconds, imposed by the Azure Load Balancer sitting in front of them, and you can't change it from inside the function. If your orchestration takes longer, the caller gets a 502 while the orchestration carries on happily in the background.
WaitForInstanceCompletionAsync also takes a CancellationToken. Pass the one from HttpRequestData, so that when the caller disconnects you're not left holding a task nobody's waiting for:
var orchestrationMetadata = await durableTaskClient
.WaitForInstanceCompletionAsync(instanceId, true, req.FunctionContext.CancellationToken);
Cancelling the wait doesn't cancel the orchestration. It stops you waiting for it. To stop the orchestration too, call TerminateInstanceAsync.
When to use which
Rough rule I've settled on. If the orchestration finishes in a second or two and the caller needs the answer to do anything useful, wait for it as above. Anything longer, or anything where a retry costs you, return 202 Accepted with the status query URL and let the caller poll. CreateCheckStatusResponseAsync gives you that for free.
The docs push you towards polling, and most of the time you should take the push. But the synchronous version is supported, and if you've already got an API contract to honour, it saves you building a polling layer nobody asked for.