0

I'm using Polly in combination with Microsoft.Extensions.Http.Polly to handle communication with an external API which has rate-limiting (N requests / second).I'm also using .NET 6.

The policy itself works fine for most requests, however it doesn't work properly for sending (stream) data. The API Client requires the usage of MemoryStream. When the Polly policy handles the requests and retries it, the stream data is not sent. I verified this behavior stems from .NET itself with this minimal example:

using var fileStream = File.OpenRead(@"C:\myfile.pdf");
using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);

var response = await httpClient.SendAsync(
    new HttpRequestMessage
    {
        // The endpoint will fail the request on the first request
        RequestUri = new Uri("https://localhost:7186/api/test"),
        Content = new StreamContent(memoryStream),
        Method = HttpMethod.Post
    }
);

Inspecting the request I see that Request.ContentLength is the length of the file on the first try. On the second try it's 0.

However if I change the example to use the FileStream directly it works:

using var fileStream = File.OpenRead(@"C:\myfile.pdf");

var response = await httpClient.SendAsync(
    new HttpRequestMessage
    {
        // The endpoint will fail the request on the first request
        RequestUri = new Uri("https://localhost:7186/api/test"),
        Content = new StreamContent(fileStream ),
        Method = HttpMethod.Post
    }
);

And this is my Polly policy that I add to the chain of AddHttpClient.

public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return Policy
        .HandleResult<HttpResponseMessage>(response =>
        {
            return response.StatusCode == System.Net.HttpStatusCode.Forbidden;
        })
        .WaitAndRetryAsync(4, (retry) => TimeSpan.FromSeconds(1));
}

My question:

How do I properly retry requests where StreamContent with a stream of type MemoryStream is involved, similar to the behavior of FileStream?

Edit for clarification:

I'm using an external API Client library (Egnyte) which accepts an instance of HttpClient

public class EgnyteClient {
   public EgnyteClient(string apiKey, string domain, HttpClient? httpClient = null){
   ...
   }
}

I pass an instance which I injected via the HttpContextFactory pattern. This instance uses the retry policy from above.

This is my method for writing a file using EgnyteClient

public async Task UploadFile(string path, MemoryStream stream){
   // _egnyteClient is assigned in the constructor
   await _egnyteClient.Files.CreateOrUpdateFile(path, stream);
}

This method call works (doesn't throw an exception) even when the API sometimes returns a 403 statucode because the internal HttpClient uses the Polly retry policy. HOWEVER the data isn't always properly transferred since it just works if it was the first attempt.

Eddi
  • 782
  • 1
  • 7
  • 20

1 Answers1

1

The root cause of your problem could be the following: once you have sent out a request then the MemoryStream's Position is at the end of the stream. So, any further requests needs to rewind the stream to be able to copy it again into the StreamContent (memoryStream.Position = 0;).


Here is how you can do that with retry:

private StreamContent GetContent(MemoryStream ms)
{
   ms.Position = 0;
   return new StreamContent(ms);
}

var response = await httpClient.SendAsync(
    new HttpRequestMessage
    {
        RequestUri = new Uri("https://localhost:7186/api/test"),
        Content = GetContent(memoryStream),
        Method = HttpMethod.Post
    }
);

This ensures that the memoryStream has been rewinded for each each retry attempt.


UPDATE #1 After receiving some clarification and digging in the source code of the Egnyte I think I know understand the problem scope better.

  • A 3rd party library receives an HttpClient instance which is decorated with a retry policy (related source code)
  • A MemoryStream is passed to a library which is passed forward as a StreamContent as a part of an HttpRequestMessage (related source code)
  • HRM is passed directly to the HttpClient and the response is wrapped into a ServiceResponse (related source code)

Based on the source code you can receive one of the followings:

  • An HttpRequestException thrown by the HttpClient
  • An EgnyteApiException or QPSLimitExceededException or RateLimitExceededException thrown by the ExceptionHelper
  • An EgnyteApiException thrown by the SendRequestAsync if there was a problem related to the deserialization
  • A ServiceResponse from SendRequestAsync

As far as I can see you can access the StatusCode only if you receive an HttpRequestException or an EgnyteApiException.

Because you can't rewind the MemoryStream whenever an HttpClient performs a retry I would suggest to decorate the UploadFile with retry. Inside the method you can always set the stream parameter's Position to 0.

public async Task UploadFile(string path, MemoryStream stream){
   stream.Position = 0;
   await _egnyteClient.Files.CreateOrUpdateFile(path, stream);
}

So rather than decorating the entire HttpClient you should decorate your UploadFile method with retry. Because of this you need to alter the policy definition to something like this:

public static IAsyncPolicy GetRetryPolicy()
    => Policy
        .Handle<EgnyteApiException>(ex => ex.StatusCode == HttpStatusCode.Forbidden)
        .Or<HttpRequestException>(ex => ex.StatusCode == HttpStatusCode.Forbidden)
        .WaitAndRetryAsync(4, _ => TimeSpan.FromSeconds(1));

Maybe the Or builder clause is not needed because I haven't seen any EnsureSuccessStatusCode call anywhere, but for safety I would build the policy like that.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • That would work if I had control over the method, but I'm using an external API client which accepts a `HttpClient` instance in its constructor. Afterwards I'm calling a method `Task UploadFile(string path, MemoryStream stream)` where I don't control what's happening inside. I guess I am looking for a Polly specific thing, like modification of the HttpRequestMessage before sending again. – Eddi Nov 11 '22 at 08:50
  • @Eddi Sorry but I'm confused. Are you saying that you have control over the registration of the `HttpClient` (w/o policies) but you don't have control over how the `SendAsync` is being called? – Peter Csala Nov 11 '22 at 09:48
  • I added a bit more context for clarification to my question. – Eddi Nov 11 '22 at 10:24
  • @Eddi In your example code you use are using the `MemoryStream` to copy data from `FileStream`. In your `UploadFile` why do you need to pass a filepath and a `MemoryStream` as well? – Peter Csala Nov 11 '22 at 10:42
  • @Eddi I've just checked the [source code](https://github.com/egnyte/egnyte-dotnet/blob/3b2686b51559465dd2b653564665743fb90d1652/Egnyte.Api/Files/FilesClient.cs#L56) and now I see how it works. Let me update my post accordingly. – Peter Csala Nov 11 '22 at 10:50
  • 1
    I tried to show the abstract problem independently of my concrete problem. The excerpt with `FileStream` was to demonstrate that it works with other kind of streams. So again, I imagined that it could be solved by something inside the policy or at least I would imagine that would be the cleanest way to do this. For now I use a workaround and create two separate clients. One client will use the policy. The other will not use it, so that I can wrap it with a separate retry policy. – Eddi Nov 11 '22 at 10:55
  • @Eddi I've updated my post, please check it. – Peter Csala Nov 11 '22 at 11:31
  • 1
    I think you came up with the best solution for this scenario. Great answer. – Eddi Nov 11 '22 at 11:36