Dorian-B
Dorian-B

Reputation: 53

Use of HttpClient with a DelegatingHandler in Azure Durable Orchestrations

I have a durable orchestration for an ETL job that downloads data from an external API. The token for the external API is stored in an Azure Key Vault. The orchestration triggers several suborchestrations with many activity functions which need to add the auth token to the request headers.

What is the best practice for using an httpClient with a DelegatingHandler, if I want to avoid unnecessarily calling the Azure Key Vault to get the same token?

I am injecting an IHttpClientFactory into the orchestration class and then CreateClient in each activity function.

Should I just create one http client in the orchestrator function and pass it as a parameter to each activity function, thus implementing a delegating handler to append the auth header?

Here is sample orchestration code. In my implementation I have many activity functions and create an httpclient in each of them.

public class ETLOrchestration 
{
    private readonly IHttpClientFactory _factory;

    public ETLOrchestration(IHttpClientFactory factory)
    {
        _factory = factory;
    }

    [Function(nameof(ETLOrchestration))]
    public static async Task RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        ILogger logger = context.CreateReplaySafeLogger(nameof(ETLOrchestration));

        string token = await context.CallActivityAsync<string>(nameof(GetAccesToken));
        if (string.IsNullOrEmpty(token))
        {
            throw new Exception("No access token found for ETL in the KeyVault.");
        }

        logger.LogInformation("Access token retrieved from KeyVault.");
    
        // Should I be creating the http client here and passing it instead of the token?
        await context.CallActivityAsync(nameof(ETLActivity), token);
    }


    [Function("ETLOrchestration_HttpStart")]
    public static async Task<HttpResponseData> HttpStart(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req,
        [DurableClient] DurableTaskClient client,
        FunctionContext executionContext)
    {
        ILogger logger = executionContext.GetLogger("ETLOrchestration_HttpStart");
        string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(ETLOrchestration));
        return await client.CreateCheckStatusResponseAsync(req, instanceId);
    }

    [Function(nameof(GetAccesToken))]
    public async Task<string> GetAccesToken([ActivityTrigger] FunctionContext executionContext)
    {
        ILogger logger = executionContext.GetLogger(nameof(GetAccesToken));
        string accessToken = string.Empty;

        // Use the KeyVault to get the access token
        string kvUri = "https://key-vault";
        var kv_client = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());  

        try
        {
            Response<KeyVaultSecret> accessTokenTask = await kv_client.GetSecretAsync($"ETL-secret");
            return accessTokenTask.Value.Value;
        }
        catch (RequestFailedException)
        {
            logger.LogCritical("No access token found for ETL in the KeyVault.");
            return string.Empty;
        }                        
    }

    [Function(nameof(ETLActivity))]
    public async Task ETLActivity([ActivityTrigger] string accessToken, FunctionContext executionContext)
    {
        ILogger logger = executionContext.GetLogger(nameof(ETLActivity));
        HttpClient http_client = _factory.CreateClient("SomeAPIClient");
        http_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        var httpResponse = await http_client.GetAsync($"some-api/data");
        //...       
    }
}

And in Program.cs I have the named http client:

services.AddHttpClient("SomeAPIClient", http_client =>
{
http_client.BaseAddress = new Uri(Environment.GetEnvironmentVariable("BaseUrlSomeAPI");
});

Upvotes: 0

Views: 96

Answers (1)

Pravallika KV
Pravallika KV

Reputation: 8694

Use below code to implement HttpClient with DelegatingHandler in Durable Azure functions.

AuthTokenDelegatingHandler.cs:

public class AuthTokenDelegatingHandler : DelegatingHandler
{
    private readonly IKeyVaultService _keyVaultService;
    private string _cachedToken;

    public AuthTokenDelegatingHandler(IKeyVaultService keyVaultService)
    {
        _keyVaultService = keyVaultService;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(_cachedToken))
        {
            _cachedToken = await _keyVaultService.GetAccessTokenAsync();
        }

        request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _cachedToken);
        return await base.SendAsync(request, cancellationToken);
    }
}

IKeyVaultService.cs:

public interface IKeyVaultService
{
    Task<string> GetAccessTokenAsync();
}

public class KeyVaultService : IKeyVaultService
{
    private readonly SecretClient _kvClient;

    public KeyVaultService(string kvUri)
    {
        _kvClient = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
    }

    public async Task<string> GetAccessTokenAsync()
    {
        try
        {
            var secret = await _kvClient.GetSecretAsync("ETL-secret");
            return secret.Value.Value;
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException("Failed to retrieve access token from Key Vault.", ex);
        }
    }
}

Program.cs:

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IKeyVaultService>(new KeyVaultService("https://<Keyvault_Name>.vault.azure.net/"));       
        services.AddTransient<AuthTokenDelegatingHandler>();
        services.AddHttpClient("APIClient")
        .AddHttpMessageHandler<AuthTokenDelegatingHandler>();
        
    services.AddSingleton<Function1>();
    }).Build();

host.Run();

Function.cs:

public class Function1
{
    private readonly IHttpClientFactory _httpClientFactory;

    public Function1(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    [Function(nameof(Function1))]
    public async Task<List<string>> RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        ILogger logger = context.CreateReplaySafeLogger(nameof(Function1));
        logger.LogInformation("Saying hello.");
        var outputs = new List<string>();
        HttpClient httpClient = _httpClientFactory.CreateClient("SomeAPIClient");
        outputs.Add(await context.CallActivityAsync<string>(nameof(SayHello), new SayHelloInput { Name = "Tokyo" }));
        return outputs;
    }

    [Function(nameof(SayHello))]
    public static async Task<string> SayHello([ActivityTrigger] SayHelloInput input, FunctionContext executionContext)
    {
        string name = input.Name;
        var factory = executionContext.InstanceServices.GetService<IHttpClientFactory>();
        HttpClient httpClient = factory.CreateClient("SomeAPIClient");

        ILogger logger = executionContext.GetLogger("SayHello");
        logger.LogInformation("Saying hello to {name}.", name);

        var response = await httpClient.GetAsync("https://www.google.com/");
        return $"Hello {name}! Response: {response.StatusCode}";
    }

    [Function("Function1_HttpStart")]
    public static async Task<HttpResponseData> HttpStart(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req,
        [DurableClient] DurableTaskClient client,
        FunctionContext executionContext)
    {
        ILogger logger = executionContext.GetLogger("Function1_HttpStart");
        string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(Function1));
        logger.LogInformation("Started orchestration with ID = '{instanceId}'.", instanceId);
        return await client.CreateCheckStatusResponseAsync(req, instanceId);
    }

    public class SayHelloInput
    {
        public string Name { get; set; }
    }
}

Console Output:

Functions:

Function1_HttpStart: [GET,POST] http://localhost:7022/api/Function1_HttpStart

Function1: orchestrationTrigger

SayHello: activityTrigger

For detailed output, run func with --verbose flag.  
[2025-01-08T13:45:55.409Z] Host lock lease acquired by instance ID '0000000000000000000000000D2022A4'.  
[2025-01-08T13:45:56.341Z] Executing 'Functions.Function1_HttpStart' (Reason='This function was programmatically called via the host APIs.', Id=f182be81-0086-4ae9-b346-023889621020)  
[2025-01-08T13:45:56.798Z] Scheduling new Function1 orchestration with instance ID 'e3a84bb6667f4ae499690d68a86b34d4' and 0 bytes of input data.  
[2025-01-08T13:45:57.063Z] Started orchestration with ID = 'e3a84bb6667f4ae499690d68a86b34d4'.  
[2025-01-08T13:45:57.160Z] Executed 'Functions.Function1_HttpStart' (Succeeded, Id=f182be81-0086-4ae9-b346-023889621020, Duration=844ms)  
[2025-01-08T13:45:57.307Z] Executing 'Functions.Function1' (Reason='(null)', Id=fdae683a-aa44-4a8c-ac58-af50abdb34f9)  
[2025-01-08T13:45:57.484Z] Saying hello.  
[2025-01-08T13:45:57.538Z] Executed 'Functions.Function1' (Succeeded, Id=fdae683a-aa44-4a8c-ac58-af50abdb34f9, Duration=253ms)  
[2025-01-08T13:45:57.679Z] Executing 'Functions.SayHello' (Reason='(null)', Id=9ee48e97-cea3-454c-89e9-95b096efac9d)  
[2025-01-08T13:45:57.698Z] Saying hello to Tokyo.  
[2025-01-08T13:45:57.710Z] Start processing HTTP request GET [https://www.google.com/](https://www.google.com/ "https://www.google.com/")  
[2025-01-08T13:46:01.063Z] Sending HTTP request GET [https://www.google.com/](https://www.google.com/ "https://www.google.com/")  
[2025-01-08T13:46:01.614Z] Received HTTP response headers after 542.4069ms - 200  
[2025-01-08T13:46:01.617Z] End processing HTTP request after 3915.3171ms - 200  
[2025-01-08T13:46:01.845Z] Executed 'Functions.SayHello' (Succeeded, Id=9ee48e97-cea3-454c-89e9-95b096efac9d, Duration=4191ms)  
[2025-01-08T13:46:01.917Z] Executing 'Functions.Function1' (Reason='(null)', Id=dd783c6f-1470-4574-9d53-f433fe19a66d)  
[2025-01-08T13:46:01.939Z] Executed 'Functions.Function1' (Succeeded, Id=dd783c6f-1470-4574-9d53-f433fe19a66d, Duration=23ms)

Response:

enter image description here

Upvotes: 1

Related Questions