Dynamics CRM API limits is a big topic. If you are not familiar what are the API limits for Dynamics 365, it is described here in a nutshell ( https://crmdm.blogspot.com/2020/11/dynamics-365-api-limits.html?m=0 )
This post aims to provide some tips for implementing the service protection API limits along with Dynamicis CRM Web API scenario.
In simple words, if your API requests exceeds the API limits mentioned below, your system would be throttled. API request returns a famous error called 'Too Many Requests' / 429 error.
"API limits enforced per web server within the 5 minute sliding window.
Measure | Description | Limit per web server |
---|---|---|
Number of requests | The cumulative number of requests made by the user. | 6000 |
Execution time | The combined execution time of all requests made by the user. | 20 minutes (1200 seconds) |
Number of concurrent requests | The number of concurrent requests made by the user | 52" |
( Ref:https://docs.microsoft.com/en-us/powerapps/developer/data-platform/api-limits#retry-operations)
Solution is mentioned by Microsoft in the above link. Basically the error response would have something like a header called 'Retry-After'. It would also have a retry after time in seconds.
I have some code snippets that I tried based on Microsoft documentation+ improved+unit tests.
As per Microsoft documentation, all requests go through HttpClient.SendAsync method. Here is a small diagram to understand this concept. One main advantage of this implementation is that Retry implementation can be done in one place.HttpClient.SendAsync() is a generic method. In other words, it's wrapping common operations with Http method.
For instance, a read operation is GetAsync. Eventhough HttpClient has a GetAsync method, it should go through HttpClient.SendAsync() as shown. HttpClient.SendAsync() captures 429 error and 'Retry-After' values accurately. So it is possible to set up the retry mechanism on the HttpClient.SendAsync(). And Retry is needed only in one place because rest of the methods call this one.
So make sure that only SendAsync() is called for HttpClient request.Code sample is below.
For understanding tokens part , please refer this post - (https://crmdm.blogspot.com/2020/12/dynamics-365-crm-web-api-with-azure.html?m=0 ). You would get a better idea. For unit tests, it's possible to mock the token service.
Maximum Retry is set to 3 in this sample. But feel free to change it accordingly.
using Demo.Core.Helper;
using Demo.Core.Interfaces;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Demo.Core.Services
{
public class HttpClientService : IHttpClientService
{
public HttpClient HttpClient { get; set; }
private readonly ITokenService _tokenService;
private readonly ILogger _logger;
public HttpClientService(ITokenService tokenService, ILogger<HttpClientService> logger)
{
_tokenService = tokenService ?? throw new ArgumentNullException(nameof(tokenService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
HttpClient = new HttpClient();
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = _tokenService.GenerateToken().Result;
HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer",
token);
}
public async Task<HttpResponseMessage> GetAsync(string requestUri)
{
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUri);
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead;
return await SendAsync(request, completionOption);
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseHeadersRead, int retryCount = 0)
{
var maxRetry = 3;
HttpResponseMessage response = null;
try
{
response = await HttpClient.SendAsync(request.Clone(), httpCompletionOption);
}
catch (Exception e)
{
_logger.LogError(e.Message);
}
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode != HttpStatusCode.TooManyRequests)
{
throw new HttpRequestException();
}
else
{
if (++retryCount >= maxRetry)
{
throw new HttpRequestException();
}
var seconds = (response.Headers.Contains("Retry-After"))?
int.Parse(response.Headers.GetValues("Retry-After").FirstOrDefault())
:(int)Math.Pow(2, retryCount);
Thread.Sleep(TimeSpan.FromSeconds(seconds));
return await SendAsync(request, httpCompletionOption, retryCount);
}
}
else
{
return response;
}
}
}
}
As you could see in the code, it sends a request copy rather than actual request. This is because request wouldn't be available if error occurs.A function called Clone takes the copy of the request. This is provided by Microsoft. Didn't make any changes to it. Code snippet is below ( Ref:Microsoft)
using System;
using System.Collections.Generic;
using System.Net.Http;
namespace Demo.Core.Helper
{
/// <summary>
/// Contains extension methods to clone HttpRequestMessage and HttpContent types.
/// </summary>
public static class Extensions
{
/// <summary>
/// Clones a HttpRequestMessage instance
/// </summary>
/// <param name="request">The HttpRequestMessage to clone.</param>
/// <returns>A copy of the HttpRequestMessage</returns>
public static HttpRequestMessage Clone(this HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
{
Content = request.Content.Clone(),
Version = request.Version
};
foreach (KeyValuePair<string, object> prop in request.Properties)
{
clone.Properties.Add(prop);
}
foreach (KeyValuePair<string, IEnumerable<string>> header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
/// <summary>
/// Clones a HttpContent instance
/// </summary>
/// <param name="content">The HttpContent to clone</param>
/// <returns>A copy of the HttpContent</returns>
public static HttpContent Clone(this HttpContent content)
{
if (content == null) return null;
HttpContent clone;
switch (content)
{
case StringContent sc:
clone = new StringContent(sc.ReadAsStringAsync().Result);
break;
default:
throw new Exception($"{content.GetType()} Content type not implemented for HttpContent.Clone extension method.");
}
clone.Headers.Clear();
foreach (KeyValuePair<string, IEnumerable<string>> header in content.Headers)
{
clone.Headers.Add(header.Key, header.Value);
}
return clone;
}
}
}
Wrote a couple of unit tests to test this scenario. Thanks to this post (ref: https://www.c-sharpcorner.com/article/mocking-httpclient-using-xunit-in-net-core/)
using AutoFixture;
using Demo.Core.Interfaces;
using Demo.Core.Services;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace Demo.Core.Unit.Tests
{
public class HttpClientServiceTests
{
private readonly Mock<ILogger<HttpClientService>> _mockLogger;
private readonly Mock<ITokenService> _mockTokenService;
private HttpClientService _sut;
public HttpClientServiceTests()
{
_mockLogger = new Mock<ILogger<HttpClientService>>();
_mockTokenService = new Mock<ITokenService>();
_sut = new HttpClientService(_mockTokenService.Object, _mockLogger.Object);
}
[Fact]
public async Task GetAsyncReturnsSuccessfully()
{
var expected = "I am Tom";
var Result = new StringContent(expected);
var httpMessageHandler = new Mock<HttpMessageHandler>();
var fixture = new Fixture();
httpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
HttpResponseMessage response = new HttpResponseMessage();
response.StatusCode = HttpStatusCode.OK;
response.Content = Result;
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return response;
});
var httpClient = new HttpClient(httpMessageHandler.Object);
httpClient.BaseAddress = fixture.Create<Uri>();
_sut.HttpClient = httpClient;
var response = await _sut.GetAsync(string.Empty);
var actual = await response.Content.ReadAsStringAsync();
Assert.Equal(actual, expected);
}
[Fact]
public async Task GetAsyncReturnsTooManyRequestsThrowsException()
{
var expected = "I am Tom";
var Result = new StringContent(expected);
var httpMessageHandler = new Mock<HttpMessageHandler>();
var fixture = new Fixture();
var waitTimeInSeconds = 5;
httpMessageHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync((HttpRequestMessage request, CancellationToken token) =>
{
HttpResponseMessage response = new HttpResponseMessage();
response.StatusCode = HttpStatusCode.TooManyRequests;
response.Content = Result;
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(waitTimeInSeconds));
return response;
});
var httpClient = new HttpClient(httpMessageHandler.Object);
httpClient.BaseAddress = fixture.Create<Uri>();
_sut.HttpClient = httpClient;
await Assert.ThrowsAsync<HttpRequestException>(() => _sut.GetAsync(string.Empty));
}
}
}
Green is good!
No comments:
Post a Comment