Azure Functions で HttpClient のリトライ処理を試してみた

HTTP のリターンコード 429 TooManyRequests が発生する場合、メソッドを再起的に呼び出し自分でリトライをコントロールしていました。本職はプログラマではないので、日常的にプログラミングコードを書いておらず、コードを書く必要に迫られたら生成 AI のお世話になっております。その生成 AI に TooManyRequests のリトライ処理を聞いてみたところ、メソッドを再起的に呼び出すのではなく、もっと簡単な方法を教えてくれました。そこでサンプルコードを書きながら、Azure Functions で HttpClient のリトライ処理を試してみました。

## 検証用 Azure Functions を作成

```bash:bash
appname=mnrazcost

func init $appname --dotnet

cd $appname

func new --name http --template 'Http Trigger'

func start
```

## Azure Functions から Azure REST API を使用するためのサービスプリンシパルを作成

```bash:bash
az ad sp create-for-rbac \
  --display-name $appname \
  --role Contributor \
  --scopes /subscriptions/$(az account show --query id --output tsv) \
  --years 100

{
  "appId": "xxxxxxxx-0b44-413e-bf51-ea8c4657e66d",
  "displayName": "mnrazcost",
  "password": "xxxxxxxxq_7KE1wAYX.lHYa8d8-ZHlR9nZ1iibhI",
  "tenant": "xxxxxxxx-7c45-4904-b075-9bf13f4dceba"
}
```

## 環境変数にサービスプリンシパル情報をセット

```bash:bash
export MNR_AZCOST_ID=xxxxxxxx-0b44-413e-bf51-ea8c4657e66d
export MNR_AZCOST_PW=xxxxxxxxq_7KE1wAYX.lHYa8d8-ZHlR9nZ1iibhI
export MNR_AZCOST_TN=xxxxxxxx-7c45-4904-b075-9bf13f4dceba
export MNR_AZCOST_SB=$(az account show --query id --output tsv)
```

## http.cs にコードを追加し TooManyRequests が発生する状況を作る

```cs:http.cs
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json.Linq;
using System.Threading;
using System.Text;
using System.Collections.Generic;

namespace mnrazcost
{
    public class http
    {
        [FunctionName("http")]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            await GetAzureToken(log);

            return new OkObjectResult("Done");
        }

        private async Task GetAzureToken(ILogger log)
        {
            string MNR_AZCOST_ID = Environment.GetEnvironmentVariable("MNR_AZCOST_ID");
            string MNR_AZCOST_PW = Environment.GetEnvironmentVariable("MNR_AZCOST_PW");
            string MNR_AZCOST_TN = Environment.GetEnvironmentVariable("MNR_AZCOST_TN");
            string MNR_AZCOST_SB = Environment.GetEnvironmentVariable("MNR_AZCOST_SB");

            HttpClient httpClient = new HttpClient();
            string url = $"https://login.microsoftonline.com/{MNR_AZCOST_TN}/oauth2/v2.0/token";
            string postData = $"grant_type=client_credentials"
                + $"&scope=https://management.azure.com/.default"
                + $"&client_id={MNR_AZCOST_ID}"
                + $"&client_secret={MNR_AZCOST_PW}";
            StringContent content = new StringContent(postData, Encoding.UTF8,
                "application/x-www-form-urlencoded");
            var response = await httpClient.PostAsync(url, content);
            if (response.StatusCode == HttpStatusCode.OK)
            {
                JObject token = JObject.Parse(await response.Content.ReadAsStringAsync());
                httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + token.GetValue("access_token"));
                List<Task> tasks = new List<Task>();
                for (int i = 0; i < 20; i++)
                {
                    tasks.Add(GetAzureCost(httpClient, log, MNR_AZCOST_SB));
                }
                await Task.WhenAll(tasks);
            }
            else
            {
                log.LogInformation($"response.StatusCode: {response.StatusCode}");
            }
        }

        private async Task GetAzureCost(HttpClient httpClient, ILogger log, string subscriptionId)
        {
            string url = $"https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version=2023-03-01";
            string postData = @"{
                ""type"": ""Usage"",
                ""timeframe"": ""MonthToDate"",
                ""dataset"": {
                    ""granularity"": ""None"",
                    ""aggregation"": {
                        ""totalCost"": {
                            ""name"": ""PreTaxCost"",
                            ""function"": ""Sum""
                        }
                    }
                }
            }";
            StringContent content = new StringContent(postData, Encoding.UTF8,
                "application/json");
            var response = await httpClient.PostAsync(url, content);
            if (response.StatusCode == HttpStatusCode.OK)
            {
                JObject data = JObject.Parse(await response.Content.ReadAsStringAsync());
                var PreTaxCost = data["properties"]["rows"][0][0];
                var Currency = data["properties"]["rows"][0][1];
                log.LogInformation($"{Currency} {PreTaxCost}");
            }
            else
            {
                log.LogInformation($"response.StatusCode: {response.StatusCode}");
            }
        }
    }
}
```

## TooManyRequests が発生した状況

```bash:bash
[2023-09-22T23:59:48.419Z] C# HTTP trigger function processed a request.
[2023-09-22T23:59:49.612Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.734Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.751Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.755Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.782Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.800Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.867Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.872Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.875Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.877Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:49.927Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.225Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.225Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.225Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.225Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.225Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.226Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.234Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.283Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.414Z] response.StatusCode: TooManyRequests
[2023-09-22T23:59:50.451Z] Executed 'http' (Succeeded, Id=06b69b53-b4ca-48dd-8bf7-671b4f5281dc, Duration=2023ms)
```

## Polly ライブラリを導入

```bash:bash
dotnet add package Polly
dotnet add package Polly.Extensions.Http
```

## HttpClient のリトライ処理を追加した http.cs

数行程度のコード追加でリトライ処理が実現できるようです。

```cs:http.cs
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json.Linq;
using System.Threading;
using System.Text;
using System.Collections.Generic;
using Polly;
using Polly.Extensions.Http;

namespace mnrazcost
{
    public class http
    {
        [FunctionName("http")]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            await GetAzureToken(log);

            return new OkObjectResult("Done");
        }

        private async Task GetAzureToken(ILogger log)
        {
            string MNR_AZCOST_ID = Environment.GetEnvironmentVariable("MNR_AZCOST_ID");
            string MNR_AZCOST_PW = Environment.GetEnvironmentVariable("MNR_AZCOST_PW");
            string MNR_AZCOST_TN = Environment.GetEnvironmentVariable("MNR_AZCOST_TN");
            string MNR_AZCOST_SB = Environment.GetEnvironmentVariable("MNR_AZCOST_SB");

            HttpClient httpClient = new HttpClient();
            string url = $"https://login.microsoftonline.com/{MNR_AZCOST_TN}/oauth2/v2.0/token";
            string postData = $"grant_type=client_credentials"
                + $"&scope=https://management.azure.com/.default"
                + $"&client_id={MNR_AZCOST_ID}"
                + $"&client_secret={MNR_AZCOST_PW}";
            StringContent content = new StringContent(postData, Encoding.UTF8,
                "application/x-www-form-urlencoded");
            var response = await httpClient.PostAsync(url, content);
            if (response.StatusCode == HttpStatusCode.OK)
            {
                JObject token = JObject.Parse(await response.Content.ReadAsStringAsync());
                httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + token.GetValue("access_token"));
                List<Task> tasks = new List<Task>();
                for (int i = 0; i < 20; i++)
                {
                    tasks.Add(GetAzureCost(httpClient, log, MNR_AZCOST_SB));
                }
                await Task.WhenAll(tasks);
            }
            else
            {
                log.LogInformation($"response.StatusCode: {response.StatusCode}");
            }
        }

        private async Task GetAzureCost(HttpClient httpClient, ILogger log, string subscriptionId)
        {
            var retryPolicy = Policy.HandleResult<HttpResponseMessage>(response =>
            {
                return response.StatusCode == HttpStatusCode.TooManyRequests;
            }).WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));

            string url = $"https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version=2023-03-01";
            string postData = @"{
                ""type"": ""Usage"",
                ""timeframe"": ""MonthToDate"",
                ""dataset"": {
                    ""granularity"": ""None"",
                    ""aggregation"": {
                        ""totalCost"": {
                            ""name"": ""PreTaxCost"",
                            ""function"": ""Sum""
                        }
                    }
                }
            }";
            StringContent content = new StringContent(postData, Encoding.UTF8,
                "application/json");
            // var response = await httpClient.PostAsync(url, content);
            var response = await retryPolicy.ExecuteAsync(() =>
            {
                return httpClient.PostAsync(url, content);
            });
            if (response.StatusCode == HttpStatusCode.OK)
            {
                JObject data = JObject.Parse(await response.Content.ReadAsStringAsync());
                var PreTaxCost = data["properties"]["rows"][0][0];
                var Currency = data["properties"]["rows"][0][1];
                log.LogInformation($"{Currency} {PreTaxCost}");
            }
            else
            {
                log.LogInformation($"response.StatusCode: {response.StatusCode}");
            }
        }
    }
}
```

## リトライ処理の結果

Azure REST API の Microsoft.CostManagement/query が頻繁に TooManyRequests を出すのでサンプルで使用しましたが、多少の効果はありそうです。また、リトライ回数を増やすと処理全体の時間がかかるので、確実にリトライはしているものと思われます。もっとほど良い TooManyRequests の再現環境があると良さそうです。

```bash:bash
[2023-09-23T00:04:12.850Z] C# HTTP trigger function processed a request.
[2023-09-23T00:04:15.646Z] JPY 5430.4168160589425
[2023-09-23T00:04:15.794Z] JPY 5430.4168160589425
[2023-09-23T00:04:15.805Z] JPY 5430.4168160589425
[2023-09-23T00:04:15.875Z] JPY 5430.4168160589425
[2023-09-23T00:04:29.859Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:29.943Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:29.956Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:30.287Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:30.287Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:30.637Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:30.682Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:30.701Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.047Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.134Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.160Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.250Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.409Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.532Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.926Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.926Z] response.StatusCode: TooManyRequests
[2023-09-23T00:04:31.960Z] Executed 'http' (Succeeded, Id=1d24fa94-b7d7-4322-ad32-a0e5dab6c86a, Duration=19102ms)
```

## 参考

https://qiita.com/mnrst/items/3ae383b8e5c29d99bd7a