Search  
Always will be ready notify the world about expectations as easy as possible: job change page
yesterday

HTTP Client in C#: Best Practices for Experts

HTTP Client in C#: Best Practices for Experts
Author:
Source:
Views:
93
HTTP Client in C#: Best Practices for Experts favorites 0

As you know, when you cruise through websites, your browser sends HTTP requests.

HTTP request response

However, it is also possible, to send HTTP requests manually from one server to another, which in C# can be done with the help of HttpClient.

Manual HTTP request

This is often useful in a microservices environment, or when integrating with third-party applications.

In case you have a web server, that implements RESTful-API, you can send a GET request and parse JSON response in a few lines:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", async () =>
{
    // magic happens here
    HttpClient client = new HttpClient();
    GetQuotesResponse response = await client.GetFromJsonAsync<GetQuotesResponse>("https://dummyjson.com/quotes");

    return response;
});

app.Run();

But, as always, everything is more complicated 😅.

In C#, working with HttpClient requires understanding how to create it correctly, implementing middleware, ensuring resilience, handling retries, using circuit-breaker, and optimizing request execution.

Think you know it all? Read to the end and let me prove you wrong 😉. Let’s dive in!

• • •

Create HTTP client

You need to know how to create HttpClient properly before using it. There are a few options, with its own pros and cons:

  • Constructor
  • Static instance
  • IHttpClientFactory
  • Named clients
  • Typed clients
  • Generated clients

Let’s discuss those one by one.

Constructor

The simplest way to work with the HttpClient is just to create a new instance and .Dispose() it in the end.

app.MapGet("/", () =>
{
    HttpClient client = new HttpClient();
              . . .
    client.Dispose();
});

However, HttpClient is a bit special, and is not disposed properly 🤪.

With each HttpClient instance a new HTTP connection is created. But even when the client is disposed, the TCP socket is not immediately released. If your application constantly creates new connections, it can lead to the exhaustion of available ports.

Therefore, HttpClient is intended to be instantiated once per application, rather than per-use.

Static instance

A bit better approach is to create one HttpClient and reuse it.

static readonly HttpClient client = new HttpClient();

app.MapGet("/", async () =>
{
    var response = await client.GetAsync("https://dummyjson.com/quotes");
              . . .
});

However, there is still an issue with DNS changes.

HttpClient only resolves DNS entries when a connection is created. If DNS entries change regularly, the client won’t respect those updates.

You can set a connection lifetime, so it is recreated from time to time:

var handler = new SocketsHttpHandler
{
    // recreate every 2 minutes
    PooledConnectionLifetime = TimeSpan.FromMinutes(2)
};
var sharedClient = new HttpClient(handler);

IHttpClientFactory

While static instance is great it is often hard to mock in unit tests 😒.

It is recommended to use an IHttpClientFactory to create the HttpClient instance.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient(); // 👈 register a factory

var app = builder.Build();

app.MapGet("/quotes", async ([FromServices] IHttpClientFactory factory) =>
{
    var client = factory.CreateClient(); // 👈 resolve a client
    var response = await client.GetFromJsonAsync<GetQuotesResponse>("https://dummyjson.com/quotes");

    return response;
});

app.Run();

It will manage the HttpClient lifetime, properly reuse and dispose TCP sockets, handle DNS changes, and so on.

Named clients

Factory is great, however, when the client is used in multiple places you can notice code duplication. The same URL address, HTTP headers, authorization token, and so on can be reused across clients.

Instead of configuring them for each client, you can use named clients:

var builder = WebApplication.CreateBuilder(args);
builder
    .Services
    .AddHttpClient("DummyJson", configureClient => // 👈 configure a client
    {
        configureClient.BaseAddress = new Uri("https://dummyjson.com");

        configureClient.DefaultRequestHeaders.Add(HeaderNames.Accept, MediaTypeNames.Application.Json);
        // configureClient.DefaultRequestHeaders.Accept.Add(MediaTypeNames.Application.Json);
        
        configureClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "your-token-here");
    });

var app = builder.Build();

app.MapGet("/", async ([FromServices] IHttpClientFactory factory) =>
{
    var client = factory.CreateClient(name: "DummyJson"); // 👈 resolve the client by name
    var response = await client.GetFromJsonAsync<GetQuotesResponse>("quotes");

    return response;
});

app.Run();

Notice, that this time, the client is resolved by its registered name.

Typed clients

There are still some improvements we would like to make:

  • instead of resolving clients by strings, we would like to inject them as other services through DI;
  • we want to encapsulate all logic in a single place, instead of scattering it around;
  • we need to ease code navigation.

This can be done with a typed client. It is just a simple class, that inject HttpClient in its constructor:

class DummyJsonHttpClient
{
    private readonly HttpClient _httpClient;

    public DummyJsonHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient;

        _httpClient.BaseAddress = new Uri("https://dummyjson.com");
    }

    public Task<GetQuotesResponse> GetQuotes()
    {
        return _httpClient.GetFromJsonAsync<GetQuotesResponse>("quotes");
    }
}

Later, this class has to be registered with .AddHttpClient<T>():

var builder = WebApplication.CreateBuilder(args);
builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>(); // 👈 register a client

var app = builder.Build();

app.MapGet("/", async ([FromServices] DummyJsonHttpClient apiClient) => // 👈 resolve the client
{
    var response = await apiClient.GetQuotes();

    return response;
});

app.Run();

Notice:

  • you can write additional custom logic in the typed clients, like exception handling, request tracing, and so on
  • typed client can inherit an interface, to ease mocking in the unit tests:
interface IDummyJsonHttpClient
{
    Task<GetQuotesResponse> GetQuotes();
}

class DummyJsonHttpClient: IDummyJsonHttpClient { . . . } // 👈 inheritance

builder
    .Services
    .AddHttpClient<IDummyJsonHttpClient, DummyJsonHttpClient>(); // 👈 register the client
  • you can set configuration during client registration in the DI (which is more preferable):
builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>(x =>
    {
        x.BaseAddress = new Uri("https://dummyjson.com");
    })
  • typed clients are also registered by name and can be resolved in the same way:
// resolves configured HttpClient from the DI
HttpClient typedClient = clientFactory.CreateClient(nameof(DummyJsonHttpClient));

This way we can obtain the configured HttpClient.

  • typed clients can also be resolved with typed factory:
app.MapGet("/", async (
    [FromServices] IHttpClientFactory clientFactory,
    [FromServices] ITypedHttpClientFactory<DummyJsonHttpClient> typedHttpClientFactory) =>
{
    // get configured HttpClient from the DI
    HttpClient httpClient = clientFactory.CreateClient(nameof(DummyJsonHttpClient));
    
    // type-safe client
    // calls constructor of DummyJsonHttpClient
    DummyJsonHttpClient typedClient = typedHttpClientFactory.CreateClient(httpClient);
}

This time we resolve DummyJsonHttpClient and not just its underlying HttpClient.

  • typed clients are registered with transient lifetime;
  • if you inject a transient typed client into a singleton service, it will become singleton and won’t be properly disposed. Therefore you should use the factory in this case.

Generated clients

This is not officially supported, but there is one cool NuGet you should be aware of. It is called Refit.

It allows you to declare an interface and it will generate a typed HttpClient.

interface IDummyJsonHttpClient
{
    [Get("/quotes")]
    Task<GetQuotesResponse> GetQuotes();
}

The interface, of course, needs to be registered before the usage:

var builder = WebApplication.CreateBuilder(args);
builder
    .Services
    .AddRefitClient<IDummyJsonHttpClient>() // 👈 register a client
    .ConfigureHttpClient(c =>               // 👈 add additional configurations
    {
        c.BaseAddress = new Uri("https://dummyjson.com");
    });

var app = builder.Build();

app.MapGet("/", async ([FromServices] IDummyJsonHttpClient apiClient) =>
{
    var response = await apiClient.GetQuotes();

    return response;
});

app.Run();

What can I say? It simplifies client creation and looks cool 🙃.

However, there are some problems I see with this NuGet:

  • it is an additional dependency, your developers' team should learn;
  • it is harder to add custom logic to your HTTP client;
  • usually, you have a pair of abstraction and implementation (IDummyJsonApiClient, DummyJsonHttpClient). In case the underlying connection layer changes, you only need to update the implementation, keeping all places using abstraction the same. With Refit you have an interface (IDummyJsonHttpClient), but this is not an abstraction. You are tightly coupled to the HTTP communication;
  • from the architectural point of view, it is not clear where to place IDummyJsonHttpClient. Is it part of the Application layer or the Infrastructure one?

As always, the final choice is yours. Still, I felt, I had to mention it 😬.

• • •

Middleware

Typed clients are excellent because they allow you to encapsulate error handling, logging, caching, or any custom logic in a single, reusable component.

However, there is another technique to reuse cross-cutting concerns across different HttpClients and make them neat.

For example, for every HTTP call, you would like to get or refresh the access token and log the time it took to execute the request.

It can be done with decorators. This way you to extend all methods with additional functionality that runs either before or after the original method call.

Access token

Simply add the corresponding DelegatingHandler:

public class PerformanceAuditorDelegatingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine($"Start {request.RequestUri} at {DateTime.UtcNow}");

        // original call
        var response = await base.SendAsync(request, cancellationToken);

        Console.WriteLine($"Finish {request.RequestUri} at {DateTime.UtcNow}");

        return response;
    }
}

That later will be registered in the DI and added to the HttpClient request pipeline:

builder
    .Services
    .AddTransient<PerformanceAuditorDelegatingHandler>() // 👈
    .AddHttpClient<DummyJsonHttpClient>()
    .AddHttpMessageHandler<PerformanceAuditorDelegatingHandler>(); // 👈

By the way, you do not necessarily have to enrich original calls. You can simply block or override all requests, which is often useful in integration tests.

public class RequestBlockingDelegatingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = new
        {
            Status = "Blocked",
        };

        return new HttpResponseMessage()
        {
            StatusCode = HttpStatusCode.OK,
            Content = JsonContent.Create(response),
        };
    }
}

Delegating Handler Lifetime

Another section with the asterisk. You can skip it since it is only for professionals 😁.

You might notice that the handler is registered as transient, yet it won’t be recreated for each request, which is unusual. You can clearly see that by adding a state, that will be persisted for some time.

public class StatefulDelegatingHandler : DelegatingHandler
{
    public int i = 0; // state will be persited
      
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        i++;       

        return await base.SendAsync(request, cancellationToken);
    }
}

Let’s try to understand what is going on.

Each time the request is made, it goes through a pipeline of multiple DelegatingHandlers.

It would be inefficient to recreate the pipeline on every call, therefore handlers are created when HttpClient is resolved and temporarily stored in a cache, for 2 minutes.

DelegatingHandlers

You can change handlers lifetime if needed:

builder
    .Services
    .AddTransient<StatefulDelegatingHandler>()
    .AddHttpClient<DummyJsonHttpClient>()
    .AddHttpMessageHandler<StatefulDelegatingHandler>()
    .SetHandlerLifetime(TimeSpan.FromSeconds(30)); // 👈

This allows a transient handler to live a bit longer and persist a state. Great!

Now, some developers may have a temptation to change its lifecycle to be a singleton:

builder
    .Services
    .AddSingleton<StatefulDelegatingHandler>() // 👈
    .AddHttpClient<DummyJsonHttpClient>()
    .AddHttpMessageHandler<StatefulDelegatingHandler>();

This will result in InvalidOperationException. Lame 😒.

Each handler references the next one in the pipeline through the InnerHandler property. If a DelegatingHandler is registered as a singleton, the InnerHandler property will remain set causing the exception during pipeline creation.

Summing Up:

  • DelegatingHandler must be registered as a transient;
  • even though it is transient, it will be cached and reused for 2 minutes;
  • you can have a state in your handler that will be persisted for a short period (but it is better not to, since it will just cause bugs);
  • be aware of this behavior when you inject services with state in a handler;
  • you can change the handlers' lifetime;
  • set the lifetime to Timeout.InfiniteTimeSpan to disable handler expiry;
  • pooling of handlers is desirable as each handler typically manages its own underlying HTTP connections.

• • •

Making HttpClient resilient

Did you think that learning how to create an HttpClient is all you need to start using it? Oh silly you are 😌. Network communication is unreliable, therefore you need to make your HttpClient resilient.

Resiliency refers to the ability of an application to continue functioning correctly even in the face of failures.

When you have one service calling another, there are multiple issues you can encounter:

  • the request may take too long;
  • the request may fail;
  • the server may be slow or not available at all;
  • etc.

Services

Therefore, you should learn about:

  • request timeout;
  • retries;
  • circuit-breaker.

There is another popular NuGet, called Polly, which allows you to write resilient code in a fluent manner:

var pipeline = new ResiliencePipelineBuilder()
    .AddTimeout(TimeSpan.FromSeconds(10))
    .Build();

await pipeline.ExecuteAsync(() =>
{
    // your algorithm goes here
});

Microsoft saw how good it is, and decided to use Polly in their own NuGet (Microsoft.Extensions.Http.Resilient) that adds resiliency to HttpClient.

Let’s see it in practice.

Request timeout

When a user clicks a button, he does not care whether you are performing complex computations or calling an external service. The user just expects to see a response right away.

However, when an external service is overloaded and the request takes too long, it will cause slowness in your application.

Service execution time

Therefore you should set a timeout of how long you can wait for the response:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2)); // 👈
    });

If the request takes longer, it will be aborted and considered as a failed one.

Retries

We can face another issue. The response is returned in an acceptable interval, however, it failed with an error.

Service error

In that case, it is worth try sending the same request again.

With retries you need to consider the following factors:

  • which status codes should be retried;
  • number of retries;
  • interval between retries;
  • idempotency.

Status code to retry

HTTP’s status code indicates the result of the request, whether it was successful or encountered an error. The codes are grouped into five categories based on their first digit:

Status codes

And only 4XX and 5XX groups indicate an error.

It is important to understand which codes should be retried.

If the request is returned with client error (4XX), it means that it was poorly formed. Parameters are invalid, the authorization token is missing, the validation failed, and so on. Retrying such a request will likely result in the same error.

Therefore you should not retry the 4XX group. But there are exceptions:

  • 408 Request Timeout. This status code indicates that the server did not complete the request within the expected time. Retrying after a reasonable delay might help;
  • 429 Too Many Requests. If you encounter this status code, it means you’re making too many requests in a given time frame. Retrying after some period can help avoid hitting rate limits.

On the other hand, server errors (5XX), typically means temporary issues that will be resolved on a retry (except 503 Service Unavailable 😅).

In practice, you don’t have to think about which status codes to retry, because Microsoft already implemented that 🙏:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions()); // 👈
    });

Number of retries

It’s often a good practice to set a limit on the number of retry attempts to prevent infinite retry loops in case of persistent failures.

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3, // 👈
        });
    });

Of course, in case you are integrating with a third party, you should take into account the number of requests you send, the rate limits it imposes, and the potential impact on performance and costs.

Retries interval

We agreed on a number of retry attempts. It is also important to choose a delay between retries.

Each retry can happen after a constant timeout, let’s say 20ms.

Retries interval

This can be easily set in our retry policy:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Constant, // 👈
            Delay = TimeSpan.FromMilliseconds(20),   // 👈
        });
    });

It is simple, but not very effective.

If the server returns an error, retrying after a short delay can help quickly obtain a response from the server.

However, if you fail on a second attempt, on the third one, it usually means the server is overloaded, and spamming it with more requests just worsens the situation.

So, if the client “sees” that the server is unavailable, but it desperately needs a response, it is better to wait for a longer period.

It is recommended to increase the delay between each retry linearly or exponentially. This way you wait 20ms, then 40ms, then 80ms, and so on.

Retry delays

Let’s adjust our strategy a bit:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Linear, // 👈
            Delay = TimeSpan.FromMilliseconds(20),
        });
    });

Even though this strategy is a bit better, it still has some drawbacks.

Picture this: your server goes down entirely. Multiple HttpClients begin retrying their requests, and these retries align in time, generating peak load moments on the already overwhelmed server. This can potentially lead to a self-DDoS attack.

Self-DDoS

You need to add a random delay to each retry, also known as jitter.

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Linear,
            Delay = TimeSpan.FromMilliseconds(20),
            UseJitter = true, // 👈
        });
    });

Idempotency

When implementing retries you should remember that some HTTP methods may have side effects.

According to REST, you can classify any operation as Create, Read, Update, or Delete (CRUD).

CRUD

Let’s say we receive 408 Request Timeout from a server. It does not mean that the request was not handled. It just indicated, that we no longer wait for a response.

As expected our client will do the retry. The behavior will be different depending on the operation:

  • DELETE — on the first attempt, the server will delete the resource, and on the second retry, there will be nothing to delete, so the retry should not harm our server.
  • PUT — generally, the update operation is not harmful. For example, if you send the same data multiple times, the server should overwrite the resource with identical values.
  • GET — it does not matter, how many times you retrieve the data as fetch is a read-only, side-effect-free operation.
  • POST — retrying the request responsible for creation could result in duplicate entries. 😖

Therefore, the server should ensure idempotency.

Idempotency is a property of an operation, which guarantees that performing the operation multiple times will yield the same result as if it was executed only once.

Or in plain English, if you have an API endpoint to pay for an order and unpetient users click 10 times, only 1 payment will be charged.

Typically, clients should include a requestId with each request, enabling the server to maintain a record of processed requests:

static List<string> HandledRequests = new List<string>();

                      .   .   .

[HttpPost]
public void CreateNewUser(string userName, string requestId)
{
    // Check if the request has already been processed
    var isAlreadyProcessed = HandledRequests.Contains(requestId);
    if (isAlreadyProcessed)
    {
        return;
    }

    // Mark the requestId as processed
    HandledRequests.Add(requestId);

    // Your logic for creating a new user
}

We won’t discuss idempotency in detail here. It is another big topic that deserves its own article 🙃. Just remember, that if your server does not implement idempotency (which is often the case with third-party apps), retries can cause more issues than solve.

Circuit-Breaker

So, we have timeout and retries. Still, there is another known problem.

Let’s say our server is overwhelmed with requests and it takes time to respond.

Sure we have a timeout, however, during the waiting period, the client continues sending requeues and also runs out of resources, such as memory, TCP connections, available threads, and so on.

Over time the client will deplete its resources and also fail.

Services requests

Sometimes it is better to fail fast instead of trying to send requests that will allocate the client’s resources and fail anyway.

It is solved with the Circuit-Breaker pattern. The idea is as follows:

  • we add a proxy that will gather the statistics about the number of failed requests:

Proxy Service

  • if at some point we realize that the server has stopped responding, we should stop all requests for a while. This is known as open-state:

Proxy Service

  • from time to time, our proxy goes to a half-open state. It allows some requests through just to verify whether the server has recovered or not:

Proxy Service

  • in case, the server starts responding at a reasonable rate, we can get back to the close state when there is no gap in the connection line:

Proxy Service

The good news here is that we don’t need to implement Circuit-Breaker from scratch or add any proxy service here. As before, everything can be done with one configuration line 😁:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddResilienceHandler("default", configure =>
    {
        configure.AddTimeout(TimeSpan.FromSeconds(2));

        configure.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Constant,
            Delay = TimeSpan.FromMilliseconds(500),
            UseJitter = true,
        });

        configure
          .AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions()); // 👈
    });

You can tune parameters more granularly, depending on your needs, but I will leave it as is because I am lazy 🙃.

Finishing the resiliency pipeline

As you can see, configuring the resiliency pipeline is complex. Microsoft also knows that it is quite easy to f#ck it up 😅. You can set policies in the wrong order, set invalid parameters, and so on. Therefore, they give us an extension method, that will register standard policies:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddStandardResilienceHandler(); // 👈

They look like this:

Resiliency Pipeline

Still, it is possible to tune some settings, if needed:

builder
    .Services
    .AddHttpClient<DummyJsonHttpClient>()
    .AddStandardResilienceHandler()
    .Configure(options =>
    {
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(5);

        options.Retry.MaxRetryAttempts = 5;

        options.CircuitBreaker.FailureRatio = 0.9;
        options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(5);      
    });

• • •

Issuing HTTP request

Finally, when all the configurations are done, we can send the HTTP requests 😌.

These days, you simply call the appropriate method (e.g., .GetAsync(), .PostAsync() ), specify the correct URL, provide a body to be serialized into JSON, and deserialize the response.

var request = new
{
    Message = "Hello world",
};

var response = await client.PostAsJsonAsync("https://echo.free.beeceptor.com", request);

var result = await response.Content.ReadFromJsonAsync<TestServerResult>();

This hasn’t always been the case. If you’re using a different serializer, such as Newtonsoft.Json, or working with legacy code, you might encounter something like this.

var request = new
{
    Message = "Hello world",
};

var serializedRequest = JsonSerializer.Serialize(request);

var stringContent = new StringContent(serializedRequest, Encoding.UTF8, MediaTypeNames.Application.Json);

var response = await _client.PostAsync("https://echo.free.beeceptor.com", stringContent);

var stringResponse = await response.Content.ReadAsStringAsync(); // 👈

var result = JsonSerializer.Deserialize<TestServerResult>(stringResponse);

In this example, we:

  1. create a request object.
  2. serialize it to JSON.
  3. build an HTTP body using StringContent, setting the correct encoding and media type.
  4. send the HTTP request, where HttpClient waits for the entire response and writes it to an internal MemoryStream buffer.
  5. then, the response content is read as a string.
  6. finally, we deserialize that string to type-safe object.

Developers often make a common mistake here at step 5. They read the entire response body as the string, deserialize it to JSON, and then never use that string again. This adds unnecessary performance overhead and allocates memory for an object that will eventually need to be cleaned up.

You can read and deserialize directly from the MemoryStream:

var streamResponse = await response.Content.ReadAsStreamAsync();
var result = await JsonSerializer.DeserializeAsync<TestServerResult>(streamResponse);

It is less convenient during debugging since you don’t see the received response, but is more performant.

However, MemoryStream is not a real stream 🙃. It holds the entire response body. It is basically byte[]. We can continue making improvements here.

Among those .GetAsync(), .PostAsync(), there is one generic method .SendAsync().

public class HttpClient : IDisposable
{
              .  .  .     

    public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, HttpCompletionOption completionOption)

              .  .  .
}

It is interesting, because of the following reasons:

  • you can send request of any type (e.g. GET, POST, DELETE, …).
  • its second parameter is HttpCompletionOption.

HttpCompletionOption is just an enum with two options that determine response behavior:

public enum HttpCompletionOption
{
    ResponseContentRead = 0,
    ResponseHeadersRead = 1
}
  • ResponseContentRead (default)— reads the entire response payload into the buffer.
  • ResponseHeadersRead — as soon as headers are read, you can read the response’s body in chunks as a stream. 😌
var request = new
{
    Message = "Hello world",
};

var jsonContent = JsonContent.Create(request); // 👈 notice, JsonContent instead of StringContent var requestMessage = new HttpRequestMessage()
{
    Method = HttpMethod.Post,
    Content = jsonContent,
    RequestUri = new Uri("https://echo.free.beeceptor.com"),
};

var response = await client.SendAsync(
    requestMessage,
    HttpCompletionOption.ResponseHeadersRead // 👈
);

var streamResponse = await response.Content.ReadAsStreamAsync(); // ChunkedEncodingReadStream instead of MemoryStream 😌 var result = await JsonSerializer.DeserializeAsync<TestServerResult>(streamResponse);

This means that we are going to use less memory and will process data faster since we don’t have to keep the entire content in the buffer.

By the way, before consuming the stream, you still can investigate the response and its headers:

var response = await client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

response.EnsureSuccessStatusCode();

if ((int)response.StatusCode > 400)
{
    // custom error handling logic
}

• • •

Canceling request

With all those await operators, you may have a temptation to stick a CancellationToken there 😁:

var response = await _client.PostAsync("https://echo.free.beeceptor.com", stringContent, cancellationToken);

var streamResponse = await response.Content.ReadAsStreamAsync(cancellationToken);

While it may seem like a performance saver at first, cancellation is only recommended alongside GET operation.

As soon as you have POST, PUT, DELETE, etc, if the server begins processing a request but does not implement transactions, canceling the request might leave the server in an inconsistent state.

Moreover, ensuring transactional behavior across multiple HTTP calls is challenging for the client application too.

If your algorithm spam multiple HTTP requests, do not forget to define a compensating action to reverse it if needed (e.g., refunding a payment if an order creation fails).

try
{
    await httpClient.PostAsync("https://service1.com/operation", content1);
    await httpClient.PostAsync("https://service2.com/operation", content2);
}
catch (Exception)
{
    await httpClient.PostAsync("https://service1.com/rollback", rollbackContent1);
    throw;
}

Remember, you can implement transactional-like behavior by employing strategies that ensure consistency, like Outbox pattern, Saga, compensation actions, two-phase commit, and so on.

• • •

Last words

As you can see, working with something as simple as HttpClient requires advanced skills and knowledge 😤.

🐞 When you instantiate a new instance you should remember about port exhaustion and DNS change problems.

🏭 It is preferable to use typed clients, however, in case, the dependency injection scope is not aligned with transient HttpClient it is preferable to use a factory or a static client.

🔗 You can leverage the middleware chain to handle cross-cutting concerns like logging, authentication, or retries.

🦾 Resiliency policies are useful, but you should ask yourself:

  • what is better for my application: to fail fast or to retry?
  • can I retry, or will it hit the API limit?
  • what errors and status codes to retry?
  • does the server support idempotency?
  • would retry cause a DDoS attack?
  • do I need a circuit-breaker, or will it make my system more unstable?

⚠️ This is especially important when integrating with third-party services, as you don’t have access to their code, and they are often limited in those regards.

⚡️ Avoid reading the response body as a string to improve performance. If performance is critical, consider using streams, but make sure you understand how they work 😉.

✖️ Before canceling HTTP requests, ensure, that servers properly handle client disconnections or cancellations or provide compensating actions (this is important with third-party apps where you cannot easily recover in case of failure).

Consider all those factors, to make the best HttpClient in the world 😁:

418 status code

Similar
Apr 4, 2024
Author: João Simões
Performance comparison between ToList and ToArray Ever since Microsoft introduced Language Integrated Query to the .NET framework (also known as LINQ) developers have been using it extensively to work with collections. From a simple filter, to an aggregation, to a...
Jan 10, 2024
Author: MESCIUS inc.
In today’s connected world, data is piling up very fast. We are generating more data than ever in human history, and a lot of this data is getting stored in non-relational formats like JSON documents. JSON has become a ubiquitous...
May 31, 2023
Author: Anton Selin
LINQ (Language Integrated Query) is a powerful querying tool in .NET that allows you to perform complex queries directly in C#. The System.Linq.Expressions namespace is a part of LINQ that provides classes, interfaces, enumerations and structures to work with lambda...
Aug 11, 2021
Author: Mel Grubb
Code Generation Code generation is a great way to apply patterns consistently across a solution or to create multiple similar classes based on some outside input file, or even other classes in the same solution. The tooling has changed over...
Send message
Type
Email
Your name
*Message