"тнιѕ вℓσg ¢συℓ∂ ѕανє уσυя мσηєу ιƒ тιмє = мσηєу" - ∂.мαηנαℓу
Showing posts with label Dynamics 365 CRM API Limits. Show all posts
Showing posts with label Dynamics 365 CRM API Limits. Show all posts

Wednesday, 30 December 2020

Tips for Implementing Service Protection API Limits with Dynamics 365 CRM Web API Scenario

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 requestsThe number of concurrent requests made by the user52"

( 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!

Sunday, 29 November 2020

Dynamics 365 CRM API Limits

Dynamics 365 API limit is a big topic. It was a bit confusing to understand the whole thing. From my understanding, there are two types of API limits. Please refer Microsoft documentation links for accurate info. This post is based on the info that I collected upto November 2020. Micorosoft might change these in the future.

1) Requests limits & allocation 

 This one, your system wouldn't throttle so operations can continue without issues. However there may be cost implications if you exceed the daily API limit. Might need to buy API Limit licenses( new license introduced by Microsoft). It includes most of the operations like plugins, custom workflow activity etc. 

For instance, Dynamics 365 Enterprise application user license has a limit of 20000 API requests per 24 hours

More details are in the below link.

https://docs.microsoft.com/en-us/power-platform/admin/api-request-limits-allocations

2) Service Protection API limits

This one , your system would throttle if you exceed this sliding window limit( 5minutes). So it is necessary that a retry mechanism should be in place ( For both connection types-  organisation service SOAP Api and CRM Web Api)  It excludes plugins, custom workflow activity. It is explained in the below link.

"The service protection API limits are evaluated within a 5 minute (300 second) sliding window. If any of the limits are exceeded within the preceding 300 seconds, a service protection API Limit error will be returned on subsequent requests to protect the service until the Retry-After duration has ended." 


MeasureDescriptionLimit per web server
Number of requests The cumulative number of requests made by the user. 6000 withing 5min sliding window
Execution time The combined execution time of all requests made by the user. 20 minutes (1200 seconds)
Number of concurrent requestsThe number of concurrent requests made by the user52

Ref:https://docs.microsoft.com/en-us/powerapps/developer/common-data-service/api-limits#retry-operations